feat(platform): harden access scoping and delivery baseline
This commit is contained in:
@@ -0,0 +1,86 @@
|
|||||||
|
name: Deploy Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
image_tag:
|
||||||
|
description: Image tag to promote to production, for example sha-<commit>
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy To Production
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: production
|
||||||
|
timeout-minutes: 30
|
||||||
|
concurrency:
|
||||||
|
group: deploy-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- id: vars
|
||||||
|
name: Resolve image refs
|
||||||
|
run: |
|
||||||
|
owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
repo="$(basename '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
image_tag="${{ inputs.image_tag }}"
|
||||||
|
echo "app_image=ghcr.io/${owner}/${repo}-app:${image_tag}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "migrator_image=ghcr.io/${owner}/${repo}-migrator:${image_tag}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Prepare SSH
|
||||||
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_KEY }}
|
||||||
|
SSH_HOST: ${{ secrets.PROD_SSH_HOST }}
|
||||||
|
SSH_PORT: ${{ secrets.PROD_SSH_PORT }}
|
||||||
|
run: |
|
||||||
|
install -m 700 -d ~/.ssh
|
||||||
|
printf '%s\n' "${SSH_PRIVATE_KEY}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -p "${SSH_PORT:-22}" -H "${SSH_HOST}" >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Bundle deploy assets
|
||||||
|
run: tar czf deploy-bundle.tgz docker-compose.cicd.yml tooling/deploy
|
||||||
|
|
||||||
|
- name: Copy deploy assets to production
|
||||||
|
env:
|
||||||
|
SSH_PORT: ${{ secrets.PROD_SSH_PORT }}
|
||||||
|
run: |
|
||||||
|
ssh -p "${SSH_PORT:-22}" "${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }}" \
|
||||||
|
"mkdir -p '${{ secrets.PROD_DEPLOY_PATH }}'"
|
||||||
|
scp -P "${SSH_PORT:-22}" deploy-bundle.tgz \
|
||||||
|
"${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }}:${{ secrets.PROD_DEPLOY_PATH }}/deploy-bundle.tgz"
|
||||||
|
|
||||||
|
- name: Run production deployment
|
||||||
|
env:
|
||||||
|
APP_IMAGE: ${{ steps.vars.outputs.app_image }}
|
||||||
|
MIGRATOR_IMAGE: ${{ steps.vars.outputs.migrator_image }}
|
||||||
|
APP_HOST_PORT: ${{ secrets.PROD_APP_HOST_PORT }}
|
||||||
|
GHCR_USERNAME: ${{ secrets.PROD_GHCR_USERNAME }}
|
||||||
|
GHCR_TOKEN: ${{ secrets.PROD_GHCR_TOKEN }}
|
||||||
|
SSH_PORT: ${{ secrets.PROD_SSH_PORT }}
|
||||||
|
run: |
|
||||||
|
cat > deploy.env <<EOF
|
||||||
|
APP_IMAGE=${APP_IMAGE}
|
||||||
|
MIGRATOR_IMAGE=${MIGRATOR_IMAGE}
|
||||||
|
APP_HOST_PORT=${APP_HOST_PORT}
|
||||||
|
GHCR_USERNAME=${GHCR_USERNAME}
|
||||||
|
GHCR_TOKEN=${GHCR_TOKEN}
|
||||||
|
EOF
|
||||||
|
scp -P "${SSH_PORT:-22}" deploy.env \
|
||||||
|
"${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }}:${{ secrets.PROD_DEPLOY_PATH }}/deploy.env"
|
||||||
|
ssh -p "${SSH_PORT:-22}" "${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }}" 'bash -se' <<'EOF'
|
||||||
|
set -euo pipefail
|
||||||
|
cd "${{ secrets.PROD_DEPLOY_PATH }}"
|
||||||
|
tar xzf deploy-bundle.tgz
|
||||||
|
rm -f deploy-bundle.tgz
|
||||||
|
set -a
|
||||||
|
. ./deploy.env
|
||||||
|
set +a
|
||||||
|
bash tooling/deploy/deploy-compose.sh production
|
||||||
|
rm -f deploy.env
|
||||||
|
EOF
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
name: Deploy Staging
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
image_tag:
|
||||||
|
description: Image tag to deploy, for example sha-<commit>
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy To Staging
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: staging
|
||||||
|
timeout-minutes: 30
|
||||||
|
concurrency:
|
||||||
|
group: deploy-staging
|
||||||
|
cancel-in-progress: false
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- id: vars
|
||||||
|
name: Resolve image refs
|
||||||
|
run: |
|
||||||
|
owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
repo="$(basename '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
image_tag="${{ inputs.image_tag }}"
|
||||||
|
echo "app_image=ghcr.io/${owner}/${repo}-app:${image_tag}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "migrator_image=ghcr.io/${owner}/${repo}-migrator:${image_tag}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Prepare SSH
|
||||||
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||||
|
SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||||
|
SSH_PORT: ${{ secrets.STAGING_SSH_PORT }}
|
||||||
|
run: |
|
||||||
|
install -m 700 -d ~/.ssh
|
||||||
|
printf '%s\n' "${SSH_PRIVATE_KEY}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -p "${SSH_PORT:-22}" -H "${SSH_HOST}" >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Bundle deploy assets
|
||||||
|
run: tar czf deploy-bundle.tgz docker-compose.cicd.yml tooling/deploy
|
||||||
|
|
||||||
|
- name: Copy deploy assets to staging
|
||||||
|
env:
|
||||||
|
SSH_PORT: ${{ secrets.STAGING_SSH_PORT }}
|
||||||
|
run: |
|
||||||
|
ssh -p "${SSH_PORT:-22}" "${{ secrets.STAGING_SSH_USER }}@${{ secrets.STAGING_SSH_HOST }}" \
|
||||||
|
"mkdir -p '${{ secrets.STAGING_DEPLOY_PATH }}'"
|
||||||
|
scp -P "${SSH_PORT:-22}" deploy-bundle.tgz \
|
||||||
|
"${{ secrets.STAGING_SSH_USER }}@${{ secrets.STAGING_SSH_HOST }}:${{ secrets.STAGING_DEPLOY_PATH }}/deploy-bundle.tgz"
|
||||||
|
|
||||||
|
- name: Run staging deployment
|
||||||
|
env:
|
||||||
|
APP_IMAGE: ${{ steps.vars.outputs.app_image }}
|
||||||
|
MIGRATOR_IMAGE: ${{ steps.vars.outputs.migrator_image }}
|
||||||
|
APP_HOST_PORT: ${{ secrets.STAGING_APP_HOST_PORT }}
|
||||||
|
GHCR_USERNAME: ${{ secrets.STAGING_GHCR_USERNAME }}
|
||||||
|
GHCR_TOKEN: ${{ secrets.STAGING_GHCR_TOKEN }}
|
||||||
|
SSH_PORT: ${{ secrets.STAGING_SSH_PORT }}
|
||||||
|
run: |
|
||||||
|
cat > deploy.env <<EOF
|
||||||
|
APP_IMAGE=${APP_IMAGE}
|
||||||
|
MIGRATOR_IMAGE=${MIGRATOR_IMAGE}
|
||||||
|
APP_HOST_PORT=${APP_HOST_PORT}
|
||||||
|
GHCR_USERNAME=${GHCR_USERNAME}
|
||||||
|
GHCR_TOKEN=${GHCR_TOKEN}
|
||||||
|
EOF
|
||||||
|
scp -P "${SSH_PORT:-22}" deploy.env \
|
||||||
|
"${{ secrets.STAGING_SSH_USER }}@${{ secrets.STAGING_SSH_HOST }}:${{ secrets.STAGING_DEPLOY_PATH }}/deploy.env"
|
||||||
|
ssh -p "${SSH_PORT:-22}" "${{ secrets.STAGING_SSH_USER }}@${{ secrets.STAGING_SSH_HOST }}" 'bash -se' <<'EOF'
|
||||||
|
set -euo pipefail
|
||||||
|
cd "${{ secrets.STAGING_DEPLOY_PATH }}"
|
||||||
|
tar xzf deploy-bundle.tgz
|
||||||
|
rm -f deploy-bundle.tgz
|
||||||
|
set -a
|
||||||
|
. ./deploy.env
|
||||||
|
set +a
|
||||||
|
bash tooling/deploy/deploy-compose.sh staging
|
||||||
|
rm -f deploy.env
|
||||||
|
EOF
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: Nightly Security
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "17 2 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: "20"
|
||||||
|
PNPM_VERSION: "9.14.2"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dependency-audit:
|
||||||
|
name: Dependency Audit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: ${{ env.PNPM_VERSION }}
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run dependency audit
|
||||||
|
run: pnpm audit --audit-level=high
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: Release Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
image_tag:
|
||||||
|
description: Optional tag override, defaults to sha-<commit>
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
name: Build And Push Images
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- id: vars
|
||||||
|
name: Compute image refs
|
||||||
|
run: |
|
||||||
|
owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
repo="$(basename '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
image_tag="${{ inputs.image_tag }}"
|
||||||
|
if [ -z "${image_tag}" ]; then
|
||||||
|
image_tag="sha-${GITHUB_SHA}"
|
||||||
|
fi
|
||||||
|
echo "app_image=ghcr.io/${owner}/${repo}-app:${image_tag}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "migrator_image=ghcr.io/${owner}/${repo}-migrator:${image_tag}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build and push app image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile.prod
|
||||||
|
target: runner
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.vars.outputs.app_image }}
|
||||||
|
cache-from: type=gha,scope=app-image
|
||||||
|
cache-to: type=gha,mode=max,scope=app-image
|
||||||
|
|
||||||
|
- name: Build and push migrator image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile.prod
|
||||||
|
target: migrator
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.vars.outputs.migrator_image }}
|
||||||
|
cache-from: type=gha,scope=migrator-image
|
||||||
|
cache-to: type=gha,mode=max,scope=migrator-image
|
||||||
@@ -41,6 +41,7 @@ vectors.db
|
|||||||
# Prisma
|
# Prisma
|
||||||
packages/db/prisma/migrations/*
|
packages/db/prisma/migrations/*
|
||||||
!packages/db/prisma/migrations/.gitkeep
|
!packages/db/prisma/migrations/.gitkeep
|
||||||
|
!packages/db/prisma/migrations/**
|
||||||
|
|
||||||
# Editors and OS files
|
# Editors and OS files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
+10
-1
@@ -47,7 +47,16 @@ ENV NODE_ENV=production
|
|||||||
RUN pnpm --filter @capakraken/web build
|
RUN pnpm --filter @capakraken/web build
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Stage 3: Production runtime
|
# Stage 3: Migration runner
|
||||||
|
# ============================================================
|
||||||
|
FROM builder AS migrator
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
CMD ["pnpm", "--filter", "@capakraken/db", "db:migrate:deploy"]
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Stage 4: Production runtime
|
||||||
# ============================================================
|
# ============================================================
|
||||||
FROM node:20-bookworm-slim AS runner
|
FROM node:20-bookworm-slim AS runner
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default async function ScenarioPage({ params }: ScenarioPageProps) {
|
|||||||
|
|
||||||
// Load resources and roles for the pickers
|
// Load resources and roles for the pickers
|
||||||
const [resources, roles] = await Promise.all([
|
const [resources, roles] = await Promise.all([
|
||||||
trpc.resource.list({ isActive: true }),
|
trpc.resource.listStaff({ isActive: true }),
|
||||||
trpc.role.list({ isActive: true }),
|
trpc.role.list({ isActive: true }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export function ResourcesClient() {
|
|||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
// Keep this boundary shallow; the full TRPC inference here trips TS depth limits.
|
// Keep this boundary shallow; the full TRPC inference here trips TS depth limits.
|
||||||
} = (trpc.resource.list.useInfiniteQuery as any)(
|
} = (trpc.resource.listStaff.useInfiniteQuery as any)(
|
||||||
{
|
{
|
||||||
isActive: isActiveFilter === "all" ? undefined : isActiveFilter === "active",
|
isActive: isActiveFilter === "all" ? undefined : isActiveFilter === "active",
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
@@ -309,13 +309,15 @@ export function ResourcesClient() {
|
|||||||
|
|
||||||
// ─── Mutations ────────────────────────────────────────────────────────────
|
// ─── Mutations ────────────────────────────────────────────────────────────
|
||||||
const deactivateMutation = trpc.resource.deactivate.useMutation({
|
const deactivateMutation = trpc.resource.deactivate.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: () => {
|
||||||
await utils.resource.list.invalidate();
|
void utils.resource.directory.invalidate();
|
||||||
|
void utils.resource.listStaff.invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const batchDeactivateMutation = trpc.resource.batchDeactivate.useMutation({
|
const batchDeactivateMutation = trpc.resource.batchDeactivate.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: () => {
|
||||||
await utils.resource.list.invalidate();
|
void utils.resource.directory.invalidate();
|
||||||
|
void utils.resource.listStaff.invalidate();
|
||||||
selection.clear();
|
selection.clear();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { eventBus } from "@capakraken/api/sse";
|
import { loadRoleDefaults } from "@capakraken/api";
|
||||||
|
import { eventBus, permissionAudience, roleAudience, userAudience } from "@capakraken/api/sse";
|
||||||
import { startReminderScheduler } from "@capakraken/api/lib/reminder-scheduler";
|
import { startReminderScheduler } from "@capakraken/api/lib/reminder-scheduler";
|
||||||
import { SSE_EVENT_TYPES } from "@capakraken/shared";
|
import { prisma } from "@capakraken/db";
|
||||||
|
import { resolvePermissions, SSE_EVENT_TYPES, SystemRole, type PermissionOverrides } from "@capakraken/shared";
|
||||||
import { auth } from "~/server/auth.js";
|
import { auth } from "~/server/auth.js";
|
||||||
|
|
||||||
// Start the reminder scheduler (idempotent — only starts once)
|
// Start the reminder scheduler (idempotent — only starts once)
|
||||||
@@ -16,6 +18,38 @@ export async function GET() {
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionUser = session.user as typeof session.user & { id?: string };
|
||||||
|
if (!sessionUser.id) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbUser = await prisma.user.findUnique({
|
||||||
|
where: { id: sessionUser.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
systemRole: true,
|
||||||
|
permissionOverrides: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleDefaults = await loadRoleDefaults();
|
||||||
|
const permissions = resolvePermissions(
|
||||||
|
dbUser.systemRole as SystemRole,
|
||||||
|
dbUser.permissionOverrides as PermissionOverrides | null,
|
||||||
|
roleDefaults,
|
||||||
|
);
|
||||||
|
const audiences = new Set<string>([
|
||||||
|
userAudience(dbUser.id),
|
||||||
|
roleAudience(dbUser.systemRole),
|
||||||
|
]);
|
||||||
|
for (const permission of permissions) {
|
||||||
|
audiences.add(permissionAudience(permission));
|
||||||
|
}
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
@@ -26,13 +60,19 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Subscribe to event bus
|
// Subscribe to event bus
|
||||||
const unsubscribe = eventBus.subscribe((event) => {
|
const unsubscribe = eventBus.subscribe(
|
||||||
try {
|
(event) => {
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
try {
|
||||||
} catch {
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||||
// Client disconnected
|
} catch {
|
||||||
}
|
// Client disconnected
|
||||||
});
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
audiences,
|
||||||
|
includeUnscoped: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Heartbeat every 30 seconds
|
// Heartbeat every 30 seconds
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function BatchSkillImport() {
|
|||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { data: roles } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
|
const { data: roles } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
|
||||||
const { data: resources } = trpc.resource.list.useQuery(
|
const { data: resources } = trpc.resource.directory.useQuery(
|
||||||
{ isActive: true, limit: 500 },
|
{ isActive: true, limit: 500 },
|
||||||
{ staleTime: 60_000 },
|
{ staleTime: 60_000 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
useFocusTrap(panelRef, true);
|
useFocusTrap(panelRef, true);
|
||||||
|
|
||||||
const { data: resources } = trpc.resource.list.useQuery(
|
const { data: resources } = trpc.resource.directory.useQuery(
|
||||||
{ isActive: true, limit: 500 },
|
{ isActive: true, limit: 500 },
|
||||||
{ staleTime: 60_000 },
|
{ staleTime: 60_000 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
|
|
||||||
const invalidatePlanningViews = useInvalidatePlanningViews();
|
const invalidatePlanningViews = useInvalidatePlanningViews();
|
||||||
|
|
||||||
const { data: resources } = trpc.resource.list.useQuery(
|
const { data: resources } = trpc.resource.listStaff.useQuery(
|
||||||
{ isActive: true, search: debouncedSearch || undefined, limit: 50 },
|
{ isActive: true, search: debouncedSearch || undefined, limit: 50 },
|
||||||
{ staleTime: 15_000 },
|
{ staleTime: 15_000 },
|
||||||
) as { data: { resources: Array<{ id: string; displayName: string; eid: string; lcrCents: number }> } | undefined };
|
) as { data: { resources: Array<{ id: string; displayName: string; eid: string; lcrCents: number }> } | undefined };
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function useComputationGraphData(): ComputationGraphState {
|
|||||||
const [domainFilter, setDomainFilter] = useState<Set<Domain>>(new Set());
|
const [domainFilter, setDomainFilter] = useState<Set<Domain>>(new Set());
|
||||||
|
|
||||||
// Load selectors
|
// Load selectors
|
||||||
const { data: resourceData } = trpc.resource.list.useQuery(
|
const { data: resourceData } = trpc.resource.directory.useQuery(
|
||||||
{ isActive: true, limit: 500 },
|
{ isActive: true, limit: 500 },
|
||||||
{ staleTime: 60_000 },
|
{ staleTime: 60_000 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
|
|||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const projectsQuery = trpc.project.list.useQuery({ limit: 200 }, { staleTime: 60_000 });
|
const projectsQuery = trpc.project.list.useQuery({ limit: 200 }, { staleTime: 60_000 });
|
||||||
const rolesQuery = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
|
const rolesQuery = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
|
||||||
const resourcesQuery = trpc.resource.list.useQuery(
|
const resourcesQuery = trpc.resource.listStaff.useQuery(
|
||||||
{ limit: 500, includeRoles: true, isActive: true },
|
{ limit: 500, includeRoles: true, isActive: true },
|
||||||
{ staleTime: 60_000 },
|
{ staleTime: 60_000 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function EstimateWorkspaceDraftEditor({
|
|||||||
}) {
|
}) {
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const versions = estimate.versions as EstimateVersionView[];
|
const versions = estimate.versions as EstimateVersionView[];
|
||||||
const resourcesQuery = trpc.resource.list.useQuery(
|
const resourcesQuery = trpc.resource.listStaff.useQuery(
|
||||||
{ isActive: true, limit: 200 },
|
{ isActive: true, limit: 200 },
|
||||||
{ staleTime: 15_000 },
|
{ staleTime: 15_000 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v
|
|||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
// Server-side search — no client-side limit, searches full database
|
// Server-side search — no client-side limit, searches full database
|
||||||
const { data } = trpc.resource.list.useQuery(
|
const { data } = trpc.resource.directory.useQuery(
|
||||||
{ isActive: true, search: debouncedSearch || undefined, limit: 30 },
|
{ isActive: true, search: debouncedSearch || undefined, limit: 30 },
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
{ staleTime: 15_000, placeholderData: (prev: any) => prev },
|
{ staleTime: 15_000, placeholderData: (prev: any) => prev },
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ export function BulkEditModal({ selectedIds, fieldDefs, onClose, onSuccess }: Pr
|
|||||||
|
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const mutation = trpc.resource.batchUpdateCustomFields.useMutation({
|
const mutation = trpc.resource.batchUpdateCustomFields.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: () => {
|
||||||
await utils.resource.list.invalidate();
|
void utils.resource.directory.invalidate();
|
||||||
|
void utils.resource.listStaff.invalidate();
|
||||||
onSuccess();
|
onSuccess();
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ interface RoleAssignment {
|
|||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CountryWithCities = { id: string; metroCities: { id: string; name: string }[] };
|
type RoleOption = { id: string; name: string; color?: string | null };
|
||||||
type ManagementGroupWithLevels = { id: string; levels: { id: string; name: string }[] };
|
type CountryOption = { id: string; name: string; metroCities: { id: string; name: string }[] };
|
||||||
|
type OrgUnitOption = { id: string; name: string; level: number; isActive: boolean };
|
||||||
|
type ClientOption = { id: string; name: string };
|
||||||
|
type ManagementGroupOption = { id: string; name: string; levels: { id: string; name: string }[] };
|
||||||
|
|
||||||
interface SkillRow {
|
interface SkillRow {
|
||||||
skill: string;
|
skill: string;
|
||||||
@@ -206,35 +209,22 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
|||||||
const { data: mgmtGroups } = trpc.managementLevel.listGroups.useQuery(undefined, { staleTime: 60_000 });
|
const { data: mgmtGroups } = trpc.managementLevel.listGroups.useQuery(undefined, { staleTime: 60_000 });
|
||||||
const { data: clients } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
|
const { data: clients } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
|
||||||
|
|
||||||
|
const roleOptions = (availableRoles ?? []) as unknown as RoleOption[];
|
||||||
|
const countryOptions = (countries ?? []) as unknown as CountryOption[];
|
||||||
|
const orgUnitOptions = (orgUnits ?? []) as unknown as OrgUnitOption[];
|
||||||
|
const managementGroupOptions = (mgmtGroups ?? []) as unknown as ManagementGroupOption[];
|
||||||
|
const clientOptions = (clients ?? []) as unknown as ClientOption[];
|
||||||
|
|
||||||
// Derive metro cities from selected country
|
// Derive metro cities from selected country
|
||||||
const countryRows = (countries ?? []) as unknown as CountryWithCities[];
|
const selectedCountry = countryOptions.find((c) => c.id === form.countryId);
|
||||||
const selectedCountry = countryRows.find((c) => c.id === form.countryId);
|
|
||||||
const metroCities = selectedCountry?.metroCities ?? [];
|
const metroCities = selectedCountry?.metroCities ?? [];
|
||||||
|
|
||||||
// Derive levels from selected group
|
// Derive levels from selected group
|
||||||
const managementGroups = (mgmtGroups ?? []) as unknown as ManagementGroupWithLevels[];
|
const selectedGroup = managementGroupOptions.find((g) => g.id === form.managementLevelGroupId);
|
||||||
const selectedGroup = managementGroups.find((g) => g.id === form.managementLevelGroupId);
|
|
||||||
const mgmtLevels = selectedGroup?.levels ?? [];
|
const mgmtLevels = selectedGroup?.levels ?? [];
|
||||||
|
|
||||||
const createMutation = trpc.resource.create.useMutation({
|
const createMutation = trpc.resource.create.useMutation();
|
||||||
onSuccess: async () => {
|
const updateMutation = trpc.resource.update.useMutation();
|
||||||
await utils.resource.list.invalidate();
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
setErrorMsg(err.message ?? "An error occurred while saving.");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateMutation = trpc.resource.update.useMutation({
|
|
||||||
onSuccess: async () => {
|
|
||||||
await utils.resource.list.invalidate();
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
setErrorMsg(err.message ?? "An error occurred while saving.");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const isMutating = createMutation.isPending || updateMutation.isPending;
|
const isMutating = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
@@ -314,16 +304,25 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
|
|
||||||
const payload = buildPayload();
|
const payload = buildPayload();
|
||||||
|
|
||||||
if (mode === "create") {
|
try {
|
||||||
createMutation.mutate(payload);
|
if (mode === "create") {
|
||||||
} else if (resource) {
|
await createMutation.mutateAsync(payload);
|
||||||
updateMutation.mutate({ id: resource.id, data: payload });
|
} else if (resource) {
|
||||||
|
await updateMutation.mutateAsync({ id: resource.id, data: payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
void utils.resource.directory.invalidate();
|
||||||
|
void utils.resource.listStaff.invalidate();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "An error occurred while saving.";
|
||||||
|
setErrorMsg(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,7 +453,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
|||||||
onChange={(e) => setField("roleId", e.target.value)}
|
onChange={(e) => setField("roleId", e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">— Not specified —</option>
|
<option value="">— Not specified —</option>
|
||||||
{(availableRoles ?? []).map((r) => (
|
{roleOptions.map((r) => (
|
||||||
<option key={r.id} value={r.id}>{r.name}</option>
|
<option key={r.id} value={r.id}>{r.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -552,8 +551,8 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">— Not specified —</option>
|
<option value="">— Not specified —</option>
|
||||||
{(countries ?? []).map((c) => (
|
{countryOptions.map((c) => (
|
||||||
<option key={c.id} value={c.id}>{(c as unknown as { name: string }).name}</option>
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -584,10 +583,10 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
|||||||
onChange={(e) => setField("orgUnitId", e.target.value)}
|
onChange={(e) => setField("orgUnitId", e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">— Not specified —</option>
|
<option value="">— Not specified —</option>
|
||||||
{(orgUnits ?? [])
|
{orgUnitOptions
|
||||||
.filter((u) => (u as unknown as { level: number }).level === 7 && (u as unknown as { isActive: boolean }).isActive)
|
.filter((u) => u.level === 7 && u.isActive)
|
||||||
.map((u) => (
|
.map((u) => (
|
||||||
<option key={u.id} value={u.id}>{(u as unknown as { name: string }).name}</option>
|
<option key={u.id} value={u.id}>{u.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -600,8 +599,8 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
|||||||
onChange={(e) => setField("clientUnitId", e.target.value)}
|
onChange={(e) => setField("clientUnitId", e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">— Not specified —</option>
|
<option value="">— Not specified —</option>
|
||||||
{(clients ?? []).map((c) => (
|
{clientOptions.map((c) => (
|
||||||
<option key={c.id} value={c.id}>{(c as unknown as { name: string }).name}</option>
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -620,8 +619,8 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">— Not specified —</option>
|
<option value="">— Not specified —</option>
|
||||||
{(mgmtGroups ?? []).map((g) => (
|
{managementGroupOptions.map((g) => (
|
||||||
<option key={g.id} value={g.id}>{(g as unknown as { name: string }).name}</option>
|
<option key={g.id} value={g.id}>{g.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -895,7 +894,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
|||||||
<p className={SECTION_HEADER_CLASS}>Roles</p>
|
<p className={SECTION_HEADER_CLASS}>Roles</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(availableRoles ?? []).map((role) => {
|
{roleOptions.map((role) => {
|
||||||
const assignment = form.roles.find((r) => r.roleId === role.id);
|
const assignment = form.roles.find((r) => r.roleId === role.id);
|
||||||
const isChecked = Boolean(assignment);
|
const isChecked = Boolean(assignment);
|
||||||
|
|
||||||
@@ -942,7 +941,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(availableRoles ?? []).length === 0 && (
|
{roleOptions.length === 0 && (
|
||||||
<p className="text-sm text-gray-400 italic">No roles defined yet. Create roles on the Roles page.</p>
|
<p className="text-sm text-gray-400 italic">No roles defined yet. Create roles on the Roles page.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
|
|||||||
>({});
|
>({});
|
||||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: allResources } = trpc.resource.list.useQuery(
|
const { data: allResources } = trpc.resource.directory.useQuery(
|
||||||
{ search: resourceSearch },
|
{ search: resourceSearch },
|
||||||
{ enabled: addingMember, staleTime: 10_000 },
|
{ enabled: addingMember, staleTime: 10_000 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type DemandRequirement,
|
type DemandRequirement,
|
||||||
} from "@capakraken/shared";
|
} from "@capakraken/shared";
|
||||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useTimelineSSE } from "~/hooks/useTimelineSSE.js";
|
import { useTimelineSSE } from "~/hooks/useTimelineSSE.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
@@ -217,7 +218,13 @@ export function TimelineProvider({
|
|||||||
isDragging,
|
isDragging,
|
||||||
children,
|
children,
|
||||||
}: TimelineProviderProps) {
|
}: TimelineProviderProps) {
|
||||||
|
const { data: session, status: sessionStatus } = useSession();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const role = sessionStatus === "authenticated"
|
||||||
|
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
|
||||||
|
: null;
|
||||||
|
const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
|
||||||
|
const isRoleLoading = sessionStatus !== "authenticated";
|
||||||
|
|
||||||
const today = useMemo(() => {
|
const today = useMemo(() => {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
@@ -283,19 +290,25 @@ export function TimelineProvider({
|
|||||||
|
|
||||||
// ─── Data queries ──────────────────────────────────────────────────────────
|
// ─── Data queries ──────────────────────────────────────────────────────────
|
||||||
const mountedRef = useRef(false);
|
const mountedRef = useRef(false);
|
||||||
|
const timelineQueryInput = {
|
||||||
|
startDate: viewStart,
|
||||||
|
endDate: viewEnd,
|
||||||
|
...(filters.clientIds.length > 0 ? { clientIds: filters.clientIds } : {}),
|
||||||
|
...(filters.projectIds.length > 0 ? { projectIds: filters.projectIds } : {}),
|
||||||
|
...(filters.chapters.length > 0 ? { chapters: filters.chapters } : {}),
|
||||||
|
...(filters.eids.length > 0 ? { eids: filters.eids } : {}),
|
||||||
|
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
const entriesViewQuery = trpc.timeline.getEntriesView.useQuery(
|
const staffEntriesViewQuery = trpc.timeline.getEntriesView.useQuery(
|
||||||
{
|
timelineQueryInput,
|
||||||
startDate: viewStart,
|
|
||||||
endDate: viewEnd,
|
|
||||||
...(filters.clientIds.length > 0 ? { clientIds: filters.clientIds } : {}),
|
|
||||||
...(filters.projectIds.length > 0 ? { projectIds: filters.projectIds } : {}),
|
|
||||||
...(filters.chapters.length > 0 ? { chapters: filters.chapters } : {}),
|
|
||||||
...(filters.eids.length > 0 ? { eids: filters.eids } : {}),
|
|
||||||
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
{ placeholderData: (prev: any) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
{
|
||||||
|
enabled: !isRoleLoading && !isSelfServiceTimeline,
|
||||||
|
placeholderData: (prev: any) => prev,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 90_000,
|
||||||
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
) as {
|
) as {
|
||||||
data: TimelineEntriesView | undefined;
|
data: TimelineEntriesView | undefined;
|
||||||
@@ -303,6 +316,23 @@ export function TimelineProvider({
|
|||||||
refetch: () => Promise<unknown>;
|
refetch: () => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selfEntriesViewQuery = trpc.timeline.getMyEntriesView.useQuery(
|
||||||
|
timelineQueryInput,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
{
|
||||||
|
enabled: !isRoleLoading && isSelfServiceTimeline,
|
||||||
|
placeholderData: (prev: any) => prev,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 90_000,
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
) as {
|
||||||
|
data: TimelineEntriesView | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
refetch: () => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const entriesViewQuery = isSelfServiceTimeline ? selfEntriesViewQuery : staffEntriesViewQuery;
|
||||||
const { data: entriesView, isLoading, refetch: refetchEntriesView } = entriesViewQuery;
|
const { data: entriesView, isLoading, refetch: refetchEntriesView } = entriesViewQuery;
|
||||||
|
|
||||||
const assignments = entriesView?.assignments ?? [];
|
const assignments = entriesView?.assignments ?? [];
|
||||||
@@ -316,24 +346,33 @@ export function TimelineProvider({
|
|||||||
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const staffHolidayOverlayQuery = trpc.timeline.getHolidayOverlays.useQuery(
|
||||||
|
timelineQueryInput,
|
||||||
|
{
|
||||||
|
enabled: !isRoleLoading && !isSelfServiceTimeline,
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 90_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const selfHolidayOverlayQuery = trpc.timeline.getMyHolidayOverlays.useQuery(
|
||||||
|
timelineQueryInput,
|
||||||
|
{
|
||||||
|
enabled: !isRoleLoading && isSelfServiceTimeline,
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 90_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const activeHolidayOverlayQuery = isSelfServiceTimeline ? selfHolidayOverlayQuery : staffHolidayOverlayQuery;
|
||||||
const {
|
const {
|
||||||
data: holidayOverlayEntries = [],
|
data: holidayOverlayEntries = [],
|
||||||
refetch: refetchHolidayOverlays,
|
refetch: refetchHolidayOverlays,
|
||||||
} = trpc.timeline.getHolidayOverlays.useQuery(
|
} = activeHolidayOverlayQuery;
|
||||||
{
|
|
||||||
startDate: viewStart,
|
|
||||||
endDate: viewEnd,
|
|
||||||
...(filters.clientIds.length > 0 ? { clientIds: filters.clientIds } : {}),
|
|
||||||
...(filters.projectIds.length > 0 ? { projectIds: filters.projectIds } : {}),
|
|
||||||
...(filters.chapters.length > 0 ? { chapters: filters.chapters } : {}),
|
|
||||||
...(filters.eids.length > 0 ? { eids: filters.eids } : {}),
|
|
||||||
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
|
|
||||||
},
|
|
||||||
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mountedRef.current) return;
|
if (mountedRef.current) return;
|
||||||
|
if (isRoleLoading) return;
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
|
|
||||||
// Harden client-side route transitions: the timeline must actively refresh
|
// Harden client-side route transitions: the timeline must actively refresh
|
||||||
@@ -341,7 +380,7 @@ export function TimelineProvider({
|
|||||||
void refetchEntriesView();
|
void refetchEntriesView();
|
||||||
void refetchVacations();
|
void refetchVacations();
|
||||||
void refetchHolidayOverlays();
|
void refetchHolidayOverlays();
|
||||||
}, [refetchEntriesView, refetchHolidayOverlays, refetchVacations]);
|
}, [isRoleLoading, refetchEntriesView, refetchHolidayOverlays, refetchVacations]);
|
||||||
|
|
||||||
const vacationsByResource = useMemo(() => {
|
const vacationsByResource = useMemo(() => {
|
||||||
const map = new Map<string, VacationEntry[]>();
|
const map = new Map<string, VacationEntry[]>();
|
||||||
@@ -378,9 +417,9 @@ export function TimelineProvider({
|
|||||||
}, [holidayOverlayEntries, vacationEntries]);
|
}, [holidayOverlayEntries, vacationEntries]);
|
||||||
|
|
||||||
// When EID filter is active, explicitly fetch those resources.
|
// When EID filter is active, explicitly fetch those resources.
|
||||||
const { data: eidFilterData } = trpc.resource.list.useQuery(
|
const { data: eidFilterData } = trpc.resource.directory.useQuery(
|
||||||
{ eids: filters.eids, limit: 100 },
|
{ eids: filters.eids, limit: 100 },
|
||||||
{ enabled: filters.eids.length > 0, staleTime: 30_000 },
|
{ enabled: !isSelfServiceTimeline && filters.eids.length > 0, staleTime: 30_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Filtered entries ──────────────────────────────────────────────────────
|
// ─── Filtered entries ──────────────────────────────────────────────────────
|
||||||
@@ -633,7 +672,7 @@ export function TimelineProvider({
|
|||||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// ─── Derived counts ───────────────────────────────────────────────────────
|
// ─── Derived counts ───────────────────────────────────────────────────────
|
||||||
const isInitialLoading = isLoading && !entriesView;
|
const isInitialLoading = (isRoleLoading || isLoading) && !entriesView;
|
||||||
const totalAllocCount = entriesView?.allocations.length ?? 0;
|
const totalAllocCount = entriesView?.allocations.length ?? 0;
|
||||||
const activeFilterCount =
|
const activeFilterCount =
|
||||||
filters.clientIds.length +
|
filters.clientIds.length +
|
||||||
|
|||||||
@@ -107,11 +107,11 @@ interface TimelineQuickFiltersProps {
|
|||||||
export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFiltersProps) {
|
export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFiltersProps) {
|
||||||
const [eidSearch, setEidSearch] = useState("");
|
const [eidSearch, setEidSearch] = useState("");
|
||||||
const { clients, countries } = useReferenceData({ clients: true, countries: true });
|
const { clients, countries } = useReferenceData({ clients: true, countries: true });
|
||||||
const { data: resourceData } = trpc.resource.list.useQuery(
|
const { data: resourceData } = trpc.resource.directory.useQuery(
|
||||||
{ isActive: true, limit: 500 },
|
{ isActive: true, limit: 500 },
|
||||||
{ staleTime: 60_000 },
|
{ staleTime: 60_000 },
|
||||||
);
|
);
|
||||||
const { data: eidSearchData } = trpc.resource.list.useQuery(
|
const { data: eidSearchData } = trpc.resource.directory.useQuery(
|
||||||
{ isActive: true, search: eidSearch, limit: 100 },
|
{ isActive: true, search: eidSearch, limit: 100 },
|
||||||
{ staleTime: 15_000 },
|
{ staleTime: 15_000 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function TimelineToolbar({
|
|||||||
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null);
|
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Look up resource to get EID when selected
|
// Look up resource to get EID when selected
|
||||||
const { data: resourceLookup } = trpc.resource.list.useQuery(
|
const { data: resourceLookup } = trpc.resource.directory.useQuery(
|
||||||
{ limit: 500 },
|
{ limit: 500 },
|
||||||
{ staleTime: 60_000 },
|
{ staleTime: 60_000 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useAllocationHistory } from "~/hooks/useAllocationHistory.js";
|
import { useAllocationHistory } from "~/hooks/useAllocationHistory.js";
|
||||||
import { useProjectDragContext } from "~/hooks/useProjectDragContext.js";
|
import { useProjectDragContext } from "~/hooks/useProjectDragContext.js";
|
||||||
@@ -38,7 +39,13 @@ import { useMultiSelectIntersection } from "~/hooks/useMultiSelectIntersection.j
|
|||||||
// then wraps children with TimelineProvider. The inner content consumes context.
|
// then wraps children with TimelineProvider. The inner content consumes context.
|
||||||
|
|
||||||
export function TimelineView() {
|
export function TimelineView() {
|
||||||
|
const { data: session, status: sessionStatus } = useSession();
|
||||||
const mousePosRef = useRef({ x: 0, y: 0 });
|
const mousePosRef = useRef({ x: 0, y: 0 });
|
||||||
|
const role = sessionStatus === "authenticated"
|
||||||
|
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
|
||||||
|
: null;
|
||||||
|
const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
|
||||||
|
const canManageTimeline = !isSelfServiceTimeline;
|
||||||
|
|
||||||
const { push: pushHistory, pushBatch: pushBatchHistory, undo, redo, canUndo, canRedo } = useAllocationHistory();
|
const { push: pushHistory, pushBatch: pushBatchHistory, undo, redo, canUndo, canRedo } = useAllocationHistory();
|
||||||
const pushHistoryRef = useRef(pushHistory);
|
const pushHistoryRef = useRef(pushHistory);
|
||||||
@@ -147,8 +154,8 @@ export function TimelineView() {
|
|||||||
|
|
||||||
const [openPanelProjectId, setOpenPanelProjectId] = useState<string | null>(null);
|
const [openPanelProjectId, setOpenPanelProjectId] = useState<string | null>(null);
|
||||||
const dragProjectId = dragState.isDragging ? dragState.projectId : null;
|
const dragProjectId = dragState.isDragging ? dragState.projectId : null;
|
||||||
const contextProjectId = dragProjectId ?? openPanelProjectId;
|
const contextProjectId = canManageTimeline ? (dragProjectId ?? openPanelProjectId) : null;
|
||||||
const { contextResourceIds, contextAllocations } = useProjectDragContext(contextProjectId);
|
const { contextResourceIds, contextAllocations } = useProjectDragContext(contextProjectId, canManageTimeline);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TimelineProvider
|
<TimelineProvider
|
||||||
@@ -189,6 +196,7 @@ export function TimelineView() {
|
|||||||
setOpenPanelProjectId={setOpenPanelProjectId}
|
setOpenPanelProjectId={setOpenPanelProjectId}
|
||||||
canUndo={canUndo}
|
canUndo={canUndo}
|
||||||
canRedo={canRedo}
|
canRedo={canRedo}
|
||||||
|
isSelfServiceTimeline={isSelfServiceTimeline}
|
||||||
undo={undo}
|
undo={undo}
|
||||||
redo={redo}
|
redo={redo}
|
||||||
/>
|
/>
|
||||||
@@ -232,6 +240,7 @@ function TimelineViewContent({
|
|||||||
setOpenPanelProjectId,
|
setOpenPanelProjectId,
|
||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
|
isSelfServiceTimeline,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
}: {
|
}: {
|
||||||
@@ -278,6 +287,7 @@ function TimelineViewContent({
|
|||||||
setOpenPanelProjectId: React.Dispatch<React.SetStateAction<string | null>>;
|
setOpenPanelProjectId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
canRedo: boolean;
|
canRedo: boolean;
|
||||||
|
isSelfServiceTimeline: boolean;
|
||||||
undo: () => Promise<void>;
|
undo: () => Promise<void>;
|
||||||
redo: () => Promise<void>;
|
redo: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
@@ -642,7 +652,7 @@ function TimelineViewContent({
|
|||||||
onMouseUp={(e) => void onCanvasMouseUp(e)}
|
onMouseUp={(e) => void onCanvasMouseUp(e)}
|
||||||
onMouseLeave={onCanvasMouseLeave}
|
onMouseLeave={onCanvasMouseLeave}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.button === 2) {
|
if (!isSelfServiceTimeline && e.button === 2) {
|
||||||
onCanvasRightMouseDown(e);
|
onCanvasRightMouseDown(e);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -666,11 +676,11 @@ function TimelineViewContent({
|
|||||||
rangeState={effectiveRangeState}
|
rangeState={effectiveRangeState}
|
||||||
shiftPreview={shiftPreview}
|
shiftPreview={shiftPreview}
|
||||||
contextResourceIds={contextResourceIds}
|
contextResourceIds={contextResourceIds}
|
||||||
onAllocMouseDown={onAllocMouseDown}
|
onAllocMouseDown={isSelfServiceTimeline ? () => undefined : onAllocMouseDown}
|
||||||
onAllocTouchStart={onAllocTouchStart}
|
onAllocTouchStart={isSelfServiceTimeline ? () => undefined : onAllocTouchStart}
|
||||||
onRowMouseDown={onRowMouseDown}
|
onRowMouseDown={isSelfServiceTimeline ? () => undefined : onRowMouseDown}
|
||||||
onRowTouchStart={onRowTouchStart}
|
onRowTouchStart={isSelfServiceTimeline ? () => undefined : onRowTouchStart}
|
||||||
onAllocationContextMenu={openAllocationPopoverAt}
|
onAllocationContextMenu={isSelfServiceTimeline ? () => undefined : openAllocationPopoverAt}
|
||||||
multiSelectState={multiSelectState}
|
multiSelectState={multiSelectState}
|
||||||
CELL_WIDTH={CELL_WIDTH}
|
CELL_WIDTH={CELL_WIDTH}
|
||||||
dates={dates}
|
dates={dates}
|
||||||
@@ -689,15 +699,15 @@ function TimelineViewContent({
|
|||||||
allocDragState={allocDragState}
|
allocDragState={allocDragState}
|
||||||
rangeState={effectiveRangeState}
|
rangeState={effectiveRangeState}
|
||||||
multiSelectState={multiSelectState}
|
multiSelectState={multiSelectState}
|
||||||
onProjectBarMouseDown={onProjectBarMouseDown}
|
onProjectBarMouseDown={isSelfServiceTimeline ? () => undefined : onProjectBarMouseDown}
|
||||||
onProjectBarTouchStart={onProjectBarTouchStart}
|
onProjectBarTouchStart={isSelfServiceTimeline ? () => undefined : onProjectBarTouchStart}
|
||||||
onAllocMouseDown={onAllocMouseDown}
|
onAllocMouseDown={isSelfServiceTimeline ? () => undefined : onAllocMouseDown}
|
||||||
onAllocTouchStart={onAllocTouchStart}
|
onAllocTouchStart={isSelfServiceTimeline ? () => undefined : onAllocTouchStart}
|
||||||
onRowMouseDown={onRowMouseDown}
|
onRowMouseDown={isSelfServiceTimeline ? () => undefined : onRowMouseDown}
|
||||||
onRowTouchStart={onRowTouchStart}
|
onRowTouchStart={isSelfServiceTimeline ? () => undefined : onRowTouchStart}
|
||||||
onOpenPanel={setOpenPanelProjectId}
|
onOpenPanel={isSelfServiceTimeline ? () => undefined : setOpenPanelProjectId}
|
||||||
onOpenDemandClick={setOpenDemandToAssign}
|
onOpenDemandClick={isSelfServiceTimeline ? () => undefined : setOpenDemandToAssign}
|
||||||
onAllocationContextMenu={openAllocationPopoverAt}
|
onAllocationContextMenu={isSelfServiceTimeline ? () => undefined : openAllocationPopoverAt}
|
||||||
CELL_WIDTH={CELL_WIDTH}
|
CELL_WIDTH={CELL_WIDTH}
|
||||||
dates={dates}
|
dates={dates}
|
||||||
totalCanvasWidth={totalCanvasWidth}
|
totalCanvasWidth={totalCanvasWidth}
|
||||||
@@ -815,7 +825,7 @@ function TimelineViewContent({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Allocation / Demand popover (click path) */}
|
{/* Allocation / Demand popover (click path) */}
|
||||||
{popover && (() => {
|
{!isSelfServiceTimeline && popover && (() => {
|
||||||
// Check if clicked allocation is actually a demand
|
// Check if clicked allocation is actually a demand
|
||||||
const clickedDemand = openDemandsByProject.get(popover.projectId)?.find((d) => d.id === popover.allocationId);
|
const clickedDemand = openDemandsByProject.get(popover.projectId)?.find((d) => d.id === popover.allocationId);
|
||||||
if (clickedDemand) {
|
if (clickedDemand) {
|
||||||
@@ -863,7 +873,7 @@ function TimelineViewContent({
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Demand popover */}
|
{/* Demand popover */}
|
||||||
{demandPopover && (
|
{!isSelfServiceTimeline && demandPopover && (
|
||||||
<DemandPopover
|
<DemandPopover
|
||||||
demand={demandPopover.demand}
|
demand={demandPopover.demand}
|
||||||
onClose={() => setDemandPopover(null)}
|
onClose={() => setDemandPopover(null)}
|
||||||
@@ -892,7 +902,7 @@ function TimelineViewContent({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* New allocation popover */}
|
{/* New allocation popover */}
|
||||||
{newAllocPopover && (
|
{!isSelfServiceTimeline && newAllocPopover && (
|
||||||
<NewAllocationPopover
|
<NewAllocationPopover
|
||||||
resourceId={newAllocPopover.resourceId}
|
resourceId={newAllocPopover.resourceId}
|
||||||
startDate={newAllocPopover.startDate}
|
startDate={newAllocPopover.startDate}
|
||||||
@@ -906,12 +916,12 @@ function TimelineViewContent({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Project side panel */}
|
{/* Project side panel */}
|
||||||
{openPanelProjectId && (
|
{!isSelfServiceTimeline && openPanelProjectId && (
|
||||||
<ProjectPanel projectId={openPanelProjectId} onClose={() => setOpenPanelProjectId(null)} />
|
<ProjectPanel projectId={openPanelProjectId} onClose={() => setOpenPanelProjectId(null)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Open-demand assignment modal */}
|
{/* Open-demand assignment modal */}
|
||||||
{openDemandToAssign && (
|
{!isSelfServiceTimeline && openDemandToAssign && (
|
||||||
<FillOpenDemandModal
|
<FillOpenDemandModal
|
||||||
allocation={openDemandToAssign}
|
allocation={openDemandToAssign}
|
||||||
onClose={() => setOpenDemandToAssign(null)}
|
onClose={() => setOpenDemandToAssign(null)}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function ResourceCombobox({
|
|||||||
...props
|
...props
|
||||||
}: ResourceComboboxProps) {
|
}: ResourceComboboxProps) {
|
||||||
const useSearchQuery = (search: string, enabled: boolean) => {
|
const useSearchQuery = (search: string, enabled: boolean) => {
|
||||||
const { data } = trpc.resource.list.useQuery(
|
const { data } = trpc.resource.directory.useQuery(
|
||||||
{ search: search || undefined, limit: 15, isActive },
|
{ search: search || undefined, limit: 15, isActive },
|
||||||
{ enabled, staleTime: 30_000 },
|
{ enabled, staleTime: 30_000 },
|
||||||
);
|
);
|
||||||
@@ -28,7 +28,7 @@ export function ResourceCombobox({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const useSelectedQuery = (_id: string | null, enabled: boolean) => {
|
const useSelectedQuery = (_id: string | null, enabled: boolean) => {
|
||||||
const { data } = trpc.resource.list.useQuery(
|
const { data } = trpc.resource.directory.useQuery(
|
||||||
{ limit: 500 },
|
{ limit: 500 },
|
||||||
{ enabled, staleTime: 60_000 },
|
{ enabled, staleTime: 60_000 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function PublicHolidayBatch() {
|
|||||||
const [replaceExisting, setReplaceExisting] = useState(false);
|
const [replaceExisting, setReplaceExisting] = useState(false);
|
||||||
const [result, setResult] = useState<{ created: number; holidays?: number; resources?: number } | null>(null);
|
const [result, setResult] = useState<{ created: number; holidays?: number; resources?: number } | null>(null);
|
||||||
|
|
||||||
const { data: resources } = trpc.resource.list.useQuery(
|
const { data: resources } = trpc.resource.directory.useQuery(
|
||||||
{ isActive: true, limit: 500 },
|
{ isActive: true, limit: 500 },
|
||||||
{ staleTime: 60_000 },
|
{ staleTime: 60_000 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function TeamCalendar() {
|
|||||||
const firstDay = new Date(Date.UTC(year, month, 1));
|
const firstDay = new Date(Date.UTC(year, month, 1));
|
||||||
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
||||||
|
|
||||||
const { data: resources } = trpc.resource.list.useQuery(
|
const { data: resources } = trpc.resource.directory.useQuery(
|
||||||
{ isActive: true, limit: 500, ...(chapter ? { chapter } : {}) },
|
{ isActive: true, limit: 500, ...(chapter ? { chapter } : {}) },
|
||||||
{ staleTime: 60_000 },
|
{ staleTime: 60_000 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function VacationClient() {
|
|||||||
{ staleTime: 15_000 },
|
{ staleTime: 15_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: resources } = trpc.resource.list.useQuery(
|
const { data: resources } = trpc.resource.directory.useQuery(
|
||||||
{ isActive: true, limit: 500 },
|
{ isActive: true, limit: 500 },
|
||||||
{ staleTime: 60_000 },
|
{ staleTime: 60_000 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
|||||||
const debouncedStart = useDebounce(startDate, 400);
|
const debouncedStart = useDebounce(startDate, 400);
|
||||||
const debouncedEnd = useDebounce(endDate, 400);
|
const debouncedEnd = useDebounce(endDate, 400);
|
||||||
|
|
||||||
const { data: resources } = trpc.resource.list.useQuery(
|
const { data: resources } = trpc.resource.directory.useQuery(
|
||||||
{ isActive: true, limit: 500 },
|
{ isActive: true, limit: 500 },
|
||||||
{ staleTime: 60_000 },
|
{ staleTime: 60_000 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export function useInvalidateTimeline() {
|
|||||||
return () => {
|
return () => {
|
||||||
void utils.timeline.getEntries.invalidate();
|
void utils.timeline.getEntries.invalidate();
|
||||||
void utils.timeline.getEntriesView.invalidate();
|
void utils.timeline.getEntriesView.invalidate();
|
||||||
|
void utils.timeline.getMyEntriesView.invalidate();
|
||||||
|
void utils.timeline.getHolidayOverlays.invalidate();
|
||||||
|
void utils.timeline.getMyHolidayOverlays.invalidate();
|
||||||
void utils.timeline.getProjectContext.invalidate();
|
void utils.timeline.getProjectContext.invalidate();
|
||||||
void utils.timeline.getBudgetStatus.invalidate();
|
void utils.timeline.getBudgetStatus.invalidate();
|
||||||
};
|
};
|
||||||
@@ -25,6 +28,9 @@ export function useInvalidatePlanningViews() {
|
|||||||
void utils.allocation.listAssignments.invalidate();
|
void utils.allocation.listAssignments.invalidate();
|
||||||
void utils.timeline.getEntries.invalidate();
|
void utils.timeline.getEntries.invalidate();
|
||||||
void utils.timeline.getEntriesView.invalidate();
|
void utils.timeline.getEntriesView.invalidate();
|
||||||
|
void utils.timeline.getMyEntriesView.invalidate();
|
||||||
|
void utils.timeline.getHolidayOverlays.invalidate();
|
||||||
|
void utils.timeline.getMyHolidayOverlays.invalidate();
|
||||||
void utils.timeline.getProjectContext.invalidate();
|
void utils.timeline.getProjectContext.invalidate();
|
||||||
void utils.timeline.getBudgetStatus.invalidate();
|
void utils.timeline.getBudgetStatus.invalidate();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ type ProjectDragContextResult = {
|
|||||||
project: any | null;
|
project: any | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useProjectDragContext(projectId: string | null): ProjectDragContextResult {
|
export function useProjectDragContext(projectId: string | null, enabled = true): ProjectDragContextResult {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const { data } = trpc.timeline.getProjectContext.useQuery(
|
const { data } = trpc.timeline.getProjectContext.useQuery(
|
||||||
{ projectId: projectId! },
|
{ projectId: projectId! },
|
||||||
{ enabled: !!projectId, staleTime: 10_000 },
|
{ enabled: enabled && !!projectId, staleTime: 10_000 },
|
||||||
) as { data: any };
|
) as { data: any };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -28,11 +28,19 @@ export function useTimelineSSE() {
|
|||||||
case SSE_EVENT_TYPES.ALLOCATION_UPDATED:
|
case SSE_EVENT_TYPES.ALLOCATION_UPDATED:
|
||||||
case SSE_EVENT_TYPES.ALLOCATION_DELETED:
|
case SSE_EVENT_TYPES.ALLOCATION_DELETED:
|
||||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntries"]] });
|
void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntries"]] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntriesView"]] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [["timeline", "getMyEntriesView"]] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [["timeline", "getHolidayOverlays"]] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [["timeline", "getMyHolidayOverlays"]] });
|
||||||
void queryClient.invalidateQueries({ queryKey: [["allocation", "list"]] });
|
void queryClient.invalidateQueries({ queryKey: [["allocation", "list"]] });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SSE_EVENT_TYPES.PROJECT_SHIFTED:
|
case SSE_EVENT_TYPES.PROJECT_SHIFTED:
|
||||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntries"]] });
|
void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntries"]] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntriesView"]] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [["timeline", "getMyEntriesView"]] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [["timeline", "getHolidayOverlays"]] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [["timeline", "getMyHolidayOverlays"]] });
|
||||||
void queryClient.invalidateQueries({ queryKey: [["project", "list"]] });
|
void queryClient.invalidateQueries({ queryKey: [["project", "list"]] });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -40,6 +48,11 @@ export function useTimelineSSE() {
|
|||||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getBudgetStatus"]] });
|
void queryClient.invalidateQueries({ queryKey: [["timeline", "getBudgetStatus"]] });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case SSE_EVENT_TYPES.NOTIFICATION_CREATED:
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [["notification", "list"]] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [["notification", "unreadCount"]] });
|
||||||
|
break;
|
||||||
|
|
||||||
case SSE_EVENT_TYPES.TASK_ASSIGNED:
|
case SSE_EVENT_TYPES.TASK_ASSIGNED:
|
||||||
case SSE_EVENT_TYPES.TASK_COMPLETED:
|
case SSE_EVENT_TYPES.TASK_COMPLETED:
|
||||||
case SSE_EVENT_TYPES.TASK_STATUS_CHANGED:
|
case SSE_EVENT_TYPES.TASK_STATUS_CHANGED:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { auth } from "./auth.js";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side tRPC caller for RSC.
|
* Server-side tRPC caller for RSC.
|
||||||
* Usage: const trpc = await createCaller(); const result = await trpc.resource.list({});
|
* Usage: const trpc = await createCaller(); const result = await trpc.resource.directory({});
|
||||||
*/
|
*/
|
||||||
export async function createCaller() {
|
export async function createCaller() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
name: capakraken-cicd
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: capakraken
|
||||||
|
POSTGRES_USER: capakraken
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- capakraken_prod_pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U capakraken -d capakraken"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
|
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- capakraken_prod_redis:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
migrator:
|
||||||
|
image: ${MIGRATOR_IMAGE:?set MIGRATOR_IMAGE}
|
||||||
|
restart: "no"
|
||||||
|
env_file:
|
||||||
|
- .env.production
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://capakraken:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/capakraken
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
app:
|
||||||
|
image: ${APP_IMAGE:?set APP_IMAGE}
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${APP_HOST_PORT:-3000}:3000"
|
||||||
|
env_file:
|
||||||
|
- .env.production
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://capakraken:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/capakraken
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3000/api/ready"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
capakraken_prod_pgdata:
|
||||||
|
name: capakraken_prod_pgdata
|
||||||
|
capakraken_prod_redis:
|
||||||
|
name: capakraken_prod_redis
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
| Topic | File | Use |
|
| Topic | File | Use |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| AI excellence due diligence | [ai-excellence-due-diligence-roadmap.md](/home/hartmut/Documents/Copilot/capakraken/docs/ai-excellence-due-diligence-roadmap.md) | Frank quality assessment and cleanup roadmap toward a showcase AI-built project |
|
||||||
|
| Target CI/CD architecture | [cicd-target-architecture.md](/home/hartmut/Documents/Copilot/capakraken/docs/cicd-target-architecture.md) | Proposed image-based build, deploy, and rollback flow |
|
||||||
| Active roadmap and open gaps | [product-roadmap.md](/home/hartmut/Documents/Copilot/capakraken/docs/product-roadmap.md) | Primary backlog and current delivery order |
|
| Active roadmap and open gaps | [product-roadmap.md](/home/hartmut/Documents/Copilot/capakraken/docs/product-roadmap.md) | Primary backlog and current delivery order |
|
||||||
| Estimating system design | [estimating-extension-design.md](/home/hartmut/Documents/Copilot/capakraken/docs/estimating-extension-design.md) | Workbook analysis, field mapping, and implementation plan |
|
| Estimating system design | [estimating-extension-design.md](/home/hartmut/Documents/Copilot/capakraken/docs/estimating-extension-design.md) | Workbook analysis, field mapping, and implementation plan |
|
||||||
| Dispo import implementation | [dispo-import-implementation.md](/home/hartmut/Documents/Copilot/capakraken/docs/dispo-import-implementation.md) | Clean-slate Dispo v2 import design, mapping rules, staging flow, and commit policy |
|
| Dispo import implementation | [dispo-import-implementation.md](/home/hartmut/Documents/Copilot/capakraken/docs/dispo-import-implementation.md) | Clean-slate Dispo v2 import design, mapping rules, staging flow, and commit policy |
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
# AI Excellence Due Diligence And Roadmap
|
||||||
|
|
||||||
|
**Date:** 2026-03-29
|
||||||
|
**Purpose:** Frank assessment of the current codebase plus a pragmatic roadmap to turn CapaKraken into a reference project for disciplined AI-assisted software engineering.
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
CapaKraken is already well beyond a prototype. The repository shows a real domain model, a non-trivial bounded-context split, a meaningful automated test baseline, and active delivery discipline.
|
||||||
|
|
||||||
|
At the same time, the codebase still carries several risks that are typical of fast-moving AI-assisted development:
|
||||||
|
|
||||||
|
1. some critical cross-cutting concerns are only partially productized
|
||||||
|
2. several files and routers have grown beyond comfortable ownership size
|
||||||
|
3. broad read access and global real-time fan-out still leak too much internal state
|
||||||
|
4. spreadsheet import parsing remains a security and reliability weak point
|
||||||
|
5. the current operational model is improving, but not yet fully standardized
|
||||||
|
|
||||||
|
The project feels strong enough to build on, but it is not yet a showcase of "how AI-built software should look" without another cleanup and hardening pass.
|
||||||
|
|
||||||
|
## Current Strengths
|
||||||
|
|
||||||
|
- Clear monorepo and package split across `api`, `application`, `db`, `engine`, `shared`, `staffing`, `ui`, and `web`, with shared tooling through `turbo` and `pnpm`.
|
||||||
|
- Product scope is substantial and business-oriented rather than CRUD-only: estimating, planning, demand/assignment, chargeability, import/export, dashboards, report building, and admin surfaces.
|
||||||
|
- CI already enforces typecheck, lint, unit tests, build, and E2E with PostgreSQL and Redis in the loop.
|
||||||
|
- Application-layer use cases exist and are not just thin router wrappers.
|
||||||
|
- Documentation coverage is materially better than average for a fast-moving product.
|
||||||
|
|
||||||
|
## Due Diligence Findings
|
||||||
|
|
||||||
|
### Critical
|
||||||
|
|
||||||
|
1. Real-time SSE delivery is still global instead of audience-scoped.
|
||||||
|
Evidence: [route.ts](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/app/api/sse/timeline/route.ts) subscribes any authenticated user to the same bus, and [event-bus.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/sse/event-bus.ts) maintains one global subscriber set and broadcasts events without per-user or per-role filtering.
|
||||||
|
Risk: internal planning, vacation, budget, task, and notification metadata can be over-shared to authenticated users who should not see global changes.
|
||||||
|
|
||||||
|
2. Untrusted spreadsheet parsing still depends on `xlsx@0.18.5`.
|
||||||
|
Evidence: import parsing remains in [read-workbook.ts](/home/hartmut/Documents/Copilot/capakraken/packages/application/src/use-cases/dispo-import/read-workbook.ts), browser-side parsing remains in [excel.ts](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/lib/excel.ts) and [skillMatrixParser.ts](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/lib/skillMatrixParser.ts), and the package is still declared in [apps/web/package.json](/home/hartmut/Documents/Copilot/capakraken/apps/web/package.json) and [packages/application/package.json](/home/hartmut/Documents/Copilot/capakraken/packages/application/package.json).
|
||||||
|
Risk: file-import attack surface remains higher than acceptable for a flagship reference implementation.
|
||||||
|
|
||||||
|
### High
|
||||||
|
|
||||||
|
1. Several high-sensitivity read paths are still too broad for least-privilege.
|
||||||
|
Evidence: multiple planning, resource, project, dashboard, allocation, and timeline reads still use `protectedProcedure` rather than narrower role-specific gates in [dashboard.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/dashboard.ts), [timeline.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/timeline.ts), [allocation.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/allocation.ts), [resource.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/resource.ts), and [project.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/project.ts).
|
||||||
|
Risk: authorization intent remains hard to reason about and easy to regress.
|
||||||
|
|
||||||
|
2. Router and UI module size is now an operational risk.
|
||||||
|
Evidence: [assistant-tools.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/assistant-tools.ts), [resource.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/resource.ts), [allocation.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/allocation.ts), [timeline.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/timeline.ts), [vacation.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/vacation.ts), and large frontend files such as [SystemSettingsClient.tsx](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/components/admin/SystemSettingsClient.tsx) and [TimelineProjectPanel.tsx](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/components/timeline/TimelineProjectPanel.tsx) are each well past the size where safe ownership stays easy.
|
||||||
|
Risk: AI-generated changes become harder to review, humans lose local reasoning context, and regressions become more likely.
|
||||||
|
|
||||||
|
3. Secret handling is still application-database centric.
|
||||||
|
Evidence: system settings mutate and persist API keys and SMTP credentials in [settings.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/settings.ts).
|
||||||
|
Risk: operational secrets remain too coupled to the main app data plane for a gold-standard project.
|
||||||
|
|
||||||
|
### Medium
|
||||||
|
|
||||||
|
1. Rate limiting is process-local and not deployment-grade.
|
||||||
|
Evidence: [rate-limit.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/middleware/rate-limit.ts) uses an in-memory `Map` and explicitly notes that multi-instance deployments need Redis-backed replacement.
|
||||||
|
Risk: protections weaken as soon as the app scales horizontally.
|
||||||
|
|
||||||
|
2. Performance hotspots are well understood but not yet structurally solved.
|
||||||
|
Evidence: the current performance review identifies repeated in-memory filtering, broad invalidation, and heavyweight timeline/report derivations in [performance-optimization-review-2026-03-18.md](/home/hartmut/Documents/Copilot/capakraken/docs/performance-optimization-review-2026-03-18.md).
|
||||||
|
Risk: user experience and infrastructure cost will degrade as data volume grows.
|
||||||
|
|
||||||
|
3. Production delivery is still in transition.
|
||||||
|
Evidence: the current repo now has a target CI/CD path, but the old manual production path still coexists with the new image-based deploy model in [cicd-target-architecture.md](/home/hartmut/Documents/Copilot/capakraken/docs/cicd-target-architecture.md).
|
||||||
|
Risk: the operational source of truth is not yet singular.
|
||||||
|
|
||||||
|
## Overall Rating
|
||||||
|
|
||||||
|
### Product Engineering Quality
|
||||||
|
|
||||||
|
`8/10`
|
||||||
|
|
||||||
|
This is materially better than a typical startup CRUD app and already has the bones of a serious internal platform or vertical SaaS.
|
||||||
|
|
||||||
|
### Security Posture
|
||||||
|
|
||||||
|
`6/10`
|
||||||
|
|
||||||
|
There are good foundations, but the remaining SSE, spreadsheet, and least-privilege gaps are not acceptable for a "parade example" yet.
|
||||||
|
|
||||||
|
### Maintainability
|
||||||
|
|
||||||
|
`6.5/10`
|
||||||
|
|
||||||
|
The architecture is promising, but file size, router density, and compatibility residue will eventually slow everyone down unless addressed deliberately.
|
||||||
|
|
||||||
|
### Operational Maturity
|
||||||
|
|
||||||
|
`6.5/10`
|
||||||
|
|
||||||
|
Good CI and improving deploy discipline are in place, but production standardization still needs one more step.
|
||||||
|
|
||||||
|
### AI-Excellence Readiness
|
||||||
|
|
||||||
|
`6/10`
|
||||||
|
|
||||||
|
The project already proves that AI can help build serious software fast. It does not yet prove that AI-assisted development can stay consistently clean, minimal, and audit-friendly at scale.
|
||||||
|
|
||||||
|
## What A Showcase AI Project Should Demonstrate
|
||||||
|
|
||||||
|
To be a true showcase for AI-assisted development, this repository should visibly demonstrate:
|
||||||
|
|
||||||
|
- small, composable files with clear ownership boundaries
|
||||||
|
- explicit security and permission models at every boundary
|
||||||
|
- deterministic build and deploy flow
|
||||||
|
- measurable quality gates beyond "tests pass"
|
||||||
|
- strong documentation that explains not only what exists, but why the structure is this way
|
||||||
|
- low-friction reviewability, so humans can still govern AI speed
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### Phase 1: Close the Dangerous Gaps
|
||||||
|
|
||||||
|
Target window: 1 to 2 weeks
|
||||||
|
|
||||||
|
Goals:
|
||||||
|
|
||||||
|
- Replace global SSE fan-out with audience-aware channels.
|
||||||
|
- Remove `xlsx` from untrusted import paths or isolate it behind a hardened parser boundary.
|
||||||
|
- Create a route access matrix and reclassify broad `protectedProcedure` read endpoints.
|
||||||
|
- Move production secrets out of regular application settings, or add an interim encrypted-secrets layer with clear migration path.
|
||||||
|
|
||||||
|
Definition of done:
|
||||||
|
|
||||||
|
- standard users cannot subscribe to unrelated real-time planning events
|
||||||
|
- file import paths have documented limits and safer parsing
|
||||||
|
- every sensitive router is explicitly classified by audience
|
||||||
|
- secret storage policy is documented and enforced
|
||||||
|
|
||||||
|
### Phase 2: Cut Down Complexity
|
||||||
|
|
||||||
|
Target window: 2 to 4 weeks
|
||||||
|
|
||||||
|
Goals:
|
||||||
|
|
||||||
|
- Split oversized routers into bounded router modules by feature slice.
|
||||||
|
- Split oversized React components into container, state, and presentational layers.
|
||||||
|
- Introduce file-size and complexity guardrails for new code.
|
||||||
|
- Create "AI review rules" for generated patches: max file growth, required tests, required docs for cross-cutting changes.
|
||||||
|
|
||||||
|
Priority candidates:
|
||||||
|
|
||||||
|
- `packages/api/src/router/assistant-tools.ts`
|
||||||
|
- `packages/api/src/router/resource.ts`
|
||||||
|
- `packages/api/src/router/allocation.ts`
|
||||||
|
- `packages/api/src/router/timeline.ts`
|
||||||
|
- `apps/web/src/components/admin/SystemSettingsClient.tsx`
|
||||||
|
- `apps/web/src/components/timeline/*`
|
||||||
|
|
||||||
|
Definition of done:
|
||||||
|
|
||||||
|
- no new source file over 500 lines without an explicit exception
|
||||||
|
- top 10 largest business-critical source files are materially reduced
|
||||||
|
- patch reviews become narrower and easier to reason about
|
||||||
|
|
||||||
|
### Phase 3: Make Quality Measurable
|
||||||
|
|
||||||
|
Target window: 2 to 3 weeks
|
||||||
|
|
||||||
|
Goals:
|
||||||
|
|
||||||
|
- Add architecture fitness checks, not just lint/tests.
|
||||||
|
- Add API authorization tests for all sensitive routers.
|
||||||
|
- Add bundle-size and route-size monitoring for the web app.
|
||||||
|
- Add mutation-path audit coverage checks where business-critical state changes occur.
|
||||||
|
- Add a dependency and unsafe-library policy.
|
||||||
|
|
||||||
|
Suggested checks:
|
||||||
|
|
||||||
|
- role/permission regression tests
|
||||||
|
- SSE audience contract tests
|
||||||
|
- import abuse tests with oversized files
|
||||||
|
- max file size / max router size lint or CI checks
|
||||||
|
- coverage thresholds for critical packages
|
||||||
|
|
||||||
|
Definition of done:
|
||||||
|
|
||||||
|
- the repo can fail CI for architectural regressions, not only syntax or unit failures
|
||||||
|
- critical security assumptions are test-backed
|
||||||
|
|
||||||
|
### Phase 4: Standardize Operations
|
||||||
|
|
||||||
|
Target window: 1 to 2 weeks
|
||||||
|
|
||||||
|
Goals:
|
||||||
|
|
||||||
|
- complete the move to image-based deploys as the canonical path
|
||||||
|
- document staging and production bootstrap as code, not tribal knowledge
|
||||||
|
- replace in-memory rate limits with Redis-backed limits where appropriate
|
||||||
|
- define rollback drills and incident response playbooks
|
||||||
|
|
||||||
|
Definition of done:
|
||||||
|
|
||||||
|
- one production deployment path
|
||||||
|
- one rollback path
|
||||||
|
- one source of truth for runtime configuration
|
||||||
|
|
||||||
|
### Phase 5: Turn It Into A Reference Project
|
||||||
|
|
||||||
|
Target window: ongoing
|
||||||
|
|
||||||
|
Goals:
|
||||||
|
|
||||||
|
- add a concise engineering doctrine for AI-assisted development in this repo
|
||||||
|
- publish coding heuristics for humans and AI: file size limits, change budgets, ownership boundaries, review expectations
|
||||||
|
- maintain a "why this is structured this way" architecture guide
|
||||||
|
- log selected before/after refactors to demonstrate how AI was used responsibly
|
||||||
|
|
||||||
|
Artifacts to add:
|
||||||
|
|
||||||
|
- `docs/engineering-doctrine.md`
|
||||||
|
- `docs/architecture-decision-records/`
|
||||||
|
- `docs/ai-collaboration-standards.md`
|
||||||
|
- a small set of "reference slices" that show exemplary patterns end to end
|
||||||
|
|
||||||
|
## Suggested Order Of Execution
|
||||||
|
|
||||||
|
1. SSE scoping
|
||||||
|
2. spreadsheet import hardening
|
||||||
|
3. access-matrix and authorization tightening
|
||||||
|
4. secrets policy
|
||||||
|
5. router/component decomposition
|
||||||
|
6. architecture fitness checks in CI
|
||||||
|
7. full operational standardization
|
||||||
|
|
||||||
|
## Success Criteria For The Next 60 Days
|
||||||
|
|
||||||
|
- no critical or high-severity known security gap remains open without an owner and due date
|
||||||
|
- no core router continues to grow unchecked
|
||||||
|
- at least one major domain slice is refactored into a clear "reference implementation" pattern
|
||||||
|
- production deployment uses the same artifact that passed CI
|
||||||
|
- the repo gains explicit AI-development rules that improve reviewability instead of just increasing output
|
||||||
|
|
||||||
|
## Bottom Line
|
||||||
|
|
||||||
|
CapaKraken is already good enough to justify further investment. It is not a cleanup disaster.
|
||||||
|
|
||||||
|
The opportunity is not to rebuild it. The opportunity is to harden the weak edges, reduce oversized ownership surfaces, and make the engineering standards visible enough that the repository becomes evidence that AI can accelerate serious software without normalizing architectural debt.
|
||||||
@@ -14,11 +14,10 @@ Der Assistant ist bereits relativ breit aufgestellt:
|
|||||||
|
|
||||||
Trotzdem ist die Paritaet zur eigentlichen App/API noch nicht erreicht. Die groessten Luecken liegen nicht bei "gar nichts vorhanden", sondern bei:
|
Trotzdem ist die Paritaet zur eigentlichen App/API noch nicht erreicht. Die groessten Luecken liegen nicht bei "gar nichts vorhanden", sondern bei:
|
||||||
|
|
||||||
- fehlenden Admin- und Konfigurationsfaehigkeiten,
|
- weiterhin fehlenden tiefen Fach-Readmodels und Spezialworkflows,
|
||||||
- fehlenden tiefen Fach-Readmodels,
|
- noch nicht vollstaendiger Router-/Objektscope-Paritaet,
|
||||||
- inkonsistentem Permission-Gating,
|
- fehlender Approval-/Governance-UX ausserhalb des Chats,
|
||||||
- fehlender serverseitiger Absicherung fuer schreibende AI-Aktionen,
|
- und einigen verbleibenden objektbezogenen Sichtbarkeitsfehlern.
|
||||||
- und einigen objektbezogenen Sichtbarkeitsfehlern.
|
|
||||||
|
|
||||||
## Architektur des Assistants
|
## Architektur des Assistants
|
||||||
|
|
||||||
@@ -76,8 +75,38 @@ Es gibt aktuell vier Permission-/Scope-Ebenen:
|
|||||||
- `import_csv_data`
|
- `import_csv_data`
|
||||||
- `list_dispo_import_batches`
|
- `list_dispo_import_batches`
|
||||||
- `get_dispo_import_batch`
|
- `get_dispo_import_batch`
|
||||||
- damit sind CSV-Export, CSV-Import und die Batch-Uebersicht der Dispo-Importe jetzt ueber echte Router-Pfade verfuegbar
|
- `stage_dispo_import_batch`
|
||||||
- Estimates: nur Suche, Detail und Anlegen, aber kein voller Lifecycle
|
- `validate_dispo_import_batch`
|
||||||
|
- `cancel_dispo_import_batch`
|
||||||
|
- `list_dispo_staged_resources`
|
||||||
|
- `list_dispo_staged_projects`
|
||||||
|
- `list_dispo_staged_assignments`
|
||||||
|
- `list_dispo_staged_vacations`
|
||||||
|
- `list_dispo_staged_unresolved_records`
|
||||||
|
- `resolve_dispo_staged_record`
|
||||||
|
- `commit_dispo_import_batch`
|
||||||
|
- damit sind CSV-Export, CSV-Import sowie die operativen Dispo-Import-Workflows jetzt ueber echte Router-Pfade verfuegbar
|
||||||
|
- Admin-/Systemsteuerung:
|
||||||
|
- `get_system_settings`
|
||||||
|
- `update_system_settings`
|
||||||
|
- `test_ai_connection`
|
||||||
|
- `test_smtp_connection`
|
||||||
|
- `test_gemini_connection`
|
||||||
|
- `get_ai_configured`
|
||||||
|
- `list_system_role_configs`
|
||||||
|
- `update_system_role_config`
|
||||||
|
- `list_webhooks`
|
||||||
|
- `get_webhook`
|
||||||
|
- `create_webhook`
|
||||||
|
- `update_webhook`
|
||||||
|
- `delete_webhook`
|
||||||
|
- `test_webhook`
|
||||||
|
- Settings/Webhooks laufen ueber die echten Router; Secrets werden in Assistant-Antworten maskiert
|
||||||
|
- Estimates:
|
||||||
|
- Suche
|
||||||
|
- Detail / Weekly Phasing / Commercial Terms auf Controller-/Manager-/Admin-Niveau
|
||||||
|
- zentrale Lifecycle-Mutationen inkl. Revision, Export und Planning Handoff
|
||||||
|
- Restluecke: fachlich tiefere Unterobjekte und Spezialworkflows ausserhalb der bereits angebundenen Router-Operationen
|
||||||
- Reports: `run_report` ist flexibel, deckt aber nicht die spezialisierten Report-/Analyse-Readmodels ab
|
- Reports: `run_report` ist flexibel, deckt aber nicht die spezialisierten Report-/Analyse-Readmodels ab
|
||||||
- Chargeability / Transparenz:
|
- Chargeability / Transparenz:
|
||||||
- `get_chargeability_report`
|
- `get_chargeability_report`
|
||||||
@@ -88,24 +117,24 @@ Es gibt aktuell vier Permission-/Scope-Ebenen:
|
|||||||
- vereinfachte History-Abfragen
|
- vereinfachte History-Abfragen
|
||||||
- echte Audit-API fuer Liste, Detail, Entity-History, Timeline und Activity Summary
|
- echte Audit-API fuer Liste, Detail, Entity-History, Timeline und Activity Summary
|
||||||
- Governance-Workbench ausserhalb des Chats bleibt offen
|
- Governance-Workbench ausserhalb des Chats bleibt offen
|
||||||
- Notification/Tasking: Kernfaelle vorhanden, aber keine volle Reminder-/Task-/Notification-Paritaet
|
- Notification/Tasking:
|
||||||
|
- Self-Service-Reads und Reminder-Verwaltung vorhanden
|
||||||
|
- Manager-/Admin-Lifecycle fuer Notification-, Task- und Broadcast-Workflows vorhanden
|
||||||
|
- Restluecke: weitere Spezialfaelle ausserhalb der bereits exponierten Router-Operationen
|
||||||
- Country-/Location-Stammdaten: nur lesend und auch dort nur flach
|
- Country-/Location-Stammdaten: nur lesend und auch dort nur flach
|
||||||
- Insights: Summary-Ebene vorhanden, Drilldowns fehlen
|
- Insights: Summary-Ebene vorhanden, Drilldowns fehlen
|
||||||
|
- Rollen-/Client-/Org-Unit-Stammdaten:
|
||||||
|
- Kernmutationen fuer Rollen, Clients und Org-Units laufen jetzt ueber die echten Router-Pfade statt ueber Assistant-Sonderlogik
|
||||||
|
- Restluecke: weitere Readmodels und Lifecycle-Faelle ausserhalb der bereits exponierten Router-Operationen
|
||||||
|
|
||||||
### Vollstaendig fehlend oder fachlich nicht ausreichend
|
### Vollstaendig fehlend oder fachlich nicht ausreichend
|
||||||
|
|
||||||
- Webhook-Administration
|
- Country- und Metro-City-Administration ausserhalb der bereits vorhandenen Kernmutationen
|
||||||
- System Settings / AI / SMTP / Image-Provider Administration
|
- Governance-/Approval-Workspace ausserhalb des Chats
|
||||||
- System Role Config Administration
|
|
||||||
- Import/Export-Flows
|
|
||||||
- User Self-Service und Preferences
|
|
||||||
- Country- und Metro-City-Administration
|
|
||||||
- Voller Estimate-Lifecycle
|
|
||||||
- Dispo-/Import-spezifische Flows
|
|
||||||
|
|
||||||
## Kritische Inkonsistenzen und Risiken
|
## Kritische Inkonsistenzen und Risiken
|
||||||
|
|
||||||
Stand 2026-03-29: Die frueheren P0s bei Notification-Scoping, `list_users`, Mutation-Audit und reinen Permission-Texten sind behoben. Holiday-Calendar-Lesezugriffe sowie Admin-Mutationen fuer Kalender und Entries sind jetzt im Assistant vorhanden. Die folgenden Punkte bleiben relevant.
|
Stand 2026-03-29: Die frueheren P0s bei Notification-Scoping, `list_users`, Mutation-Audit und reinen Permission-Texten sind behoben. Zusaetzlich sind User-Self-Service/Admin-Paritaet im Tool-Visibility-Layer, Manager-Notification-Lifecycle und die wichtigsten Estimate-Lifecycle-/Readmodel-Gates jetzt an die Router-Rollen angenaehert. Holiday-Calendar-Lesezugriffe sowie Admin-Mutationen fuer Kalender und Entries sind ebenfalls im Assistant vorhanden. Die folgenden Punkte bleiben relevant.
|
||||||
|
|
||||||
### P0: Human-in-the-Loop ist serverseitig persistiert, aber noch nicht als vollwertiger Approval-Workspace ausgebaut
|
### P0: Human-in-the-Loop ist serverseitig persistiert, aber noch nicht als vollwertiger Approval-Workspace ausgebaut
|
||||||
|
|
||||||
@@ -174,6 +203,7 @@ Aktuell im Assistant vorhanden:
|
|||||||
Restluecke:
|
Restluecke:
|
||||||
|
|
||||||
- Country-/Metro-City-Stammdaten und tiefere Standortregeln sind weiterhin nicht in derselben Pflegebreite wie die eigentliche Admin-Oberflaeche abgedeckt
|
- Country-/Metro-City-Stammdaten und tiefere Standortregeln sind weiterhin nicht in derselben Pflegebreite wie die eigentliche Admin-Oberflaeche abgedeckt
|
||||||
|
- Weitere Admin-Stammdatenbereiche mit direkten Assistant-Queries, vor allem Resource-/Project-/Vacation-Spezialfaelle, brauchen weiterhin Router-Paritaet
|
||||||
|
|
||||||
### Timeline und Disposition
|
### Timeline und Disposition
|
||||||
|
|
||||||
@@ -261,9 +291,17 @@ Fehlend:
|
|||||||
- Webhooks:
|
- Webhooks:
|
||||||
- Liste, Detail, Create, Update, Delete, Test
|
- Liste, Detail, Create, Update, Delete, Test
|
||||||
|
|
||||||
Konsequenz:
|
Aktuell im Assistant vorhanden:
|
||||||
|
|
||||||
- Ein Admin kann in der UI deutlich mehr Systemsteuerung als der Assistant.
|
- System Settings lesen/aktualisieren
|
||||||
|
- AI-/SMTP-/Gemini-Connection-Tests
|
||||||
|
- AI-Konfigurationsstatus lesen
|
||||||
|
- System-Role-Configs lesen/aktualisieren
|
||||||
|
- Webhooks lesen/anlegen/aendern/loeschen/testen
|
||||||
|
|
||||||
|
Restluecke:
|
||||||
|
|
||||||
|
- eigenstaendige Admin-Oberflaechen und mehrschrittige Governance-Workflows ausserhalb des Chats
|
||||||
|
|
||||||
### User Self-Service
|
### User Self-Service
|
||||||
|
|
||||||
@@ -276,7 +314,7 @@ Konsequenz:
|
|||||||
|
|
||||||
Konsequenz:
|
Konsequenz:
|
||||||
|
|
||||||
- Der Assistant kennt den Nutzerkontext nur oberflaechlich, aber nicht dessen persoenliche Einstellungen und Self-Service-Moeglichkeiten.
|
- Die wichtigsten Self-Service-Bausteine sind inzwischen vorhanden; offen bleiben vor allem tiefere persoenliche Einstellungs- und Spezialflows ausserhalb der bereits exponierten Router-Prozeduren.
|
||||||
|
|
||||||
### Stammdaten fuer Laender und Orte
|
### Stammdaten fuer Laender und Orte
|
||||||
|
|
||||||
@@ -296,50 +334,42 @@ Restluecke:
|
|||||||
|
|
||||||
### Estimate-Lifecycle und Fachobjekte unterhalb des Estimates
|
### Estimate-Lifecycle und Fachobjekte unterhalb des Estimates
|
||||||
|
|
||||||
- volle Estimate-Listen-/Detail-Paritaet
|
|
||||||
- Versionen, Scope Items, Demand Lines, Locking, Freigaben, weiterfuehrende Mutationen
|
|
||||||
|
|
||||||
Aktuell im Assistant vorhanden:
|
Aktuell im Assistant vorhanden:
|
||||||
|
|
||||||
- Suche
|
- Suche
|
||||||
- Baseline-Detail
|
- Detail / Weekly Phasing / Commercial Terms
|
||||||
- Anlegen
|
- Anlegen, Klonen, Draft-Update, Submit, Approve, Revision, Export, Planning Handoff
|
||||||
|
|
||||||
Fehlend:
|
Fehlend:
|
||||||
|
|
||||||
- der eigentliche Arbeitsprozess auf Estimate-Ebene
|
- tiefere Unterobjekt- und Spezialworkflows jenseits der bereits angebundenen Router-Prozeduren
|
||||||
|
|
||||||
### Notifications, Tasks und Reminder
|
### Notifications, Tasks und Reminder
|
||||||
|
|
||||||
Vorhanden:
|
Vorhanden:
|
||||||
|
|
||||||
- Listen, Task-Detail, Statuswechsel, Reminder anlegen, Task fuer User anlegen, Broadcast senden
|
- Listen, Unread Count, Task-Detail, Task Counts, Statuswechsel
|
||||||
|
- Reminder anlegen, listen, aktualisieren, loeschen
|
||||||
|
- generische Notification-Erstellung, Task fuer User anlegen, Task zuweisen, Broadcast senden/listen
|
||||||
|
|
||||||
Fehlend:
|
Fehlend:
|
||||||
|
|
||||||
- Reminder-Liste
|
- weitere Spezialfaelle ausserhalb der bereits exponierten Notification-Router-Prozeduren
|
||||||
- Reminder-Update/Delete
|
|
||||||
- Unread Count
|
|
||||||
- Task Counts
|
|
||||||
- generische Notification-Erstellung mit derselben Tiefe wie `notificationRouter`
|
|
||||||
|
|
||||||
## Capability Gaps nach Router
|
## Capability Gaps nach Router
|
||||||
|
|
||||||
### Komplett fehlende Router-Paritaet
|
### Komplett fehlende Router-Paritaet
|
||||||
|
|
||||||
- `settings`
|
- derzeit keine in den zuvor priorisierten Admin-/Audit-/Import-Bereichen
|
||||||
- `systemRoleConfig`
|
|
||||||
- `webhook`
|
|
||||||
|
|
||||||
### Deutlich unvollstaendige Router-Paritaet
|
### Deutlich unvollstaendige Router-Paritaet
|
||||||
|
|
||||||
- `importExport`
|
- `importExport`
|
||||||
- `dispo`
|
|
||||||
- `timeline` (Kern-Readmodels und wichtigste Write-Paritaet vorhanden, Spezial-Workflows fehlen)
|
- `timeline` (Kern-Readmodels und wichtigste Write-Paritaet vorhanden, Spezial-Workflows fehlen)
|
||||||
- `vacation`
|
- `vacation`
|
||||||
|
- `user`
|
||||||
- `estimate`
|
- `estimate`
|
||||||
- `notification`
|
- `notification`
|
||||||
- `user`
|
|
||||||
- `country`
|
- `country`
|
||||||
- `insights`
|
- `insights`
|
||||||
- `scenario`
|
- `scenario`
|
||||||
@@ -354,6 +384,11 @@ Fehlend:
|
|||||||
- `staffing`
|
- `staffing`
|
||||||
- `report`
|
- `report`
|
||||||
- `dashboard`
|
- `dashboard`
|
||||||
|
- `settings`
|
||||||
|
- `systemRoleConfig`
|
||||||
|
- `webhook`
|
||||||
|
- `importExport`
|
||||||
|
- `dispo`
|
||||||
|
|
||||||
## System Prompt: offensichtliche Uebertreibungen / Irrefuehrungen
|
## System Prompt: offensichtliche Uebertreibungen / Irrefuehrungen
|
||||||
|
|
||||||
@@ -407,6 +442,7 @@ Die Human-in-the-Loop-Regel ist inzwischen serverseitig erzwungen. Der Prompt so
|
|||||||
- `update_holiday_entry`
|
- `update_holiday_entry`
|
||||||
- `delete_holiday_entry`
|
- `delete_holiday_entry`
|
||||||
- `preview_resolved_holidays`
|
- `preview_resolved_holidays`
|
||||||
|
- Status: Kern-Read/Write-Pfad und Preview sind umgesetzt; offen bleiben nur weitergehende Editor-/Governance-Flows.
|
||||||
|
|
||||||
2. Timeline-Assistant-Strang bauen
|
2. Timeline-Assistant-Strang bauen
|
||||||
- Read:
|
- Read:
|
||||||
@@ -422,6 +458,7 @@ Die Human-in-the-Loop-Regel ist inzwischen serverseitig erzwungen. Der Prompt so
|
|||||||
- `get_chargeability_report`
|
- `get_chargeability_report`
|
||||||
- `get_resource_computation_graph`
|
- `get_resource_computation_graph`
|
||||||
- `get_project_computation_graph`
|
- `get_project_computation_graph`
|
||||||
|
- Status: die zentralen Readmodels sind umgesetzt; offen bleibt vor allem breitere Reuse-Tiefe in weiteren Spezialansichten.
|
||||||
|
|
||||||
### P2: Admin- und Stammdaten-Paritaet
|
### P2: Admin- und Stammdaten-Paritaet
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# CI/CD Target Architecture
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
This document captures the intended delivery model for CapaKraken without replacing the currently working manual production setup immediately.
|
||||||
|
|
||||||
|
The target state is:
|
||||||
|
|
||||||
|
1. CI validates every PR.
|
||||||
|
2. GitHub Actions builds immutable Docker images.
|
||||||
|
3. Staging and production pull those exact images from a registry.
|
||||||
|
4. Database migrations run as an explicit deploy step.
|
||||||
|
5. Traffic is considered safe only after the app answers `GET /api/ready`.
|
||||||
|
|
||||||
|
## Core Idea
|
||||||
|
|
||||||
|
The production host should stop building application code from a Git checkout. Instead, it should only:
|
||||||
|
|
||||||
|
- pull a versioned `app` image
|
||||||
|
- pull a matching `migrator` image
|
||||||
|
- run Prisma deploy migrations
|
||||||
|
- start the application container
|
||||||
|
- wait for readiness
|
||||||
|
|
||||||
|
That removes "works on the server but not in CI" drift and makes rollbacks much simpler.
|
||||||
|
|
||||||
|
## Delivery Flow
|
||||||
|
|
||||||
|
### 1. Pull Request Validation
|
||||||
|
|
||||||
|
The existing `CI` workflow continues to validate:
|
||||||
|
|
||||||
|
- typecheck
|
||||||
|
- lint
|
||||||
|
- unit tests
|
||||||
|
- build
|
||||||
|
- E2E
|
||||||
|
|
||||||
|
This remains the quality gate before merge.
|
||||||
|
|
||||||
|
### 2. Image Build
|
||||||
|
|
||||||
|
The new manual workflow [release-image.yml](/home/hartmut/Documents/Copilot/capakraken/.github/workflows/release-image.yml) builds two images from [Dockerfile.prod](/home/hartmut/Documents/Copilot/capakraken/Dockerfile.prod):
|
||||||
|
|
||||||
|
- `runner` target as the production app image
|
||||||
|
- `migrator` target as the Prisma migration image
|
||||||
|
|
||||||
|
Recommended tag format:
|
||||||
|
|
||||||
|
- `sha-<git-commit>`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ghcr.io/<owner>/capakraken-app:sha-abc123
|
||||||
|
ghcr.io/<owner>/capakraken-migrator:sha-abc123
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Staging Deploy
|
||||||
|
|
||||||
|
The staging workflow [deploy-staging.yml](/home/hartmut/Documents/Copilot/capakraken/.github/workflows/deploy-staging.yml) is intended to:
|
||||||
|
|
||||||
|
1. connect to the staging host over SSH
|
||||||
|
2. copy the deploy assets
|
||||||
|
3. export `APP_IMAGE` and `MIGRATOR_IMAGE`
|
||||||
|
4. run [deploy-compose.sh](/home/hartmut/Documents/Copilot/capakraken/tooling/deploy/deploy-compose.sh)
|
||||||
|
|
||||||
|
The compose file used for this target flow is [docker-compose.cicd.yml](/home/hartmut/Documents/Copilot/capakraken/docker-compose.cicd.yml).
|
||||||
|
|
||||||
|
### 4. Production Promotion
|
||||||
|
|
||||||
|
The production workflow [deploy-prod.yml](/home/hartmut/Documents/Copilot/capakraken/.github/workflows/deploy-prod.yml) follows the same logic as staging, but the image tag is promoted manually.
|
||||||
|
|
||||||
|
That means production uses an image that was already built and can already have been exercised in staging.
|
||||||
|
|
||||||
|
## Required Infrastructure
|
||||||
|
|
||||||
|
### Minimum
|
||||||
|
|
||||||
|
- GitHub repository with Actions enabled
|
||||||
|
- GHCR or another container registry
|
||||||
|
- 1 Linux host with Docker and Docker Compose
|
||||||
|
- PostgreSQL
|
||||||
|
- Redis
|
||||||
|
- reverse proxy such as nginx
|
||||||
|
- SSH access from GitHub Actions to the host
|
||||||
|
|
||||||
|
### Recommended
|
||||||
|
|
||||||
|
- separate staging and production hosts
|
||||||
|
- GitHub Environments for `staging` and `production`
|
||||||
|
- required reviewer approval for `production`
|
||||||
|
- backup strategy for PostgreSQL volumes
|
||||||
|
- uptime monitoring and error tracking
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
### GitHub Environment Secrets
|
||||||
|
|
||||||
|
For `staging`:
|
||||||
|
|
||||||
|
- `STAGING_SSH_HOST`
|
||||||
|
- `STAGING_SSH_PORT`
|
||||||
|
- `STAGING_SSH_USER`
|
||||||
|
- `STAGING_SSH_KEY`
|
||||||
|
- `STAGING_DEPLOY_PATH`
|
||||||
|
- `STAGING_APP_HOST_PORT`
|
||||||
|
- `STAGING_GHCR_USERNAME`
|
||||||
|
- `STAGING_GHCR_TOKEN`
|
||||||
|
|
||||||
|
For `production`:
|
||||||
|
|
||||||
|
- `PROD_SSH_HOST`
|
||||||
|
- `PROD_SSH_PORT`
|
||||||
|
- `PROD_SSH_USER`
|
||||||
|
- `PROD_SSH_KEY`
|
||||||
|
- `PROD_DEPLOY_PATH`
|
||||||
|
- `PROD_APP_HOST_PORT`
|
||||||
|
- `PROD_GHCR_USERNAME`
|
||||||
|
- `PROD_GHCR_TOKEN`
|
||||||
|
|
||||||
|
### Host-side Files
|
||||||
|
|
||||||
|
Each target host should already have:
|
||||||
|
|
||||||
|
- `.env.production`
|
||||||
|
- Docker installed
|
||||||
|
- network access to the container registry
|
||||||
|
|
||||||
|
The repository now also contains a small host example at [tooling/deploy/.env.production.example](/home/hartmut/Documents/Copilot/capakraken/tooling/deploy/.env.production.example) and an operator note at [tooling/deploy/README.md](/home/hartmut/Documents/Copilot/capakraken/tooling/deploy/README.md).
|
||||||
|
|
||||||
|
### Minimum Host Bootstrap
|
||||||
|
|
||||||
|
For each target host, create a dedicated deploy directory such as `/opt/capakraken` and place these files there:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docker-compose.cicd.yml
|
||||||
|
.env.production
|
||||||
|
tooling/deploy/deploy-compose.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env.production` should hold the long-lived runtime settings, including:
|
||||||
|
|
||||||
|
```env
|
||||||
|
POSTGRES_PASSWORD=<long-random-password>
|
||||||
|
NEXTAUTH_URL=https://capakraken.example.com
|
||||||
|
NEXTAUTH_SECRET=<long-random-secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
GitHub Actions only injects the short-lived image references through `deploy.env`. The deploy script then loads both files before calling Docker Compose, so compose interpolation and container runtime env use the same source of truth.
|
||||||
|
|
||||||
|
## Database Policy
|
||||||
|
|
||||||
|
For release environments, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @capakraken/db db:migrate:deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not use `db:push` as the main production deployment mechanism. `db:push` is convenient for local development, but it does not give the release traceability that a migration-based deploy requires.
|
||||||
|
|
||||||
|
## Rollback Model
|
||||||
|
|
||||||
|
Rollback should be image-based:
|
||||||
|
|
||||||
|
1. choose the previous good `sha-...` tag
|
||||||
|
2. run the production deploy workflow again with that tag
|
||||||
|
3. confirm readiness
|
||||||
|
|
||||||
|
This is only safe when schema changes follow backwards-compatible expand and contract rules.
|
||||||
|
|
||||||
|
## How A Production Update Works
|
||||||
|
|
||||||
|
The intended production update path is:
|
||||||
|
|
||||||
|
1. merge to `main` after the existing CI workflow is green
|
||||||
|
2. run [release-image.yml](/home/hartmut/Documents/Copilot/capakraken/.github/workflows/release-image.yml) to build immutable `app` and `migrator` images tagged as `sha-<commit>`
|
||||||
|
3. run [deploy-staging.yml](/home/hartmut/Documents/Copilot/capakraken/.github/workflows/deploy-staging.yml) with that exact image tag
|
||||||
|
4. GitHub Actions uploads the deploy bundle to the staging host and writes a temporary `deploy.env`
|
||||||
|
5. [deploy-compose.sh](/home/hartmut/Documents/Copilot/capakraken/tooling/deploy/deploy-compose.sh) pulls images, starts PostgreSQL and Redis, runs Prisma deploy migrations, starts the new app container, and waits for `GET /api/ready`
|
||||||
|
6. after staging is accepted, run [deploy-prod.yml](/home/hartmut/Documents/Copilot/capakraken/.github/workflows/deploy-prod.yml) with the same tag
|
||||||
|
7. production repeats the same image-based flow, so the running artifact matches staging
|
||||||
|
|
||||||
|
That means the production host no longer builds from Git. It only receives a versioned image and starts it after migrations complete.
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
The repository now contains the CI/CD scaffolding, but the existing manual production setup remains untouched:
|
||||||
|
|
||||||
|
- current manual compose flow: [docker-compose.prod.yml](/home/hartmut/Documents/Copilot/capakraken/docker-compose.prod.yml)
|
||||||
|
- current manual runbook: [ci-cd-manual.md](/home/hartmut/Documents/Copilot/capakraken/docs/ci-cd-manual.md)
|
||||||
|
|
||||||
|
This allows the team to introduce the new path gradually instead of switching production in one step.
|
||||||
@@ -230,7 +230,7 @@ Estimated effort: medium
|
|||||||
|
|
||||||
Proposal:
|
Proposal:
|
||||||
|
|
||||||
- Avoid loading large supporting datasets such as `resource.list({ limit: 500 })` or “all projects” queries when only label resolution for selected IDs is required.
|
- Avoid loading large supporting datasets such as `resource.listStaff({ limit: 500 })` or “all projects” queries when only label resolution for selected IDs is required; prefer `resource.directory` for lightweight label resolution.
|
||||||
- Add lightweight lookup endpoints like `getByIds()` or `resolveLabels()`.
|
- Add lightweight lookup endpoints like `getByIds()` or `resolveLabels()`.
|
||||||
- On the resources page, fetch chargeability/stat enrichments only for the visible page or current filtered result slice.
|
- On the resources page, fetch chargeability/stat enrichments only for the visible page or current filtered result slice.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Route Access Matrix
|
||||||
|
|
||||||
|
**Date:** 2026-03-29
|
||||||
|
**Purpose:** Explicit interim audience model for sensitive API read routes while the broader least-privilege refactor is still in progress.
|
||||||
|
|
||||||
|
## Decision Rules
|
||||||
|
|
||||||
|
- `protectedProcedure`: only for clearly personal or low-sensitivity reads.
|
||||||
|
- `controllerProcedure`: planning, financial, staffing, or portfolio-wide analytics that should only be visible to `CONTROLLER`, `MANAGER`, or `ADMIN`.
|
||||||
|
- Ownership checks: self-service routes stay user-accessible, but only for the caller's own linked resource unless elevated staff access applies.
|
||||||
|
|
||||||
|
## Applied In This Pass
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
All routes in [dashboard.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/dashboard.ts) are treated as portfolio analytics and now require `controllerProcedure`.
|
||||||
|
|
||||||
|
| Route | Classification | Reason |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `getOverview` | `controllerProcedure` | exposes global resource/project counts and budget context |
|
||||||
|
| `getPeakTimes` | `controllerProcedure` | exposes portfolio-wide demand/utilization peaks |
|
||||||
|
| `getTopValueResources` | `controllerProcedure` | exposes ranked value/cost-related resource data |
|
||||||
|
| `getDemand` | `controllerProcedure` | exposes staffing demand by project/person/chapter |
|
||||||
|
| `getDetail` | `controllerProcedure` | aggregates the above into assistant-facing strategic detail |
|
||||||
|
| `getChargeabilityOverview` | `controllerProcedure` | already correctly scoped |
|
||||||
|
| `getBudgetForecast` | `controllerProcedure` | exposes budget burn and exhaustion projections |
|
||||||
|
| `getSkillGaps` | `controllerProcedure` | exposes org-wide capability shortfalls |
|
||||||
|
| `getSkillGapSummary` | `controllerProcedure` | summary variant of strategic skill analytics |
|
||||||
|
| `getProjectHealth` | `controllerProcedure` | exposes portfolio-level delivery risk indicators |
|
||||||
|
|
||||||
|
### Vacation
|
||||||
|
|
||||||
|
Routes in [vacation.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/vacation.ts) now distinguish between self-service and staff oversight.
|
||||||
|
|
||||||
|
| Route | Classification | Reason |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `previewRequest` | self-service | personal validation before request creation |
|
||||||
|
| `create` | self-service with ownership check | users may request only for their own resource |
|
||||||
|
| `cancel` | self-service with ownership check | users may cancel only their own requests |
|
||||||
|
| `list` | self-service scoped to own resource, or full staff view for manager/admin | broad vacation visibility is sensitive absence data |
|
||||||
|
| `getById` | self-service scoped to own vacation, or full staff view for manager/admin | direct object lookup should not bypass ownership |
|
||||||
|
| `getForResource` | self-service scoped to own resource, or full staff view for manager/admin | calculator support should not expose foreign absence data |
|
||||||
|
| `getTeamOverlap` | self-service scoped to own resource, or full staff view for manager/admin | overlap warnings are valid, but only for the caller's team context |
|
||||||
|
| `getPendingApprovals` | manager/admin | approval queue is supervisory data |
|
||||||
|
|
||||||
|
### Resource
|
||||||
|
|
||||||
|
Routes in [resource.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/resource.ts) remain partially deferred, but the clearest sensitive reads are now explicitly scoped.
|
||||||
|
|
||||||
|
| Route | Classification | Reason |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `directory` | authenticated safe directory | dedicated low-sensitivity directory for generic pickers, filters, calendars, and lookups; returns only `id`, `eid`, `displayName`, `chapter`, and `isActive`, limits search to name/EID, and preserves anonymization behavior |
|
||||||
|
| `getMyResource` | self-service | explicit route for the caller's linked resource |
|
||||||
|
| `getChargeabilitySummary` | self-service scoped to own resource, or staff with `VIEW_ALL_RESOURCES` | exposes detailed capacity, holiday, assignment, and target data for an individual resource |
|
||||||
|
| `getValueScores` | explicit permission gate `VIEW_SCORES` | ranked score output should not depend on ad hoc session-role strings |
|
||||||
|
| `getById` | self-service scoped to own resource, or staff with `VIEW_ALL_RESOURCES` | full resource detail page includes person-level operational and cost context |
|
||||||
|
| `getByEid` | self-service scoped to own resource, or staff with `VIEW_ALL_RESOURCES` | direct identifier lookup should not bypass ownership |
|
||||||
|
| `getHoverCard` | self-service scoped to own resource, or staff with `VIEW_ALL_RESOURCES` | hover card exposes rates, role, skills, and staffing targets |
|
||||||
|
| `getByIdentifier` | exact self lookup for regular users; broad lookup for staff with `VIEW_ALL_RESOURCES` | lightweight identifier read returns only identity-safe fields (`id`, `eid`, `displayName`, `chapter`, `isActive`) |
|
||||||
|
| `getByIdentifierDetail` | exact self lookup for regular users; broad lookup for staff with `VIEW_ALL_RESOURCES` | explicit detail route for assistant and UI flows that truly need rates, targets, org placement, and skill/count context |
|
||||||
|
| `resolveByIdentifier` | exact self lookup for regular users; broad lookup for staff with `VIEW_ALL_RESOURCES` | minimal identity resolver used by tool chains to convert free-form names/EIDs into canonical IDs without leaking cost or location detail |
|
||||||
|
| `listSummaries` | staff with `VIEW_ALL_RESOURCES` | staff-only org search that returns non-financial summary cards for discovery and candidate selection |
|
||||||
|
| `listSummariesDetail` | staff with `VIEW_ALL_RESOURCES` | explicit richer search variant for assistant/staff workflows that need FTE, LCR, and chargeability context |
|
||||||
|
| `listStaff` | staff with `VIEW_ALL_RESOURCES` | explicit staff-only list for cost-aware, role-aware, and estimate/planning workflows; supports email search, rates, roles, and dynamic field filters |
|
||||||
|
|
||||||
|
### Resource Directory Split
|
||||||
|
|
||||||
|
This pass introduces an explicit audience split in [resource.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/resource.ts):
|
||||||
|
|
||||||
|
- `resource.directory` is the default route for generic UI selectors and org-directory style lookups.
|
||||||
|
- `resource.listStaff` is the explicit staff-only route for estimate, staffing, and scenario-planning screens that need cost-sensitive resource data.
|
||||||
|
|
||||||
|
The following web consumers now use `resource.directory`:
|
||||||
|
|
||||||
|
- generic resource comboboxes and assignment pickers
|
||||||
|
- vacation filters and team calendar selectors
|
||||||
|
- timeline quick filters, toolbar lookup, and project panel add-member search
|
||||||
|
- project responsible-person picker
|
||||||
|
- computation graph resource selector
|
||||||
|
- batch skill import resource matching
|
||||||
|
|
||||||
|
### Project
|
||||||
|
|
||||||
|
Routes in [project.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/project.ts) now distinguish between lightweight project discovery and planning/commercial detail.
|
||||||
|
|
||||||
|
| Route | Classification | Reason |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `resolveByIdentifier` | authenticated safe resolver | minimal project identity lookup for names/codes/IDs without commercial detail |
|
||||||
|
| `searchSummaries` | authenticated safe summary search | lightweight project discovery returns only code, name, status, dates, and client |
|
||||||
|
| `searchSummariesDetail` | `controllerProcedure` | exposes budget, win probability, and staffing/estimate counts |
|
||||||
|
| `getByIdentifier` | authenticated safe identifier read | exact/fuzzy lookup returns only identity-safe project fields |
|
||||||
|
| `getByIdentifierDetail` | `controllerProcedure` | exposes commercial and staffing detail, including budget, responsible person, category, and top allocations |
|
||||||
|
| `list` | `controllerProcedure` | broad project listing can expose commercial/custom-field planning context |
|
||||||
|
| `getById` | `controllerProcedure` | full project read model includes allocations, demands, and assignments |
|
||||||
|
| `getShoringRatio` | `controllerProcedure` | derived staffing geography analytics should not be generally user-visible |
|
||||||
|
|
||||||
|
### Timeline
|
||||||
|
|
||||||
|
Routes in [timeline.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/timeline.ts) now split personal self-service reads from broad planning views.
|
||||||
|
|
||||||
|
| Route | Classification | Reason |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `getEntries` | `controllerProcedure` | returns broad staffing allocations across projects/resources for a time window |
|
||||||
|
| `getEntriesView` | `controllerProcedure` | exposes the full timeline read model, including demands and assignments |
|
||||||
|
| `getHolidayOverlays` | `controllerProcedure` | org-wide absence overlays reveal staffing availability context |
|
||||||
|
| `getMyEntriesView` | self-service scoped to own linked resource | personal timeline view for `USER`/`VIEWER`; ignores foreign resource scoping and never broadens beyond the caller's linked resource |
|
||||||
|
| `getMyHolidayOverlays` | self-service scoped to own linked resource | personal holiday overlays for the caller's own timeline window without org-wide absence visibility |
|
||||||
|
| `getEntriesDetail` | `controllerProcedure` | assistant-facing planning detail aggregates allocations, demands, assignments, and holiday summaries |
|
||||||
|
| `getHolidayOverlayDetail` | `controllerProcedure` | detailed overlay summaries are planning-sensitive absence context |
|
||||||
|
| `getProjectContext` | `controllerProcedure` | project-side planning context includes all allocations and cross-resource context |
|
||||||
|
| `getProjectContextDetail` | `controllerProcedure` | detailed project timeline context exposes conflict and overlap analysis |
|
||||||
|
| `previewShift` | `controllerProcedure` | shift preview computes operational and budget impacts before mutation |
|
||||||
|
| `getShiftPreviewDetail` | `controllerProcedure` | detail variant includes project metadata plus cost/conflict preview |
|
||||||
|
| `getBudgetStatus` | `controllerProcedure` | budget burn/remaining exposure is commercial data |
|
||||||
|
|
||||||
|
## Review Standard
|
||||||
|
|
||||||
|
- Any new sensitive read route must document one of:
|
||||||
|
- personal self-service ownership
|
||||||
|
- explicit role gate
|
||||||
|
- explicit permission gate
|
||||||
|
- Any route returning portfolio-wide financial, staffing, scheduling, or HR absence data should not default to plain `protectedProcedure`.
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { AllocationStatus, SystemRole } from "@capakraken/shared";
|
import { AllocationStatus, SystemRole } from "@capakraken/shared";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { allocationRouter } from "../router/allocation.js";
|
import { allocationRouter } from "../router/allocation.js";
|
||||||
import { emitAllocationCreated, emitAllocationDeleted, emitNotificationCreated } from "../sse/event-bus.js";
|
import { emitAllocationCreated, emitAllocationDeleted, emitNotificationCreated } from "../sse/event-bus.js";
|
||||||
|
import { checkBudgetThresholds } from "../lib/budget-alerts.js";
|
||||||
|
import { generateAutoSuggestions } from "../lib/auto-staffing.js";
|
||||||
|
import { invalidateDashboardCache } from "../lib/cache.js";
|
||||||
|
import { logger } from "../lib/logger.js";
|
||||||
|
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
||||||
import { createCallerFactory } from "../trpc.js";
|
import { createCallerFactory } from "../trpc.js";
|
||||||
|
|
||||||
vi.mock("../sse/event-bus.js", () => ({
|
vi.mock("../sse/event-bus.js", () => ({
|
||||||
@@ -19,12 +24,29 @@ vi.mock("../lib/cache.js", () => ({
|
|||||||
invalidateDashboardCache: vi.fn(),
|
invalidateDashboardCache: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/auto-staffing.js", () => ({
|
||||||
|
generateAutoSuggestions: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../lib/webhook-dispatcher.js", () => ({
|
vi.mock("../lib/webhook-dispatcher.js", () => ({
|
||||||
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
|
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/logger.js", () => ({
|
||||||
|
logger: {
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const createCaller = createCallerFactory(allocationRouter);
|
const createCaller = createCallerFactory(allocationRouter);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
function createManagerCaller(db: Record<string, unknown>) {
|
function createManagerCaller(db: Record<string, unknown>) {
|
||||||
return createCaller({
|
return createCaller({
|
||||||
session: {
|
session: {
|
||||||
@@ -112,6 +134,9 @@ describe("allocation entry resolution router", () => {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
@@ -134,6 +159,97 @@ describe("allocation entry resolution router", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns the canonical resource availability summary shape", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "resource_1",
|
||||||
|
displayName: "Bruce Banner",
|
||||||
|
eid: "E-001",
|
||||||
|
fte: 1,
|
||||||
|
availability: {
|
||||||
|
monday: 8,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
saturday: 0,
|
||||||
|
sunday: 0,
|
||||||
|
},
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { dailyWorkingHours: 8, code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "assignment_1",
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 4,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
project: { name: "Gelddruckmaschine", shortCode: "GDM" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "vac_1",
|
||||||
|
type: "ANNUAL",
|
||||||
|
status: "APPROVED",
|
||||||
|
startDate: new Date("2026-04-02T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-02T00:00:00.000Z"),
|
||||||
|
isHalfDay: true,
|
||||||
|
halfDayPart: "AFTERNOON",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createManagerCaller(db);
|
||||||
|
const result = await caller.getResourceAvailabilitySummary({
|
||||||
|
resourceId: "resource_1",
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-02T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
resource: "Bruce Banner",
|
||||||
|
period: "2026-04-01 to 2026-04-02",
|
||||||
|
fte: null,
|
||||||
|
workingDays: 2,
|
||||||
|
periodAvailableHours: 16,
|
||||||
|
periodBookedHours: 4,
|
||||||
|
periodRemainingHours: 12,
|
||||||
|
maxHoursPerDay: 8,
|
||||||
|
currentBookedHoursPerDay: 2,
|
||||||
|
availableHoursPerDay: 6,
|
||||||
|
isFullyAvailable: false,
|
||||||
|
existingAllocations: [
|
||||||
|
{
|
||||||
|
project: "Gelddruckmaschine (GDM)",
|
||||||
|
hoursPerDay: 4,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
start: "2026-04-01",
|
||||||
|
end: "2026-04-01",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vacations: [
|
||||||
|
{
|
||||||
|
type: "ANNUAL",
|
||||||
|
start: "2026-04-02",
|
||||||
|
end: "2026-04-02",
|
||||||
|
isHalfDay: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("creates an open demand through allocation.create without requiring isPlaceholder", async () => {
|
it("creates an open demand through allocation.create without requiring isPlaceholder", async () => {
|
||||||
const createdDemandRequirement = {
|
const createdDemandRequirement = {
|
||||||
id: "demand_1",
|
id: "demand_1",
|
||||||
@@ -346,6 +462,217 @@ describe("allocation entry resolution router", () => {
|
|||||||
expect(emitNotificationCreated).toHaveBeenCalledTimes(2);
|
expect(emitNotificationCreated).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates a canonical demand draft with router-owned defaults", async () => {
|
||||||
|
vi.mocked(emitAllocationCreated).mockClear();
|
||||||
|
vi.mocked(emitNotificationCreated).mockClear();
|
||||||
|
|
||||||
|
const createdDemandRequirement = {
|
||||||
|
id: "demand_draft_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
startDate: new Date("2026-04-01"),
|
||||||
|
endDate: new Date("2026-04-15"),
|
||||||
|
hoursPerDay: 6,
|
||||||
|
percentage: 75,
|
||||||
|
role: "Designer",
|
||||||
|
roleId: "role_design",
|
||||||
|
headcount: 2,
|
||||||
|
status: AllocationStatus.PROPOSED,
|
||||||
|
metadata: {},
|
||||||
|
createdAt: new Date("2026-03-13"),
|
||||||
|
updatedAt: new Date("2026-03-13"),
|
||||||
|
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
|
||||||
|
roleEntity: { id: "role_design", name: "Designer", color: "#0099FF" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const db = createDemandWorkflowDb({
|
||||||
|
demandRequirement: {
|
||||||
|
create: vi.fn().mockResolvedValue(createdDemandRequirement),
|
||||||
|
},
|
||||||
|
}) as Record<string, unknown>;
|
||||||
|
Object.assign(db, {
|
||||||
|
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const caller = createManagerCaller(db);
|
||||||
|
const result = await caller.createDemand({
|
||||||
|
projectId: "project_1",
|
||||||
|
role: "Designer",
|
||||||
|
roleId: "role_design",
|
||||||
|
headcount: 2,
|
||||||
|
hoursPerDay: 6,
|
||||||
|
startDate: new Date("2026-04-01"),
|
||||||
|
endDate: new Date("2026-04-15"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.id).toBe("demand_draft_1");
|
||||||
|
expect(db.demandRequirement.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
headcount: 2,
|
||||||
|
percentage: 75,
|
||||||
|
status: AllocationStatus.PROPOSED,
|
||||||
|
metadata: {},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(emitAllocationCreated).toHaveBeenCalledWith({
|
||||||
|
id: "demand_draft_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
resourceId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and swallows background side-effect failures during demand creation", async () => {
|
||||||
|
vi.mocked(invalidateDashboardCache).mockRejectedValueOnce(new Error("redis unavailable"));
|
||||||
|
vi.mocked(checkBudgetThresholds).mockRejectedValueOnce(new Error("budget alerts unavailable"));
|
||||||
|
vi.mocked(generateAutoSuggestions).mockRejectedValueOnce(new Error("auto suggestions unavailable"));
|
||||||
|
|
||||||
|
const createdDemandRequirement = {
|
||||||
|
id: "demand_safe_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
startDate: new Date("2026-04-01"),
|
||||||
|
endDate: new Date("2026-04-15"),
|
||||||
|
hoursPerDay: 6,
|
||||||
|
percentage: 75,
|
||||||
|
role: "Designer",
|
||||||
|
roleId: "role_design",
|
||||||
|
headcount: 2,
|
||||||
|
status: AllocationStatus.PROPOSED,
|
||||||
|
metadata: {},
|
||||||
|
createdAt: new Date("2026-03-13"),
|
||||||
|
updatedAt: new Date("2026-03-13"),
|
||||||
|
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
|
||||||
|
roleEntity: { id: "role_design", name: "Designer", color: "#0099FF" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const db = createDemandWorkflowDb({
|
||||||
|
demandRequirement: {
|
||||||
|
create: vi.fn().mockResolvedValue(createdDemandRequirement),
|
||||||
|
},
|
||||||
|
}) as Record<string, unknown>;
|
||||||
|
Object.assign(db, {
|
||||||
|
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const caller = createManagerCaller(db);
|
||||||
|
const result = await caller.createDemand({
|
||||||
|
projectId: "project_1",
|
||||||
|
role: "Designer",
|
||||||
|
roleId: "role_design",
|
||||||
|
headcount: 2,
|
||||||
|
hoursPerDay: 6,
|
||||||
|
startDate: new Date("2026-04-01"),
|
||||||
|
endDate: new Date("2026-04-15"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(result.id).toBe("demand_safe_1");
|
||||||
|
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ effectName: "invalidateDashboardCache" }),
|
||||||
|
"Allocation background side effect failed",
|
||||||
|
);
|
||||||
|
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ effectName: "checkBudgetThresholds", projectId: "project_1" }),
|
||||||
|
"Allocation background side effect failed",
|
||||||
|
);
|
||||||
|
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ effectName: "generateAutoSuggestions", demandRequirementId: "demand_safe_1" }),
|
||||||
|
"Allocation background side effect failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and swallows background webhook failures during allocation creation", async () => {
|
||||||
|
vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook unavailable"));
|
||||||
|
|
||||||
|
const createdAssignment = {
|
||||||
|
id: "assignment_safe_1",
|
||||||
|
demandRequirementId: null,
|
||||||
|
resourceId: "resource_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
startDate: new Date("2026-03-16"),
|
||||||
|
endDate: new Date("2026-03-20"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
percentage: 100,
|
||||||
|
role: "Compositor",
|
||||||
|
roleId: "role_comp",
|
||||||
|
dailyCostCents: 40000,
|
||||||
|
status: AllocationStatus.ACTIVE,
|
||||||
|
metadata: {},
|
||||||
|
createdAt: new Date("2026-03-13"),
|
||||||
|
updatedAt: new Date("2026-03-13"),
|
||||||
|
resource: {
|
||||||
|
id: "resource_1",
|
||||||
|
displayName: "Alice",
|
||||||
|
eid: "E-001",
|
||||||
|
lcrCents: 5000,
|
||||||
|
},
|
||||||
|
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
|
||||||
|
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
|
||||||
|
demandRequirement: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "resource_1",
|
||||||
|
lcrCents: 5000,
|
||||||
|
availability: {
|
||||||
|
monday: 8,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
saturday: 0,
|
||||||
|
sunday: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
allocation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue(createdAssignment),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
auditLog: {
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createManagerCaller(db);
|
||||||
|
const result = await caller.create({
|
||||||
|
resourceId: "resource_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
startDate: new Date("2026-03-16"),
|
||||||
|
endDate: new Date("2026-03-20"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
percentage: 100,
|
||||||
|
role: "Compositor",
|
||||||
|
roleId: "role_comp",
|
||||||
|
status: AllocationStatus.ACTIVE,
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(result.id).toBe("assignment_safe_1");
|
||||||
|
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ effectName: "dispatchWebhooks", event: "allocation.created" }),
|
||||||
|
"Allocation background side effect failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("creates an explicit assignment without dual-writing a legacy allocation row", async () => {
|
it("creates an explicit assignment without dual-writing a legacy allocation row", async () => {
|
||||||
vi.mocked(emitAllocationCreated).mockClear();
|
vi.mocked(emitAllocationCreated).mockClear();
|
||||||
|
|
||||||
@@ -442,6 +769,121 @@ describe("allocation entry resolution router", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("assigns a resource to demand and returns the hydrated demand view", async () => {
|
||||||
|
const demandView = {
|
||||||
|
id: "demand_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
startDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-05-15T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 6,
|
||||||
|
percentage: 75,
|
||||||
|
role: "Designer",
|
||||||
|
roleId: "role_1",
|
||||||
|
headcount: 1,
|
||||||
|
status: AllocationStatus.PROPOSED,
|
||||||
|
metadata: {},
|
||||||
|
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
|
||||||
|
roleEntity: { id: "role_1", name: "Designer", color: "#00AAFF" },
|
||||||
|
assignments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdAssignment = {
|
||||||
|
id: "assignment_1",
|
||||||
|
demandRequirementId: "demand_1",
|
||||||
|
resourceId: "resource_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
startDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-05-15T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 6,
|
||||||
|
percentage: 75,
|
||||||
|
role: "Designer",
|
||||||
|
roleId: "role_1",
|
||||||
|
dailyCostCents: 42000,
|
||||||
|
status: AllocationStatus.PROPOSED,
|
||||||
|
metadata: {},
|
||||||
|
createdAt: new Date("2026-03-13"),
|
||||||
|
updatedAt: new Date("2026-03-13"),
|
||||||
|
resource: {
|
||||||
|
id: "resource_1",
|
||||||
|
displayName: "Alice",
|
||||||
|
eid: "E-001",
|
||||||
|
lcrCents: 7000,
|
||||||
|
},
|
||||||
|
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
|
||||||
|
roleEntity: { id: "role_1", name: "Designer", color: "#00AAFF" },
|
||||||
|
demandRequirement: demandView,
|
||||||
|
};
|
||||||
|
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "resource_1",
|
||||||
|
lcrCents: 7000,
|
||||||
|
availability: {
|
||||||
|
monday: 8,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
saturday: 0,
|
||||||
|
sunday: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
demandRequirement: {
|
||||||
|
findUnique: vi.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "demand_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
startDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-05-15T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 6,
|
||||||
|
role: "Designer",
|
||||||
|
roleId: "role_1",
|
||||||
|
headcount: 1,
|
||||||
|
status: AllocationStatus.PROPOSED,
|
||||||
|
metadata: {},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "demand_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce(demandView),
|
||||||
|
update: vi.fn().mockResolvedValue({
|
||||||
|
id: "demand_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
headcount: 1,
|
||||||
|
status: AllocationStatus.COMPLETED,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue(createdAssignment),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
auditLog: {
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createManagerCaller(db);
|
||||||
|
const result = await caller.assignResourceToDemand({
|
||||||
|
demandRequirementId: "demand_1",
|
||||||
|
resourceId: "resource_1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.assignment.id).toBe("assignment_1");
|
||||||
|
expect(result.demandRequirement.project.shortCode).toBe("PRJ");
|
||||||
|
expect(result.demandRequirement.roleEntity?.name).toBe("Designer");
|
||||||
|
expect(db.assignment.create).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("deletes an explicit demand requirement without routing through allocation.delete", async () => {
|
it("deletes an explicit demand requirement without routing through allocation.delete", async () => {
|
||||||
vi.mocked(emitAllocationDeleted).mockClear();
|
vi.mocked(emitAllocationDeleted).mockClear();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
|
import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
|
||||||
|
import { apiRateLimiter } from "../middleware/rate-limit.js";
|
||||||
import {
|
import {
|
||||||
ASSISTANT_CONFIRMATION_PREFIX,
|
ASSISTANT_CONFIRMATION_PREFIX,
|
||||||
canExecuteMutationTool,
|
canExecuteMutationTool,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
getAvailableAssistantTools,
|
getAvailableAssistantTools,
|
||||||
listPendingAssistantApprovals,
|
listPendingAssistantApprovals,
|
||||||
peekPendingAssistantApproval,
|
peekPendingAssistantApproval,
|
||||||
|
selectAssistantToolsForRequest,
|
||||||
} from "../router/assistant.js";
|
} from "../router/assistant.js";
|
||||||
import { TOOL_DEFINITIONS } from "../router/assistant-tools.js";
|
import { TOOL_DEFINITIONS } from "../router/assistant-tools.js";
|
||||||
|
|
||||||
@@ -19,6 +21,19 @@ function getToolNames(
|
|||||||
return getAvailableAssistantTools(new Set(permissions), userRole).map((tool) => tool.function.name);
|
return getAvailableAssistantTools(new Set(permissions), userRole).map((tool) => tool.function.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSelectedToolNames(
|
||||||
|
permissions: PermissionKeyValue[],
|
||||||
|
messages: Array<{ role: "user" | "assistant"; content: string }>,
|
||||||
|
userRole: SystemRole = SystemRole.ADMIN,
|
||||||
|
pageContext?: string,
|
||||||
|
) {
|
||||||
|
return selectAssistantToolsForRequest(
|
||||||
|
getAvailableAssistantTools(new Set(permissions), userRole),
|
||||||
|
messages,
|
||||||
|
pageContext,
|
||||||
|
).map((tool) => tool.function.name);
|
||||||
|
}
|
||||||
|
|
||||||
const TEST_USER_ID = "assistant-test-user";
|
const TEST_USER_ID = "assistant-test-user";
|
||||||
const TEST_CONVERSATION_ID = "assistant-test-conversation";
|
const TEST_CONVERSATION_ID = "assistant-test-conversation";
|
||||||
|
|
||||||
@@ -174,11 +189,22 @@ function createApprovalStoreMock() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createMissingApprovalTableError() {
|
||||||
|
return Object.assign(
|
||||||
|
new Error("The table `public.assistant_approvals` does not exist in the current database."),
|
||||||
|
{
|
||||||
|
code: "P2021",
|
||||||
|
meta: { table: "public.assistant_approvals" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("assistant router tool gating", () => {
|
describe("assistant router tool gating", () => {
|
||||||
let approvalStore = createApprovalStoreMock();
|
let approvalStore = createApprovalStoreMock();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
approvalStore = createApprovalStoreMock();
|
approvalStore = createApprovalStoreMock();
|
||||||
|
apiRateLimiter.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
|
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
|
||||||
@@ -195,12 +221,115 @@ describe("assistant router tool gating", () => {
|
|||||||
expect(withAdvanced).toContain("get_project_computation_graph");
|
expect(withAdvanced).toContain("get_project_computation_graph");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps user administration tools behind manageUsers", () => {
|
it("keeps user self-service tools available to plain authenticated users", () => {
|
||||||
const withoutManageUsers = getToolNames([]);
|
const userNames = getToolNames([], SystemRole.USER);
|
||||||
const withManageUsers = getToolNames([PermissionKey.MANAGE_USERS]);
|
|
||||||
|
|
||||||
expect(withoutManageUsers).not.toContain("list_users");
|
expect(userNames).toContain("get_current_user");
|
||||||
expect(withManageUsers).toContain("list_users");
|
expect(userNames).toContain("get_dashboard_layout");
|
||||||
|
expect(userNames).toContain("save_dashboard_layout");
|
||||||
|
expect(userNames).toContain("get_favorite_project_ids");
|
||||||
|
expect(userNames).toContain("toggle_favorite_project");
|
||||||
|
expect(userNames).toContain("get_column_preferences");
|
||||||
|
expect(userNames).toContain("set_column_preferences");
|
||||||
|
expect(userNames).toContain("get_mfa_status");
|
||||||
|
expect(userNames).toContain("list_notifications");
|
||||||
|
expect(userNames).toContain("get_unread_notification_count");
|
||||||
|
expect(userNames).toContain("list_tasks");
|
||||||
|
expect(userNames).toContain("get_task_counts");
|
||||||
|
expect(userNames).toContain("create_reminder");
|
||||||
|
expect(userNames).toContain("list_reminders");
|
||||||
|
expect(userNames).toContain("update_reminder");
|
||||||
|
expect(userNames).toContain("delete_reminder");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps admin-only user tools hidden from non-admin roles", () => {
|
||||||
|
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||||
|
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||||
|
const userNames = getToolNames([], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(adminNames).toContain("list_users");
|
||||||
|
expect(adminNames).toContain("get_active_user_count");
|
||||||
|
expect(adminNames).toContain("create_user");
|
||||||
|
expect(adminNames).toContain("set_user_password");
|
||||||
|
expect(adminNames).toContain("update_user_role");
|
||||||
|
expect(adminNames).toContain("update_user_name");
|
||||||
|
expect(adminNames).toContain("link_user_resource");
|
||||||
|
expect(adminNames).toContain("auto_link_users_by_email");
|
||||||
|
expect(adminNames).toContain("set_user_permissions");
|
||||||
|
expect(adminNames).toContain("reset_user_permissions");
|
||||||
|
expect(adminNames).toContain("get_effective_user_permissions");
|
||||||
|
expect(adminNames).toContain("disable_user_totp");
|
||||||
|
|
||||||
|
expect(managerNames).not.toContain("list_users");
|
||||||
|
expect(managerNames).not.toContain("create_user");
|
||||||
|
expect(managerNames).not.toContain("set_user_permissions");
|
||||||
|
expect(managerNames).not.toContain("disable_user_totp");
|
||||||
|
expect(userNames).not.toContain("list_users");
|
||||||
|
expect(userNames).not.toContain("get_active_user_count");
|
||||||
|
expect(userNames).not.toContain("create_user");
|
||||||
|
expect(userNames).not.toContain("set_user_password");
|
||||||
|
expect(userNames).not.toContain("update_user_role");
|
||||||
|
expect(userNames).not.toContain("update_user_name");
|
||||||
|
expect(userNames).not.toContain("link_user_resource");
|
||||||
|
expect(userNames).not.toContain("auto_link_users_by_email");
|
||||||
|
expect(userNames).not.toContain("set_user_permissions");
|
||||||
|
expect(userNames).not.toContain("reset_user_permissions");
|
||||||
|
expect(userNames).not.toContain("get_effective_user_permissions");
|
||||||
|
expect(userNames).not.toContain("disable_user_totp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the OpenAI tool payload to 128 definitions even for fully privileged admins", () => {
|
||||||
|
const allPermissions = Object.values(PermissionKey);
|
||||||
|
const selectedNames = getSelectedToolNames(
|
||||||
|
allPermissions,
|
||||||
|
[{ role: "user", content: "Bitte gib mir einen Überblick über das System." }],
|
||||||
|
SystemRole.ADMIN,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selectedNames.length).toBeLessThanOrEqual(128);
|
||||||
|
expect(selectedNames).toContain("get_current_user");
|
||||||
|
expect(selectedNames).toContain("search_resources");
|
||||||
|
expect(selectedNames).toContain("search_projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritizes holiday and resource tools for German holiday questions", () => {
|
||||||
|
const allPermissions = Object.values(PermissionKey);
|
||||||
|
const selectedNames = getSelectedToolNames(
|
||||||
|
allPermissions,
|
||||||
|
[{ role: "user", content: "Kannst du mir alle Feiertage nennen, die Peter Parker in 2026 zustehen?" }],
|
||||||
|
SystemRole.ADMIN,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selectedNames.length).toBeLessThanOrEqual(128);
|
||||||
|
expect(selectedNames).toContain("search_resources");
|
||||||
|
expect(selectedNames).toContain("get_resource");
|
||||||
|
expect(selectedNames).toContain("get_resource_holidays");
|
||||||
|
expect(selectedNames).toContain("list_holidays_by_region");
|
||||||
|
expect(selectedNames).toContain("list_holiday_calendars");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps assignable users and manager notification lifecycle tools behind manager/admin role", () => {
|
||||||
|
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||||
|
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||||
|
const userNames = getToolNames([], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(managerNames).toContain("list_assignable_users");
|
||||||
|
expect(managerNames).toContain("create_notification");
|
||||||
|
expect(managerNames).toContain("create_task_for_user");
|
||||||
|
expect(managerNames).toContain("assign_task");
|
||||||
|
expect(managerNames).toContain("send_broadcast");
|
||||||
|
expect(managerNames).toContain("list_broadcasts");
|
||||||
|
expect(managerNames).toContain("get_broadcast_detail");
|
||||||
|
expect(adminNames).toContain("list_assignable_users");
|
||||||
|
expect(adminNames).toContain("create_task_for_user");
|
||||||
|
expect(adminNames).toContain("send_broadcast");
|
||||||
|
expect(userNames).not.toContain("list_assignable_users");
|
||||||
|
expect(userNames).not.toContain("create_notification");
|
||||||
|
expect(userNames).not.toContain("create_task_for_user");
|
||||||
|
expect(userNames).not.toContain("assign_task");
|
||||||
|
expect(userNames).not.toContain("send_broadcast");
|
||||||
|
expect(userNames).not.toContain("list_broadcasts");
|
||||||
|
expect(userNames).not.toContain("get_broadcast_detail");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("continues to hide cost-aware advanced tools when viewCosts is missing", () => {
|
it("continues to hide cost-aware advanced tools when viewCosts is missing", () => {
|
||||||
@@ -273,6 +402,66 @@ describe("assistant router tool gating", () => {
|
|||||||
expect(missingAdvancedNames).not.toContain("quick_assign_timeline_resource");
|
expect(missingAdvancedNames).not.toContain("quick_assign_timeline_resource");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps estimate lifecycle mutations behind manager/admin role and their router permissions", () => {
|
||||||
|
const managerProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.MANAGER);
|
||||||
|
const managerAllocationNames = getToolNames([PermissionKey.MANAGE_ALLOCATIONS], SystemRole.MANAGER);
|
||||||
|
const userProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(managerProjectNames).toContain("create_estimate");
|
||||||
|
expect(managerProjectNames).toContain("clone_estimate");
|
||||||
|
expect(managerProjectNames).toContain("update_estimate_draft");
|
||||||
|
expect(managerProjectNames).toContain("submit_estimate_version");
|
||||||
|
expect(managerProjectNames).toContain("approve_estimate_version");
|
||||||
|
expect(managerProjectNames).toContain("create_estimate_revision");
|
||||||
|
expect(managerProjectNames).toContain("create_estimate_export");
|
||||||
|
expect(managerProjectNames).toContain("generate_estimate_weekly_phasing");
|
||||||
|
expect(managerProjectNames).toContain("update_estimate_commercial_terms");
|
||||||
|
expect(managerProjectNames).not.toContain("create_estimate_planning_handoff");
|
||||||
|
expect(managerAllocationNames).toContain("create_estimate_planning_handoff");
|
||||||
|
expect(managerAllocationNames).not.toContain("create_estimate");
|
||||||
|
expect(userProjectNames).not.toContain("create_estimate");
|
||||||
|
expect(userProjectNames).not.toContain("clone_estimate");
|
||||||
|
expect(userProjectNames).not.toContain("update_estimate_draft");
|
||||||
|
expect(userProjectNames).not.toContain("submit_estimate_version");
|
||||||
|
expect(userProjectNames).not.toContain("approve_estimate_version");
|
||||||
|
expect(userProjectNames).not.toContain("create_estimate_revision");
|
||||||
|
expect(userProjectNames).not.toContain("create_estimate_export");
|
||||||
|
expect(userProjectNames).not.toContain("generate_estimate_weekly_phasing");
|
||||||
|
expect(userProjectNames).not.toContain("update_estimate_commercial_terms");
|
||||||
|
expect(userProjectNames).not.toContain("create_estimate_planning_handoff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps estimate read tools aligned to controller/manager/admin visibility and cost requirements", () => {
|
||||||
|
const controllerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.CONTROLLER);
|
||||||
|
const controllerWithoutCosts = getToolNames([], SystemRole.CONTROLLER);
|
||||||
|
const managerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.MANAGER);
|
||||||
|
const managerWithoutCosts = getToolNames([], SystemRole.MANAGER);
|
||||||
|
const userNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(controllerNames).toContain("get_estimate_detail");
|
||||||
|
expect(controllerNames).toContain("list_estimate_versions");
|
||||||
|
expect(controllerNames).toContain("get_estimate_version_snapshot");
|
||||||
|
expect(controllerNames).toContain("get_estimate_weekly_phasing");
|
||||||
|
expect(controllerNames).toContain("get_estimate_commercial_terms");
|
||||||
|
expect(controllerWithoutCosts).not.toContain("get_estimate_detail");
|
||||||
|
expect(controllerWithoutCosts).toContain("list_estimate_versions");
|
||||||
|
expect(controllerWithoutCosts).not.toContain("get_estimate_version_snapshot");
|
||||||
|
expect(controllerWithoutCosts).toContain("get_estimate_weekly_phasing");
|
||||||
|
expect(controllerWithoutCosts).toContain("get_estimate_commercial_terms");
|
||||||
|
expect(managerNames).toContain("get_estimate_detail");
|
||||||
|
expect(managerNames).toContain("list_estimate_versions");
|
||||||
|
expect(managerNames).toContain("get_estimate_version_snapshot");
|
||||||
|
expect(managerNames).toContain("get_estimate_weekly_phasing");
|
||||||
|
expect(managerNames).toContain("get_estimate_commercial_terms");
|
||||||
|
expect(managerWithoutCosts).toContain("list_estimate_versions");
|
||||||
|
expect(managerWithoutCosts).not.toContain("get_estimate_version_snapshot");
|
||||||
|
expect(userNames).not.toContain("get_estimate_detail");
|
||||||
|
expect(userNames).not.toContain("list_estimate_versions");
|
||||||
|
expect(userNames).not.toContain("get_estimate_version_snapshot");
|
||||||
|
expect(userNames).not.toContain("get_estimate_weekly_phasing");
|
||||||
|
expect(userNames).not.toContain("get_estimate_commercial_terms");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps import/dispo parity tools aligned to router roles and permissions", () => {
|
it("keeps import/dispo parity tools aligned to router roles and permissions", () => {
|
||||||
const managerNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.MANAGER);
|
const managerNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.MANAGER);
|
||||||
const controllerNames = getToolNames([], SystemRole.CONTROLLER);
|
const controllerNames = getToolNames([], SystemRole.CONTROLLER);
|
||||||
@@ -284,11 +473,54 @@ describe("assistant router tool gating", () => {
|
|||||||
expect(controllerNames).toContain("export_projects_csv");
|
expect(controllerNames).toContain("export_projects_csv");
|
||||||
expect(adminNames).toContain("list_dispo_import_batches");
|
expect(adminNames).toContain("list_dispo_import_batches");
|
||||||
expect(adminNames).toContain("get_dispo_import_batch");
|
expect(adminNames).toContain("get_dispo_import_batch");
|
||||||
|
expect(adminNames).toContain("stage_dispo_import_batch");
|
||||||
|
expect(adminNames).toContain("validate_dispo_import_batch");
|
||||||
|
expect(adminNames).toContain("cancel_dispo_import_batch");
|
||||||
|
expect(adminNames).toContain("list_dispo_staged_resources");
|
||||||
|
expect(adminNames).toContain("list_dispo_staged_projects");
|
||||||
|
expect(adminNames).toContain("list_dispo_staged_assignments");
|
||||||
|
expect(adminNames).toContain("list_dispo_staged_vacations");
|
||||||
|
expect(adminNames).toContain("list_dispo_staged_unresolved_records");
|
||||||
|
expect(adminNames).toContain("resolve_dispo_staged_record");
|
||||||
|
expect(adminNames).toContain("commit_dispo_import_batch");
|
||||||
expect(userNames).not.toContain("import_csv_data");
|
expect(userNames).not.toContain("import_csv_data");
|
||||||
expect(userNames).not.toContain("export_resources_csv");
|
expect(userNames).not.toContain("export_resources_csv");
|
||||||
expect(userNames).not.toContain("export_projects_csv");
|
expect(userNames).not.toContain("export_projects_csv");
|
||||||
expect(userNames).not.toContain("list_dispo_import_batches");
|
expect(userNames).not.toContain("list_dispo_import_batches");
|
||||||
expect(userNames).not.toContain("get_dispo_import_batch");
|
expect(userNames).not.toContain("get_dispo_import_batch");
|
||||||
|
expect(userNames).not.toContain("stage_dispo_import_batch");
|
||||||
|
expect(userNames).not.toContain("validate_dispo_import_batch");
|
||||||
|
expect(userNames).not.toContain("list_dispo_staged_resources");
|
||||||
|
expect(userNames).not.toContain("commit_dispo_import_batch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps settings and webhook admin tools hidden while preserving protected parity tools", () => {
|
||||||
|
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||||
|
const userNames = getToolNames([], SystemRole.USER);
|
||||||
|
|
||||||
|
expect(adminNames).toContain("get_system_settings");
|
||||||
|
expect(adminNames).toContain("update_system_settings");
|
||||||
|
expect(adminNames).toContain("test_ai_connection");
|
||||||
|
expect(adminNames).toContain("test_smtp_connection");
|
||||||
|
expect(adminNames).toContain("test_gemini_connection");
|
||||||
|
expect(adminNames).toContain("update_system_role_config");
|
||||||
|
expect(adminNames).toContain("list_webhooks");
|
||||||
|
expect(adminNames).toContain("get_webhook");
|
||||||
|
expect(adminNames).toContain("create_webhook");
|
||||||
|
expect(adminNames).toContain("update_webhook");
|
||||||
|
expect(adminNames).toContain("delete_webhook");
|
||||||
|
expect(adminNames).toContain("test_webhook");
|
||||||
|
expect(adminNames).toContain("get_ai_configured");
|
||||||
|
expect(adminNames).toContain("list_system_role_configs");
|
||||||
|
|
||||||
|
expect(userNames).not.toContain("get_system_settings");
|
||||||
|
expect(userNames).not.toContain("update_system_settings");
|
||||||
|
expect(userNames).not.toContain("test_ai_connection");
|
||||||
|
expect(userNames).not.toContain("update_system_role_config");
|
||||||
|
expect(userNames).not.toContain("list_webhooks");
|
||||||
|
expect(userNames).not.toContain("create_webhook");
|
||||||
|
expect(userNames).toContain("get_ai_configured");
|
||||||
|
expect(userNames).toContain("list_system_role_configs");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps holiday calendar mutation tools admin-only while leaving read tools available", () => {
|
it("keeps holiday calendar mutation tools admin-only while leaving read tools available", () => {
|
||||||
@@ -506,6 +738,59 @@ describe("assistant router tool gating", () => {
|
|||||||
expect(approvalSummaries).not.toContain("Foreign");
|
expect(approvalSummaries).not.toContain("Foreign");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("degrades approval reads gracefully when approval storage is missing", async () => {
|
||||||
|
const missingTableError = createMissingApprovalTableError();
|
||||||
|
const missingStore = {
|
||||||
|
assistantApproval: {
|
||||||
|
findFirst: vi.fn(async () => {
|
||||||
|
throw missingTableError;
|
||||||
|
}),
|
||||||
|
findMany: vi.fn(async () => {
|
||||||
|
throw missingTableError;
|
||||||
|
}),
|
||||||
|
create: vi.fn(async () => {
|
||||||
|
throw missingTableError;
|
||||||
|
}),
|
||||||
|
updateMany: vi.fn(async () => {
|
||||||
|
throw missingTableError;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(listPendingAssistantApprovals(missingStore, TEST_USER_ID)).resolves.toEqual([]);
|
||||||
|
await expect(peekPendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
|
||||||
|
await expect(consumePendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
|
||||||
|
await expect(clearPendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an explicit error when approval storage is missing for mutation confirmation", async () => {
|
||||||
|
const missingTableError = createMissingApprovalTableError();
|
||||||
|
const missingStore = {
|
||||||
|
assistantApproval: {
|
||||||
|
findFirst: vi.fn(async () => {
|
||||||
|
throw missingTableError;
|
||||||
|
}),
|
||||||
|
findMany: vi.fn(async () => {
|
||||||
|
throw missingTableError;
|
||||||
|
}),
|
||||||
|
create: vi.fn(async () => {
|
||||||
|
throw missingTableError;
|
||||||
|
}),
|
||||||
|
updateMany: vi.fn(async () => {
|
||||||
|
throw missingTableError;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(createPendingAssistantApproval(
|
||||||
|
missingStore,
|
||||||
|
TEST_USER_ID,
|
||||||
|
TEST_CONVERSATION_ID,
|
||||||
|
"create_project",
|
||||||
|
JSON.stringify({ name: "Apollo" }),
|
||||||
|
)).rejects.toThrow("Assistant approval storage is unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not require confirmation for read-only assistant tools", () => {
|
it("does not require confirmation for read-only assistant tools", () => {
|
||||||
expect(canExecuteMutationTool([
|
expect(canExecuteMutationTool([
|
||||||
{ role: "user", content: "Zeig mir meine Notifications" },
|
{ role: "user", content: "Zeig mir meine Notifications" },
|
||||||
@@ -518,12 +803,31 @@ describe("assistant router tool gating", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(toolDescriptions.get("create_estimate")).toContain("manageProjects");
|
expect(toolDescriptions.get("create_estimate")).toContain("manageProjects");
|
||||||
expect(toolDescriptions.get("set_entitlement")).toContain("manageVacations");
|
expect(toolDescriptions.get("create_estimate_planning_handoff")).toContain("manageAllocations");
|
||||||
expect(toolDescriptions.get("create_org_unit")).toContain("manageResources");
|
expect(toolDescriptions.get("get_estimate_detail")).toContain("Controller/manager/admin");
|
||||||
expect(toolDescriptions.get("update_org_unit")).toContain("manageResources");
|
expect(toolDescriptions.get("list_estimate_versions")).toContain("Controller/manager/admin");
|
||||||
expect(toolDescriptions.get("list_users")).toContain("manageUsers");
|
expect(toolDescriptions.get("get_estimate_version_snapshot")).toContain("viewCosts");
|
||||||
expect(toolDescriptions.get("create_task_for_user")).toContain("manageProjects");
|
expect(toolDescriptions.get("get_estimate_weekly_phasing")).toContain("Controller/manager/admin");
|
||||||
expect(toolDescriptions.get("send_broadcast")).toContain("manageProjects");
|
expect(toolDescriptions.get("get_estimate_commercial_terms")).toContain("Controller/manager/admin");
|
||||||
|
expect(toolDescriptions.get("create_vacation")).toContain("authenticated user");
|
||||||
|
expect(toolDescriptions.get("approve_vacation")).toContain("Manager or admin role");
|
||||||
|
expect(toolDescriptions.get("reject_vacation")).toContain("Manager or admin role");
|
||||||
|
expect(toolDescriptions.get("cancel_vacation")).toContain("Users can cancel their own requests");
|
||||||
|
expect(toolDescriptions.get("set_entitlement")).toContain("Manager or admin role");
|
||||||
|
expect(toolDescriptions.get("create_role")).toContain("manageRoles");
|
||||||
|
expect(toolDescriptions.get("update_role")).toContain("manageRoles");
|
||||||
|
expect(toolDescriptions.get("delete_role")).toContain("manageRoles");
|
||||||
|
expect(toolDescriptions.get("create_org_unit")).toContain("Admin role");
|
||||||
|
expect(toolDescriptions.get("update_org_unit")).toContain("Admin role");
|
||||||
|
expect(toolDescriptions.get("list_users")).toContain("Admin role");
|
||||||
|
expect(toolDescriptions.get("list_assignable_users")).toContain("Manager or admin role");
|
||||||
|
expect(toolDescriptions.get("get_current_user")).toContain("authenticated user's own profile");
|
||||||
|
expect(toolDescriptions.get("create_notification")).toContain("Manager or admin role");
|
||||||
|
expect(toolDescriptions.get("create_task_for_user")).toContain("Manager or admin role");
|
||||||
|
expect(toolDescriptions.get("send_broadcast")).toContain("Manager or admin role");
|
||||||
|
expect(toolDescriptions.get("get_broadcast_detail")).toContain("Manager or admin role");
|
||||||
|
expect(toolDescriptions.get("create_client")).toContain("manager or admin role");
|
||||||
|
expect(toolDescriptions.get("update_client")).toContain("manager or admin role");
|
||||||
expect(toolDescriptions.get("create_holiday_calendar")).toContain("Admin role");
|
expect(toolDescriptions.get("create_holiday_calendar")).toContain("Admin role");
|
||||||
expect(toolDescriptions.get("create_holiday_calendar_entry")).toContain("Admin role");
|
expect(toolDescriptions.get("create_holiday_calendar_entry")).toContain("Admin role");
|
||||||
expect(toolDescriptions.get("query_change_history")).toContain("Controller/manager/admin");
|
expect(toolDescriptions.get("query_change_history")).toContain("Controller/manager/admin");
|
||||||
@@ -534,6 +838,17 @@ describe("assistant router tool gating", () => {
|
|||||||
expect(toolDescriptions.get("import_csv_data")).toContain("manager/admin");
|
expect(toolDescriptions.get("import_csv_data")).toContain("manager/admin");
|
||||||
expect(toolDescriptions.get("list_dispo_import_batches")).toContain("Admin role");
|
expect(toolDescriptions.get("list_dispo_import_batches")).toContain("Admin role");
|
||||||
expect(toolDescriptions.get("get_dispo_import_batch")).toContain("Admin role");
|
expect(toolDescriptions.get("get_dispo_import_batch")).toContain("Admin role");
|
||||||
|
expect(toolDescriptions.get("stage_dispo_import_batch")).toContain("Admin role");
|
||||||
|
expect(toolDescriptions.get("validate_dispo_import_batch")).toContain("Admin role");
|
||||||
|
expect(toolDescriptions.get("commit_dispo_import_batch")).toContain("Always confirm first");
|
||||||
|
expect(toolDescriptions.get("get_system_settings")).toContain("Admin role");
|
||||||
|
expect(toolDescriptions.get("update_system_settings")).toContain("Always confirm first");
|
||||||
|
expect(toolDescriptions.get("get_ai_configured")).toContain("authenticated user");
|
||||||
|
expect(toolDescriptions.get("list_system_role_configs")).toContain("authenticated user");
|
||||||
|
expect(toolDescriptions.get("update_system_role_config")).toContain("Admin role");
|
||||||
|
expect(toolDescriptions.get("list_webhooks")).toContain("Secrets are masked");
|
||||||
|
expect(toolDescriptions.get("create_webhook")).toContain("Always confirm first");
|
||||||
|
expect(toolDescriptions.get("test_webhook")).toContain("Always confirm first");
|
||||||
expect(toolDescriptions.get("list_audit_log_entries")).toContain("Controller/manager/admin");
|
expect(toolDescriptions.get("list_audit_log_entries")).toContain("Controller/manager/admin");
|
||||||
expect(toolDescriptions.get("get_audit_log_entry")).toContain("Controller/manager/admin");
|
expect(toolDescriptions.get("get_audit_log_entry")).toContain("Controller/manager/admin");
|
||||||
expect(toolDescriptions.get("get_audit_log_timeline")).toContain("Controller/manager/admin");
|
expect(toolDescriptions.get("get_audit_log_timeline")).toContain("Controller/manager/admin");
|
||||||
@@ -548,4 +863,72 @@ describe("assistant router tool gating", () => {
|
|||||||
expect(toolDescriptions.get("batch_quick_assign_timeline_resources")).toContain("manageAllocations");
|
expect(toolDescriptions.get("batch_quick_assign_timeline_resources")).toContain("manageAllocations");
|
||||||
expect(toolDescriptions.get("batch_shift_timeline_allocations")).toContain("manager/admin");
|
expect(toolDescriptions.get("batch_shift_timeline_allocations")).toContain("manager/admin");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("aligns assistant tool visibility with router role and permission rules", () => {
|
||||||
|
const managerWithRolePermission = getToolNames(
|
||||||
|
[PermissionKey.MANAGE_ROLES],
|
||||||
|
SystemRole.MANAGER,
|
||||||
|
);
|
||||||
|
const managerWithoutRolePermission = getToolNames([], SystemRole.MANAGER);
|
||||||
|
|
||||||
|
expect(managerWithRolePermission).toContain("create_role");
|
||||||
|
expect(managerWithRolePermission).toContain("update_role");
|
||||||
|
expect(managerWithRolePermission).toContain("delete_role");
|
||||||
|
expect(managerWithRolePermission).toContain("create_client");
|
||||||
|
expect(managerWithRolePermission).toContain("update_client");
|
||||||
|
expect(managerWithRolePermission).not.toContain("create_org_unit");
|
||||||
|
expect(managerWithRolePermission).not.toContain("update_org_unit");
|
||||||
|
|
||||||
|
expect(managerWithoutRolePermission).not.toContain("create_role");
|
||||||
|
expect(managerWithoutRolePermission).not.toContain("update_role");
|
||||||
|
expect(managerWithoutRolePermission).not.toContain("delete_role");
|
||||||
|
expect(managerWithoutRolePermission).toContain("create_client");
|
||||||
|
expect(managerWithoutRolePermission).toContain("update_client");
|
||||||
|
|
||||||
|
const adminWithRolePermission = getToolNames(
|
||||||
|
[PermissionKey.MANAGE_ROLES],
|
||||||
|
SystemRole.ADMIN,
|
||||||
|
);
|
||||||
|
expect(adminWithRolePermission).toContain("create_org_unit");
|
||||||
|
expect(adminWithRolePermission).toContain("update_org_unit");
|
||||||
|
|
||||||
|
const standardUserTools = getToolNames([], SystemRole.USER);
|
||||||
|
expect(standardUserTools).toContain("get_vacation_balance");
|
||||||
|
expect(standardUserTools).toContain("create_vacation");
|
||||||
|
expect(standardUserTools).toContain("cancel_vacation");
|
||||||
|
expect(standardUserTools).not.toContain("approve_vacation");
|
||||||
|
expect(standardUserTools).not.toContain("reject_vacation");
|
||||||
|
expect(standardUserTools).not.toContain("set_entitlement");
|
||||||
|
|
||||||
|
const managerVacationTools = getToolNames([], SystemRole.MANAGER);
|
||||||
|
expect(managerVacationTools).toContain("approve_vacation");
|
||||||
|
expect(managerVacationTools).toContain("reject_vacation");
|
||||||
|
expect(managerVacationTools).toContain("set_entitlement");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps estimate tool parameter enums aligned with the current estimate schema", () => {
|
||||||
|
const definitionByName = new Map(
|
||||||
|
TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool.function]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const createEstimateStatus = (
|
||||||
|
definitionByName.get("create_estimate")?.parameters as {
|
||||||
|
properties?: Record<string, { enum?: unknown[] }>;
|
||||||
|
}
|
||||||
|
)?.properties?.status?.enum;
|
||||||
|
const updateEstimateStatus = (
|
||||||
|
definitionByName.get("update_estimate_draft")?.parameters as {
|
||||||
|
properties?: Record<string, { enum?: unknown[] }>;
|
||||||
|
}
|
||||||
|
)?.properties?.status?.enum;
|
||||||
|
const estimateExportFormats = (
|
||||||
|
definitionByName.get("create_estimate_export")?.parameters as {
|
||||||
|
properties?: Record<string, { enum?: unknown[] }>;
|
||||||
|
}
|
||||||
|
)?.properties?.format?.enum;
|
||||||
|
|
||||||
|
expect(createEstimateStatus).toEqual(["DRAFT", "IN_REVIEW", "APPROVED", "ARCHIVED"]);
|
||||||
|
expect(updateEstimateStatus).toEqual(["DRAFT", "IN_REVIEW", "APPROVED", "ARCHIVED"]);
|
||||||
|
expect(estimateExportFormats).toEqual(["XLSX", "CSV", "JSON", "SAP", "MMP"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -126,6 +126,15 @@ describe("assistant advanced tools and scoping", () => {
|
|||||||
findUnique: vi
|
findUnique: vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce(null)
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "project_lari",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
shortCode: "LARI",
|
||||||
|
status: "ACTIVE",
|
||||||
|
responsiblePerson: "Larissa Joos",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
id: "project_lari",
|
id: "project_lari",
|
||||||
name: "Gelddruckmaschine",
|
name: "Gelddruckmaschine",
|
||||||
@@ -228,6 +237,101 @@ describe("assistant advanced tools and scoping", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns project shift preview details from the canonical timeline router", async () => {
|
||||||
|
const projectFindUnique = vi.fn().mockImplementation((args: { where?: { id?: string; shortCode?: string }; select?: Record<string, unknown> }) => {
|
||||||
|
if (args.where?.id === "GDM") {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
if (args.where?.shortCode === "GDM") {
|
||||||
|
return Promise.resolve({
|
||||||
|
id: "project_shift",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
shortCode: "GDM",
|
||||||
|
status: "ACTIVE",
|
||||||
|
responsiblePerson: "Larissa",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (args.select && "budgetCents" in args.select) {
|
||||||
|
return Promise.resolve({
|
||||||
|
id: "project_shift",
|
||||||
|
budgetCents: 100000,
|
||||||
|
winProbability: 100,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
id: "project_shift",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
shortCode: "GDM",
|
||||||
|
status: "ACTIVE",
|
||||||
|
responsiblePerson: "Larissa",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = createToolContext(
|
||||||
|
{
|
||||||
|
project: {
|
||||||
|
findUnique: projectFindUnique,
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
demandRequirement: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await executeTool(
|
||||||
|
"preview_project_shift",
|
||||||
|
JSON.stringify({
|
||||||
|
projectIdentifier: "GDM",
|
||||||
|
newStartDate: "2026-01-19",
|
||||||
|
newEndDate: "2026-01-30",
|
||||||
|
}),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(JSON.parse(result.content)).toEqual({
|
||||||
|
project: {
|
||||||
|
id: "project_shift",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
shortCode: "GDM",
|
||||||
|
status: "ACTIVE",
|
||||||
|
responsiblePerson: "Larissa",
|
||||||
|
startDate: "2026-01-05",
|
||||||
|
endDate: "2026-01-16",
|
||||||
|
},
|
||||||
|
requestedShift: {
|
||||||
|
newStartDate: "2026-01-19",
|
||||||
|
newEndDate: "2026-01-30",
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
valid: true,
|
||||||
|
errors: [],
|
||||||
|
warnings: [],
|
||||||
|
conflictDetails: [],
|
||||||
|
costImpact: {
|
||||||
|
currentTotalCents: 0,
|
||||||
|
newTotalCents: 0,
|
||||||
|
deltaCents: 0,
|
||||||
|
budgetCents: 100000,
|
||||||
|
budgetUtilizationBefore: 0,
|
||||||
|
budgetUtilizationAfter: 0,
|
||||||
|
wouldExceedBudget: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("returns timeline entries view with demand, assignment, and holiday overlay context", async () => {
|
it("returns timeline entries view with demand, assignment, and holiday overlay context", async () => {
|
||||||
const ctx = createToolContext(
|
const ctx = createToolContext(
|
||||||
{
|
{
|
||||||
@@ -1248,9 +1352,94 @@ describe("assistant advanced tools and scoping", () => {
|
|||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scopes assistant notification listing to the current user", async () => {
|
it("returns a filtered project computation graph through the assistant", async () => {
|
||||||
|
const projectRecord = {
|
||||||
|
id: "project_1",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
shortCode: "GDM",
|
||||||
|
budgetCents: 100_000,
|
||||||
|
winProbability: 75,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-02-28T00:00:00.000Z"),
|
||||||
|
status: "ACTIVE",
|
||||||
|
responsiblePerson: "Larissa Joos",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = createToolContext(
|
||||||
|
{
|
||||||
|
project: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(projectRecord),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
findUniqueOrThrow: vi.fn().mockResolvedValue(projectRecord),
|
||||||
|
},
|
||||||
|
estimate: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
status: "CONFIRMED",
|
||||||
|
dailyCostCents: 4_000,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-30T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 4,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
effortRule: {
|
||||||
|
count: vi.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
experienceMultiplierRule: {
|
||||||
|
count: vi.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await executeTool(
|
||||||
|
"get_project_computation_graph",
|
||||||
|
JSON.stringify({
|
||||||
|
projectId: "project_1",
|
||||||
|
domain: "BUDGET",
|
||||||
|
includeLinks: true,
|
||||||
|
}),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result.content) as {
|
||||||
|
project: { id: string; shortCode: string; name: string };
|
||||||
|
requestedDomain: string;
|
||||||
|
totalNodeCount: number;
|
||||||
|
selectedNodeCount: number;
|
||||||
|
selectedLinkCount: number;
|
||||||
|
nodes: Array<{ id: string; domain: string }>;
|
||||||
|
links: Array<{ source: string; target: string }>;
|
||||||
|
meta: { projectName: string; projectCode: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsed.project).toEqual({
|
||||||
|
id: "project_1",
|
||||||
|
shortCode: "GDM",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
});
|
||||||
|
expect(parsed.meta).toEqual({
|
||||||
|
projectName: "Gelddruckmaschine",
|
||||||
|
projectCode: "GDM",
|
||||||
|
});
|
||||||
|
expect(parsed.requestedDomain).toBe("BUDGET");
|
||||||
|
expect(parsed.totalNodeCount).toBeGreaterThan(parsed.selectedNodeCount);
|
||||||
|
expect(parsed.selectedNodeCount).toBeGreaterThan(0);
|
||||||
|
expect(parsed.selectedLinkCount).toBeGreaterThan(0);
|
||||||
|
expect(parsed.nodes.every((node) => node.domain === "BUDGET")).toBe(true);
|
||||||
|
expect(parsed.links.length).toBe(parsed.selectedLinkCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes assistant notification listing to the current user through the router path", async () => {
|
||||||
const findMany = vi.fn().mockResolvedValue([]);
|
const findMany = vi.fn().mockResolvedValue([]);
|
||||||
const ctx = createToolContext({
|
const ctx = createToolContext({
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: "user_1" }),
|
||||||
|
},
|
||||||
notification: {
|
notification: {
|
||||||
findMany,
|
findMany,
|
||||||
},
|
},
|
||||||
@@ -1268,40 +1457,44 @@ describe("assistant advanced tools and scoping", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects marking notifications that do not belong to the current user", async () => {
|
it("scopes mark_notification_read mutations to the current user through the router path", async () => {
|
||||||
const update = vi.fn();
|
const update = vi.fn();
|
||||||
const ctx = createToolContext({
|
const ctx = createToolContext({
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: "user_1" }),
|
||||||
|
},
|
||||||
notification: {
|
notification: {
|
||||||
findUnique: vi.fn().mockResolvedValue({ id: "notif_1", userId: "someone_else" }),
|
|
||||||
update,
|
update,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await executeTool(
|
await executeTool(
|
||||||
"mark_notification_read",
|
"mark_notification_read",
|
||||||
JSON.stringify({ notificationId: "notif_1" }),
|
JSON.stringify({ notificationId: "notif_1" }),
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(JSON.parse(result.content)).toEqual({
|
expect(update).toHaveBeenCalledWith({
|
||||||
error: "Access denied: this notification does not belong to you",
|
where: { id: "notif_1", userId: "user_1" },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
readAt: expect.any(Date),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
expect(update).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires manageUsers before listing users through the assistant", async () => {
|
it("requires admin role before listing users through the assistant", async () => {
|
||||||
const findMany = vi.fn();
|
const findMany = vi.fn();
|
||||||
const ctx = createToolContext({
|
const ctx = createToolContext({
|
||||||
user: {
|
user: {
|
||||||
findMany,
|
findMany,
|
||||||
},
|
},
|
||||||
});
|
}, [], SystemRole.MANAGER);
|
||||||
|
|
||||||
const result = await executeTool("list_users", JSON.stringify({ limit: 10 }), ctx);
|
const result = await executeTool("list_users", JSON.stringify({ limit: 10 }), ctx);
|
||||||
|
|
||||||
expect(JSON.parse(result.content)).toEqual(
|
expect(JSON.parse(result.content)).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
error: expect.stringContaining(PermissionKey.MANAGE_USERS),
|
error: expect.stringContaining("Admin role required"),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(findMany).not.toHaveBeenCalled();
|
expect(findMany).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -12,30 +12,50 @@ function createToolContext(
|
|||||||
userId: "user_1",
|
userId: "user_1",
|
||||||
userRole,
|
userRole,
|
||||||
permissions: new Set(permissions) as ToolContext["permissions"],
|
permissions: new Set(permissions) as ToolContext["permissions"],
|
||||||
|
session: {
|
||||||
|
user: { email: "assistant@example.com", name: "Assistant User", image: null },
|
||||||
|
expires: "2026-03-29T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
dbUser: {
|
||||||
|
id: "user_1",
|
||||||
|
systemRole: userRole,
|
||||||
|
permissionOverrides: null,
|
||||||
|
},
|
||||||
|
roleDefaults: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("assistant country tools", () => {
|
describe("assistant country tools", () => {
|
||||||
it("lists countries with schedule rules, active state, and metro cities", async () => {
|
it("lists countries with schedule rules, active state, and metro cities", async () => {
|
||||||
|
const findMany = vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "country_de",
|
||||||
|
code: "DE",
|
||||||
|
name: "Deutschland",
|
||||||
|
dailyWorkingHours: 8,
|
||||||
|
scheduleRules: null,
|
||||||
|
isActive: true,
|
||||||
|
metroCities: [{ id: "city_muc", name: "Munich" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "country_es",
|
||||||
|
code: "ES",
|
||||||
|
name: "Spain",
|
||||||
|
dailyWorkingHours: 8,
|
||||||
|
scheduleRules: null,
|
||||||
|
isActive: true,
|
||||||
|
metroCities: [{ id: "city_mad", name: "Madrid" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
const ctx = createToolContext({
|
const ctx = createToolContext({
|
||||||
country: {
|
country: {
|
||||||
findMany: vi.fn().mockResolvedValue([
|
findMany,
|
||||||
{
|
|
||||||
id: "country_de",
|
|
||||||
code: "DE",
|
|
||||||
name: "Deutschland",
|
|
||||||
dailyWorkingHours: 8,
|
|
||||||
scheduleRules: null,
|
|
||||||
isActive: true,
|
|
||||||
metroCities: [{ id: "city_muc", name: "Munich" }],
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await executeTool(
|
const result = await executeTool(
|
||||||
"list_countries",
|
"list_countries",
|
||||||
JSON.stringify({ includeInactive: true }),
|
JSON.stringify({ search: "deu" }),
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -49,6 +69,11 @@ describe("assistant country tools", () => {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
expect(findMany).toHaveBeenCalledWith({
|
||||||
|
where: { isActive: true },
|
||||||
|
include: { metroCities: { orderBy: { name: "asc" } } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
expect(parsed.count).toBe(1);
|
expect(parsed.count).toBe(1);
|
||||||
expect(parsed.countries[0]).toMatchObject({
|
expect(parsed.countries[0]).toMatchObject({
|
||||||
code: "DE",
|
code: "DE",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ vi.mock("@capakraken/application", async (importOriginal) => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
|
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
|
||||||
|
listAssignmentBookings: vi.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,6 +26,16 @@ function createToolContext(
|
|||||||
userId: "user_1",
|
userId: "user_1",
|
||||||
userRole,
|
userRole,
|
||||||
permissions: new Set(permissions) as ToolContext["permissions"],
|
permissions: new Set(permissions) as ToolContext["permissions"],
|
||||||
|
session: {
|
||||||
|
user: { email: "assistant@example.com", name: "Assistant User", image: null },
|
||||||
|
expires: "2026-03-29T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
dbUser: {
|
||||||
|
id: "user_1",
|
||||||
|
systemRole: userRole,
|
||||||
|
permissionOverrides: null,
|
||||||
|
},
|
||||||
|
roleDefaults: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +89,7 @@ describe("assistant holiday tools", () => {
|
|||||||
findUnique: vi
|
findUnique: vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce(null)
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce({ id: "res_1", eid: "bruce.banner", displayName: "Bruce Banner" })
|
||||||
.mockResolvedValueOnce({ id: "res_1", eid: "bruce.banner", displayName: "Bruce Banner", federalState: "BY", countryId: "country_de", metroCityId: "city_augsburg", country: { code: "DE", name: "Deutschland" }, metroCity: { name: "Augsburg" } }),
|
.mockResolvedValueOnce({ id: "res_1", eid: "bruce.banner", displayName: "Bruce Banner", federalState: "BY", countryId: "country_de", metroCityId: "city_augsburg", country: { code: "DE", name: "Deutschland" }, metroCity: { name: "Augsburg" } }),
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -172,10 +184,10 @@ describe("assistant holiday tools", () => {
|
|||||||
it("previews resolved holiday calendars for a scope and shows the source calendar", async () => {
|
it("previews resolved holiday calendars for a scope and shows the source calendar", async () => {
|
||||||
const ctx = createToolContext({
|
const ctx = createToolContext({
|
||||||
country: {
|
country: {
|
||||||
findUnique: vi.fn().mockResolvedValue({ code: "DE" }),
|
findUnique: vi.fn().mockResolvedValue({ id: "country_de", code: "DE", name: "Deutschland" }),
|
||||||
},
|
},
|
||||||
metroCity: {
|
metroCity: {
|
||||||
findUnique: vi.fn().mockResolvedValue({ name: "Augsburg" }),
|
findUnique: vi.fn().mockResolvedValue({ id: "city_augsburg", name: "Augsburg", countryId: "country_de" }),
|
||||||
},
|
},
|
||||||
holidayCalendar: {
|
holidayCalendar: {
|
||||||
findMany: vi.fn().mockResolvedValue([
|
findMany: vi.fn().mockResolvedValue([
|
||||||
@@ -229,6 +241,14 @@ describe("assistant holiday tools", () => {
|
|||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
expect(ctx.db.country.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: "country_de" },
|
||||||
|
select: { id: true, code: true, name: true },
|
||||||
|
});
|
||||||
|
expect(ctx.db.metroCity.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: "city_augsburg" },
|
||||||
|
select: { id: true, name: true, countryId: true },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a holiday calendar through the assistant for admin users", async () => {
|
it("creates a holiday calendar through the assistant for admin users", async () => {
|
||||||
@@ -301,36 +321,58 @@ describe("assistant holiday tools", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("calculates chargeability with regional holidays excluded from booked and available hours", async () => {
|
it("calculates chargeability with regional holidays excluded from booked and available hours", async () => {
|
||||||
|
const resourceRecord = {
|
||||||
|
id: "res_1",
|
||||||
|
displayName: "Bruce Banner",
|
||||||
|
eid: "bruce.banner",
|
||||||
|
fte: 1,
|
||||||
|
lcrCents: 5000,
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { id: "country_de", code: "DE", name: "Deutschland", dailyWorkingHours: 8, scheduleRules: null },
|
||||||
|
metroCity: null,
|
||||||
|
managementLevelGroup: null,
|
||||||
|
};
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
findUnique: vi
|
findUnique: vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce(resourceRecord),
|
||||||
id: "res_1",
|
findUniqueOrThrow: vi.fn().mockResolvedValue(resourceRecord),
|
||||||
displayName: "Bruce Banner",
|
|
||||||
eid: "bruce.banner",
|
|
||||||
fte: 1,
|
|
||||||
chargeabilityTarget: 80,
|
|
||||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
|
||||||
countryId: "country_de",
|
|
||||||
federalState: "BY",
|
|
||||||
metroCityId: null,
|
|
||||||
country: { code: "DE", dailyWorkingHours: 8 },
|
|
||||||
metroCity: null,
|
|
||||||
}),
|
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
},
|
},
|
||||||
assignment: {
|
assignment: {
|
||||||
findMany: vi.fn().mockResolvedValue([
|
findMany: vi.fn().mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
id: "assign_1",
|
||||||
hoursPerDay: 8,
|
hoursPerDay: 8,
|
||||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
dailyCostCents: 40000,
|
||||||
status: "CONFIRMED",
|
status: "CONFIRMED",
|
||||||
project: { name: "Gamma", shortCode: "GAM" },
|
project: {
|
||||||
|
id: "project_gamma",
|
||||||
|
name: "Gamma",
|
||||||
|
shortCode: "GAM",
|
||||||
|
budgetCents: null,
|
||||||
|
winProbability: 100,
|
||||||
|
utilizationCategory: { code: "Chg" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
holidayCalendar: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
calculationRule: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const ctx = createToolContext(db);
|
const ctx = createToolContext(db);
|
||||||
|
|
||||||
@@ -356,12 +398,12 @@ describe("assistant holiday tools", () => {
|
|||||||
|
|
||||||
expect(parsed.bookedHours).toBe(8);
|
expect(parsed.bookedHours).toBe(8);
|
||||||
expect(parsed.allocations).toEqual([expect.objectContaining({ hours: 8 })]);
|
expect(parsed.allocations).toEqual([expect.objectContaining({ hours: 8 })]);
|
||||||
expect(parsed.baseWorkingDays).toBe(23);
|
expect(parsed.baseWorkingDays).toBe(22);
|
||||||
expect(parsed.baseAvailableHours).toBe(184);
|
expect(parsed.baseAvailableHours).toBe(176);
|
||||||
expect(parsed.availableHours).toBe(168);
|
expect(parsed.availableHours).toBe(160);
|
||||||
expect(parsed.workingDays).toBe(21);
|
expect(parsed.workingDays).toBe(20);
|
||||||
expect(parsed.targetHours).toBe(134.4);
|
expect(parsed.targetHours).toBe(128);
|
||||||
expect(parsed.unassignedHours).toBe(160);
|
expect(parsed.unassignedHours).toBe(152);
|
||||||
expect(parsed.locationContext.federalState).toBe("BY");
|
expect(parsed.locationContext.federalState).toBe("BY");
|
||||||
expect(parsed.holidaySummary).toEqual(
|
expect(parsed.holidaySummary).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -409,7 +451,6 @@ describe("assistant holiday tools", () => {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(getDashboardBudgetForecast).toHaveBeenCalled();
|
|
||||||
expect(parsed.forecasts).toEqual([
|
expect(parsed.forecasts).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
projectName: "Gelddruckmaschine",
|
projectName: "Gelddruckmaschine",
|
||||||
@@ -425,21 +466,23 @@ describe("assistant holiday tools", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("checks resource availability with regional holidays excluded from capacity", async () => {
|
it("checks resource availability with regional holidays excluded from capacity", async () => {
|
||||||
|
const resourceRecord = {
|
||||||
|
id: "res_1",
|
||||||
|
displayName: "Bruce Banner",
|
||||||
|
eid: "bruce.banner",
|
||||||
|
fte: 1,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE", dailyWorkingHours: 8 },
|
||||||
|
metroCity: null,
|
||||||
|
};
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
findUnique: vi
|
findUnique: vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValue(resourceRecord),
|
||||||
id: "res_1",
|
|
||||||
displayName: "Bruce Banner",
|
|
||||||
fte: 1,
|
|
||||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
|
||||||
countryId: "country_de",
|
|
||||||
federalState: "BY",
|
|
||||||
metroCityId: null,
|
|
||||||
country: { code: "DE" },
|
|
||||||
metroCity: null,
|
|
||||||
}),
|
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
},
|
},
|
||||||
assignment: {
|
assignment: {
|
||||||
@@ -581,13 +624,17 @@ describe("assistant holiday tools", () => {
|
|||||||
it("prefers resources without a local holiday in staffing suggestions", async () => {
|
it("prefers resources without a local holiday in staffing suggestions", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
project: {
|
project: {
|
||||||
findFirst: vi.fn().mockResolvedValue({
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
id: "project_1",
|
id: "project_1",
|
||||||
name: "Holiday Project",
|
name: "Holiday Project",
|
||||||
shortCode: "HP",
|
shortCode: "HP",
|
||||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
}),
|
}),
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
resource: {
|
resource: {
|
||||||
findMany: vi.fn().mockResolvedValue([
|
findMany: vi.fn().mockResolvedValue([
|
||||||
@@ -597,15 +644,17 @@ describe("assistant holiday tools", () => {
|
|||||||
eid: "BY-1",
|
eid: "BY-1",
|
||||||
fte: 1,
|
fte: 1,
|
||||||
lcrCents: 10000,
|
lcrCents: 10000,
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
valueScore: 10,
|
||||||
|
skills: [],
|
||||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
countryId: "country_de",
|
countryId: "country_de",
|
||||||
federalState: "BY",
|
federalState: "BY",
|
||||||
metroCityId: null,
|
metroCityId: null,
|
||||||
country: { code: "DE" },
|
country: { code: "DE", name: "Deutschland" },
|
||||||
metroCity: null,
|
metroCity: null,
|
||||||
areaRole: { name: "Consultant" },
|
areaRole: { name: "Consultant" },
|
||||||
chapter: "CGI",
|
chapter: "CGI",
|
||||||
assignments: [],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "res_hh",
|
id: "res_hh",
|
||||||
@@ -613,21 +662,20 @@ describe("assistant holiday tools", () => {
|
|||||||
eid: "HH-1",
|
eid: "HH-1",
|
||||||
fte: 1,
|
fte: 1,
|
||||||
lcrCents: 10000,
|
lcrCents: 10000,
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
valueScore: 10,
|
||||||
|
skills: [],
|
||||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
countryId: "country_de",
|
countryId: "country_de",
|
||||||
federalState: "HH",
|
federalState: "HH",
|
||||||
metroCityId: null,
|
metroCityId: null,
|
||||||
country: { code: "DE" },
|
country: { code: "DE", name: "Deutschland" },
|
||||||
metroCity: null,
|
metroCity: null,
|
||||||
areaRole: { name: "Consultant" },
|
areaRole: { name: "Consultant" },
|
||||||
chapter: "CGI",
|
chapter: "CGI",
|
||||||
assignments: [],
|
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
vacation: {
|
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
const ctx = createToolContext(db);
|
const ctx = createToolContext(db);
|
||||||
|
|
||||||
@@ -645,6 +693,16 @@ describe("assistant holiday tools", () => {
|
|||||||
expect(parsed.suggestions[0]).toEqual(
|
expect(parsed.suggestions[0]).toEqual(
|
||||||
expect.objectContaining({ name: "Hamburg", availableHours: 8 }),
|
expect.objectContaining({ name: "Hamburg", availableHours: 8 }),
|
||||||
);
|
);
|
||||||
|
expect(db.project.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: "project_1" },
|
||||||
|
select: expect.objectContaining({
|
||||||
|
id: true,
|
||||||
|
shortCode: true,
|
||||||
|
name: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("finds capacity with local holidays respected", async () => {
|
it("finds capacity with local holidays respected", async () => {
|
||||||
@@ -714,6 +772,12 @@ describe("assistant holiday tools", () => {
|
|||||||
id: "project_1",
|
id: "project_1",
|
||||||
name: "Holiday Project",
|
name: "Holiday Project",
|
||||||
shortCode: "HP",
|
shortCode: "HP",
|
||||||
|
status: "ACTIVE",
|
||||||
|
responsiblePerson: null,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "project_1",
|
||||||
|
name: "Holiday Project",
|
||||||
shoringThreshold: 55,
|
shoringThreshold: 55,
|
||||||
onshoreCountryCode: "DE",
|
onshoreCountryCode: "DE",
|
||||||
}),
|
}),
|
||||||
@@ -726,6 +790,7 @@ describe("assistant holiday tools", () => {
|
|||||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
resource: {
|
resource: {
|
||||||
|
id: "res_by",
|
||||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
countryId: "country_de",
|
countryId: "country_de",
|
||||||
federalState: "BY",
|
federalState: "BY",
|
||||||
@@ -740,6 +805,7 @@ describe("assistant holiday tools", () => {
|
|||||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
resource: {
|
resource: {
|
||||||
|
id: "res_in",
|
||||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
countryId: "country_in",
|
countryId: "country_in",
|
||||||
federalState: null,
|
federalState: null,
|
||||||
@@ -765,4 +831,121 @@ describe("assistant holiday tools", () => {
|
|||||||
expect(result.content).toContain("0% onshore (DE), 100% offshore");
|
expect(result.content).toContain("0% onshore (DE), 100% offshore");
|
||||||
expect(result.content).toContain("IN 100% (1 people)");
|
expect(result.content).toContain("IN 100% (1 people)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes pending vacation approvals through the vacation router path", async () => {
|
||||||
|
const db = {
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "vac_1",
|
||||||
|
type: "ANNUAL",
|
||||||
|
startDate: new Date("2026-07-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-07-03T00:00:00.000Z"),
|
||||||
|
isHalfDay: false,
|
||||||
|
resource: { displayName: "Bruce Banner", eid: "BB-1", chapter: "CGI" },
|
||||||
|
requestedBy: { id: "user_2", name: "Manager", email: "manager@example.com" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ctx = createToolContext(db, [], SystemRole.MANAGER);
|
||||||
|
|
||||||
|
const result = await executeTool(
|
||||||
|
"get_pending_vacation_approvals",
|
||||||
|
JSON.stringify({ limit: 10 }),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(db.vacation.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { status: "PENDING" },
|
||||||
|
include: {
|
||||||
|
resource: { select: expect.any(Object) },
|
||||||
|
requestedBy: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
orderBy: { startDate: "asc" },
|
||||||
|
});
|
||||||
|
expect(JSON.parse(result.content)).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "vac_1",
|
||||||
|
resource: "Bruce Banner",
|
||||||
|
eid: "BB-1",
|
||||||
|
chapter: "CGI",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes team vacation overlap through the vacation router path", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({
|
||||||
|
id: "res_1",
|
||||||
|
displayName: "Bruce Banner",
|
||||||
|
eid: "BB-1",
|
||||||
|
chapter: "CGI",
|
||||||
|
lcrCents: 0,
|
||||||
|
isActive: true,
|
||||||
|
countryId: null,
|
||||||
|
federalState: null,
|
||||||
|
metroCityId: null,
|
||||||
|
areaRole: null,
|
||||||
|
country: null,
|
||||||
|
metroCity: null,
|
||||||
|
}),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
type: "ANNUAL",
|
||||||
|
status: "APPROVED",
|
||||||
|
startDate: new Date("2026-08-10T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-08-12T00:00:00.000Z"),
|
||||||
|
resource: { displayName: "Clark Kent" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ctx = createToolContext(db);
|
||||||
|
|
||||||
|
const result = await executeTool(
|
||||||
|
"get_team_vacation_overlap",
|
||||||
|
JSON.stringify({
|
||||||
|
resourceId: "res_1",
|
||||||
|
startDate: "2026-08-10",
|
||||||
|
endDate: "2026-08-12",
|
||||||
|
}),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(db.vacation.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
resource: { chapter: "CGI" },
|
||||||
|
resourceId: { not: "res_1" },
|
||||||
|
status: { in: ["APPROVED", "PENDING"] },
|
||||||
|
startDate: { lte: new Date("2026-08-12T00:00:00.000Z") },
|
||||||
|
endDate: { gte: new Date("2026-08-10T00:00:00.000Z") },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
resource: { select: expect.any(Object) },
|
||||||
|
},
|
||||||
|
orderBy: { startDate: "asc" },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
expect(JSON.parse(result.content)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
resource: "Bruce Banner",
|
||||||
|
chapter: "CGI",
|
||||||
|
overlapCount: 1,
|
||||||
|
overlappingVacations: [
|
||||||
|
expect.objectContaining({
|
||||||
|
resource: "Clark Kent",
|
||||||
|
status: "APPROVED",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
|||||||
|
import { SystemRole } from "@capakraken/shared";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { auditLogRouter } from "../router/audit-log.js";
|
||||||
|
import { createCallerFactory } from "../trpc.js";
|
||||||
|
|
||||||
|
const createCaller = createCallerFactory(auditLogRouter);
|
||||||
|
|
||||||
|
function createControllerCaller(db: Record<string, unknown>) {
|
||||||
|
return createCaller({
|
||||||
|
session: {
|
||||||
|
user: { email: "controller@example.com", name: "Controller", image: null },
|
||||||
|
expires: "2099-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
db: db as never,
|
||||||
|
dbUser: {
|
||||||
|
id: "user_1",
|
||||||
|
systemRole: SystemRole.CONTROLLER,
|
||||||
|
permissionOverrides: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("audit log router detail endpoints", () => {
|
||||||
|
it("returns formatted list detail rows with ISO timestamps", async () => {
|
||||||
|
const db = {
|
||||||
|
auditLog: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "audit_1",
|
||||||
|
entityType: "project",
|
||||||
|
entityId: "project_1",
|
||||||
|
entityName: "Apollo",
|
||||||
|
action: "updated",
|
||||||
|
userId: "user_1",
|
||||||
|
source: "ui",
|
||||||
|
summary: "Changed budget",
|
||||||
|
createdAt: new Date("2026-03-29T12:00:00.000Z"),
|
||||||
|
user: {
|
||||||
|
id: "user_1",
|
||||||
|
name: "Controller User",
|
||||||
|
email: "controller@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.listDetail({ limit: 10 });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "audit_1",
|
||||||
|
entityType: "project",
|
||||||
|
entityId: "project_1",
|
||||||
|
entityName: "Apollo",
|
||||||
|
action: "updated",
|
||||||
|
userId: "user_1",
|
||||||
|
source: "ui",
|
||||||
|
summary: "Changed budget",
|
||||||
|
createdAt: "2026-03-29T12:00:00.000Z",
|
||||||
|
user: {
|
||||||
|
id: "user_1",
|
||||||
|
name: "Controller User",
|
||||||
|
email: "controller@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns formatted timeline detail grouped by date", async () => {
|
||||||
|
const db = {
|
||||||
|
auditLog: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "audit_2",
|
||||||
|
entityType: "resource",
|
||||||
|
entityId: "resource_1",
|
||||||
|
entityName: "Peter Parker",
|
||||||
|
action: "updated",
|
||||||
|
userId: "user_2",
|
||||||
|
source: "assistant",
|
||||||
|
summary: "Updated location",
|
||||||
|
changes: { city: ["Hamburg", "Munich"] },
|
||||||
|
createdAt: new Date("2026-03-30T08:00:00.000Z"),
|
||||||
|
user: {
|
||||||
|
id: "user_2",
|
||||||
|
name: "Audit User",
|
||||||
|
email: "audit@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.getTimelineDetail({ limit: 10 });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
"2026-03-30": [
|
||||||
|
{
|
||||||
|
id: "audit_2",
|
||||||
|
entityType: "resource",
|
||||||
|
entityId: "resource_1",
|
||||||
|
entityName: "Peter Parker",
|
||||||
|
action: "updated",
|
||||||
|
userId: "user_2",
|
||||||
|
source: "assistant",
|
||||||
|
summary: "Updated location",
|
||||||
|
createdAt: "2026-03-30T08:00:00.000Z",
|
||||||
|
changes: { city: ["Hamburg", "Munich"] },
|
||||||
|
user: {
|
||||||
|
id: "user_2",
|
||||||
|
name: "Audit User",
|
||||||
|
email: "audit@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -425,4 +425,111 @@ describe("chargeability report router", () => {
|
|||||||
expect(month).toBeDefined();
|
expect(month).toBeDefined();
|
||||||
expect(month?.chg).toBeCloseTo(16 / 144, 5);
|
expect(month?.chg).toBeCloseTo(16 / 144, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns a filtered detailed report with rounded percentages", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "resource_1",
|
||||||
|
eid: "E-001",
|
||||||
|
displayName: "Alice",
|
||||||
|
fte: 1,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_es",
|
||||||
|
federalState: null,
|
||||||
|
metroCityId: "city_1",
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
country: {
|
||||||
|
id: "country_es",
|
||||||
|
code: "ES",
|
||||||
|
dailyWorkingHours: 8,
|
||||||
|
scheduleRules: null,
|
||||||
|
},
|
||||||
|
orgUnit: { id: "org_1", name: "CGI" },
|
||||||
|
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
|
||||||
|
managementLevel: { id: "level_1", name: "L7" },
|
||||||
|
metroCity: { id: "city_1", name: "Barcelona" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{ id: "project_confirmed", utilizationCategory: { code: "Chg" } },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "assignment_confirmed",
|
||||||
|
projectId: "project_confirmed",
|
||||||
|
resourceId: "resource_1",
|
||||||
|
startDate: new Date("2026-03-02T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-03-06T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 4,
|
||||||
|
dailyCostCents: 0,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
project: {
|
||||||
|
id: "project_confirmed",
|
||||||
|
name: "Confirmed Project",
|
||||||
|
shortCode: "CP",
|
||||||
|
status: "ACTIVE",
|
||||||
|
orderType: "CLIENT",
|
||||||
|
dynamicFields: null,
|
||||||
|
},
|
||||||
|
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.getDetail({
|
||||||
|
startMonth: "2026-03",
|
||||||
|
endMonth: "2026-03",
|
||||||
|
resourceQuery: "ali",
|
||||||
|
resourceLimit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.filters).toEqual({
|
||||||
|
startMonth: "2026-03",
|
||||||
|
endMonth: "2026-03",
|
||||||
|
orgUnitId: null,
|
||||||
|
managementLevelGroupId: null,
|
||||||
|
countryId: null,
|
||||||
|
includeProposed: false,
|
||||||
|
resourceQuery: "ali",
|
||||||
|
});
|
||||||
|
expect(result.groupTotals).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
monthKey: "2026-03",
|
||||||
|
totalFte: 1,
|
||||||
|
chargeabilityPct: expect.any(Number),
|
||||||
|
targetPct: 80,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(result.resourceCount).toBe(1);
|
||||||
|
expect(result.returnedResourceCount).toBe(1);
|
||||||
|
expect(result.truncated).toBe(false);
|
||||||
|
expect(result.resources).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
displayName: "Alice",
|
||||||
|
targetPct: 80,
|
||||||
|
country: "ES",
|
||||||
|
city: "Barcelona",
|
||||||
|
managementLevelGroup: "Senior",
|
||||||
|
managementLevel: "L7",
|
||||||
|
months: [
|
||||||
|
expect.objectContaining({
|
||||||
|
monthKey: "2026-03",
|
||||||
|
sah: expect.any(Number),
|
||||||
|
chargeabilityPct: expect.any(Number),
|
||||||
|
gapPct: expect.any(Number),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,32 @@ type ResourceGraphMeta = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ResourceGraphDetail = {
|
||||||
|
resource: { id: string; eid: string; displayName: string };
|
||||||
|
availableDomains: string[];
|
||||||
|
requestedDomain: string | null;
|
||||||
|
totalNodeCount: number;
|
||||||
|
totalLinkCount: number;
|
||||||
|
selectedNodeCount: number;
|
||||||
|
selectedLinkCount: number;
|
||||||
|
nodes: Array<{ id: string; domain: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectGraphDetail = {
|
||||||
|
project: { id: string; shortCode: string; name: string };
|
||||||
|
availableDomains: string[];
|
||||||
|
requestedDomain: string | null;
|
||||||
|
totalNodeCount: number;
|
||||||
|
selectedNodeCount: number;
|
||||||
|
selectedLinkCount: number;
|
||||||
|
nodes: Array<{ id: string; domain: string }>;
|
||||||
|
links?: Array<{ source: string; target: string }>;
|
||||||
|
meta: {
|
||||||
|
projectName: string;
|
||||||
|
projectCode: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
function createControllerCaller(db: Record<string, unknown>) {
|
function createControllerCaller(db: Record<string, unknown>) {
|
||||||
return createCaller({
|
return createCaller({
|
||||||
session: {
|
session: {
|
||||||
@@ -99,6 +125,47 @@ function buildResource(overrides: Record<string, unknown> = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createProjectDb(projectFindImpl: ReturnType<typeof vi.fn>) {
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
findUniqueOrThrow: projectFindImpl,
|
||||||
|
},
|
||||||
|
estimate: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
status: "CONFIRMED",
|
||||||
|
dailyCostCents: 4_000,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-30T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 4,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
effortRule: {
|
||||||
|
count: vi.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
experienceMultiplierRule: {
|
||||||
|
count: vi.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProject(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "project_1",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
shortCode: "GDM",
|
||||||
|
budgetCents: 100_000,
|
||||||
|
winProbability: 75,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-02-28T00:00:00.000Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("computation graph router", () => {
|
describe("computation graph router", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -192,4 +259,60 @@ describe("computation graph router", () => {
|
|||||||
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
|
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns a filtered resource detail graph with canonical selection metadata", async () => {
|
||||||
|
const db = createDb(vi.fn().mockResolvedValue(buildResource({
|
||||||
|
id: "resource_augsburg",
|
||||||
|
metroCityId: "city_augsburg",
|
||||||
|
metroCity: { id: "city_augsburg", name: "Augsburg" },
|
||||||
|
})));
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.getResourceDataDetail({
|
||||||
|
resourceId: "resource_augsburg",
|
||||||
|
month: "2026-08",
|
||||||
|
domain: "SAH",
|
||||||
|
}) as ResourceGraphDetail;
|
||||||
|
|
||||||
|
expect(result.resource).toEqual({
|
||||||
|
id: "resource_augsburg",
|
||||||
|
eid: "bruce.banner",
|
||||||
|
displayName: "Bruce Banner",
|
||||||
|
});
|
||||||
|
expect(result.availableDomains).toEqual(expect.arrayContaining(["INPUT", "SAH", "ALLOCATION", "CHARGEABILITY"]));
|
||||||
|
expect(result.requestedDomain).toBe("SAH");
|
||||||
|
expect(result.totalNodeCount).toBeGreaterThan(result.selectedNodeCount);
|
||||||
|
expect(result.totalLinkCount).toBeGreaterThan(0);
|
||||||
|
expect(result.selectedNodeCount).toBeGreaterThan(0);
|
||||||
|
expect(result.selectedLinkCount).toBe(0);
|
||||||
|
expect(result.nodes.every((node) => node.domain === "SAH")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a filtered project detail graph with canonical project identity", async () => {
|
||||||
|
const db = createProjectDb(vi.fn().mockResolvedValue(buildProject()));
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.getProjectDataDetail({
|
||||||
|
projectId: "project_1",
|
||||||
|
domain: "BUDGET",
|
||||||
|
includeLinks: true,
|
||||||
|
}) as ProjectGraphDetail;
|
||||||
|
|
||||||
|
expect(result.project).toEqual({
|
||||||
|
id: "project_1",
|
||||||
|
shortCode: "GDM",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
});
|
||||||
|
expect(result.meta).toEqual({
|
||||||
|
projectName: "Gelddruckmaschine",
|
||||||
|
projectCode: "GDM",
|
||||||
|
});
|
||||||
|
expect(result.availableDomains).toEqual(expect.arrayContaining(["INPUT", "BUDGET"]));
|
||||||
|
expect(result.requestedDomain).toBe("BUDGET");
|
||||||
|
expect(result.totalNodeCount).toBeGreaterThan(result.selectedNodeCount);
|
||||||
|
expect(result.selectedNodeCount).toBeGreaterThan(0);
|
||||||
|
expect(result.selectedLinkCount).toBeGreaterThan(0);
|
||||||
|
expect(result.nodes.every((node) => node.domain === "BUDGET")).toBe(true);
|
||||||
|
expect(result.links?.length).toBe(result.selectedLinkCount);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ vi.mock("@capakraken/application", async (importOriginal) => {
|
|||||||
getDashboardTopValueResources: vi.fn(),
|
getDashboardTopValueResources: vi.fn(),
|
||||||
getDashboardChargeabilityOverview: vi.fn(),
|
getDashboardChargeabilityOverview: vi.fn(),
|
||||||
getDashboardBudgetForecast: vi.fn(),
|
getDashboardBudgetForecast: vi.fn(),
|
||||||
|
getDashboardProjectHealth: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
getDashboardTopValueResources,
|
getDashboardTopValueResources,
|
||||||
getDashboardChargeabilityOverview,
|
getDashboardChargeabilityOverview,
|
||||||
getDashboardBudgetForecast,
|
getDashboardBudgetForecast,
|
||||||
|
getDashboardProjectHealth,
|
||||||
} from "@capakraken/application";
|
} from "@capakraken/application";
|
||||||
import { dashboardRouter } from "../router/dashboard.js";
|
import { dashboardRouter } from "../router/dashboard.js";
|
||||||
import { createCallerFactory } from "../trpc.js";
|
import { createCallerFactory } from "../trpc.js";
|
||||||
@@ -97,7 +99,7 @@ describe("dashboard router", () => {
|
|||||||
|
|
||||||
vi.mocked(getDashboardOverview).mockResolvedValue(overview);
|
vi.mocked(getDashboardOverview).mockResolvedValue(overview);
|
||||||
|
|
||||||
const caller = createProtectedCaller({});
|
const caller = createControllerCaller({});
|
||||||
const result = await caller.getOverview();
|
const result = await caller.getOverview();
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
@@ -115,6 +117,72 @@ describe("dashboard router", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getStatisticsDetail", () => {
|
||||||
|
it("returns assistant-friendly statistics derived from the canonical dashboard overview", async () => {
|
||||||
|
vi.mocked(getDashboardOverview).mockResolvedValue({
|
||||||
|
totalResources: 12,
|
||||||
|
activeResources: 10,
|
||||||
|
inactiveResources: 2,
|
||||||
|
totalProjects: 7,
|
||||||
|
activeProjects: 4,
|
||||||
|
inactiveProjects: 3,
|
||||||
|
totalAllocations: 21,
|
||||||
|
activeAllocations: 18,
|
||||||
|
cancelledAllocations: 3,
|
||||||
|
approvedVacations: 6,
|
||||||
|
totalEstimates: 9,
|
||||||
|
budgetSummary: {
|
||||||
|
totalBudgetCents: 1_234_56,
|
||||||
|
totalCostCents: 654_32,
|
||||||
|
avgUtilizationPercent: 53,
|
||||||
|
},
|
||||||
|
budgetBasis: {
|
||||||
|
remainingBudgetCents: 58_024,
|
||||||
|
budgetedProjects: 5,
|
||||||
|
unbudgetedProjects: 2,
|
||||||
|
trackedAssignmentCount: 18,
|
||||||
|
windowStart: null,
|
||||||
|
windowEnd: null,
|
||||||
|
},
|
||||||
|
projectsByStatus: [
|
||||||
|
{ status: "ACTIVE", count: 4 },
|
||||||
|
{ status: "DRAFT", count: 2 },
|
||||||
|
{ status: "DONE", count: 1 },
|
||||||
|
],
|
||||||
|
chapterUtilization: [
|
||||||
|
{ chapter: "CGI", resourceCount: 5, avgChargeabilityTarget: 78 },
|
||||||
|
{ chapter: "Compositing", resourceCount: 3, avgChargeabilityTarget: 74 },
|
||||||
|
{ chapter: "Unassigned", resourceCount: 2, avgChargeabilityTarget: 0 },
|
||||||
|
],
|
||||||
|
recentActivity: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const caller = createControllerCaller({});
|
||||||
|
const result = await caller.getStatisticsDetail();
|
||||||
|
|
||||||
|
expect(getDashboardOverview).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toEqual({
|
||||||
|
activeResources: 10,
|
||||||
|
totalProjects: 7,
|
||||||
|
activeProjects: 4,
|
||||||
|
totalAllocations: 21,
|
||||||
|
approvedVacations: 6,
|
||||||
|
totalEstimates: 9,
|
||||||
|
totalBudget: "1.234,56 EUR",
|
||||||
|
projectsByStatus: {
|
||||||
|
ACTIVE: 4,
|
||||||
|
DRAFT: 2,
|
||||||
|
DONE: 1,
|
||||||
|
},
|
||||||
|
topChapters: [
|
||||||
|
{ chapter: "CGI", count: 5 },
|
||||||
|
{ chapter: "Compositing", count: 3 },
|
||||||
|
{ chapter: "Unassigned", count: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── getPeakTimes ─────────────────────────────────────────────────────────
|
// ─── getPeakTimes ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("getPeakTimes", () => {
|
describe("getPeakTimes", () => {
|
||||||
@@ -126,7 +194,7 @@ describe("dashboard router", () => {
|
|||||||
|
|
||||||
vi.mocked(getDashboardPeakTimes).mockResolvedValue(peakData);
|
vi.mocked(getDashboardPeakTimes).mockResolvedValue(peakData);
|
||||||
|
|
||||||
const caller = createProtectedCaller({});
|
const caller = createControllerCaller({});
|
||||||
const result = await caller.getPeakTimes({
|
const result = await caller.getPeakTimes({
|
||||||
startDate: "2026-03-01T00:00:00.000Z",
|
startDate: "2026-03-01T00:00:00.000Z",
|
||||||
endDate: "2026-06-30T00:00:00.000Z",
|
endDate: "2026-06-30T00:00:00.000Z",
|
||||||
@@ -148,7 +216,7 @@ describe("dashboard router", () => {
|
|||||||
it("passes week granularity to application layer", async () => {
|
it("passes week granularity to application layer", async () => {
|
||||||
vi.mocked(getDashboardPeakTimes).mockResolvedValue([]);
|
vi.mocked(getDashboardPeakTimes).mockResolvedValue([]);
|
||||||
|
|
||||||
const caller = createProtectedCaller({});
|
const caller = createControllerCaller({});
|
||||||
await caller.getPeakTimes({
|
await caller.getPeakTimes({
|
||||||
startDate: "2026-03-01T00:00:00.000Z",
|
startDate: "2026-03-01T00:00:00.000Z",
|
||||||
endDate: "2026-03-31T00:00:00.000Z",
|
endDate: "2026-03-31T00:00:00.000Z",
|
||||||
@@ -177,7 +245,7 @@ describe("dashboard router", () => {
|
|||||||
|
|
||||||
vi.mocked(getDashboardDemand).mockResolvedValue(demandData);
|
vi.mocked(getDashboardDemand).mockResolvedValue(demandData);
|
||||||
|
|
||||||
const caller = createProtectedCaller({});
|
const caller = createControllerCaller({});
|
||||||
const result = await caller.getDemand({
|
const result = await caller.getDemand({
|
||||||
startDate: "2026-01-01T00:00:00.000Z",
|
startDate: "2026-01-01T00:00:00.000Z",
|
||||||
endDate: "2026-12-31T00:00:00.000Z",
|
endDate: "2026-12-31T00:00:00.000Z",
|
||||||
@@ -194,7 +262,7 @@ describe("dashboard router", () => {
|
|||||||
it("supports grouping by chapter", async () => {
|
it("supports grouping by chapter", async () => {
|
||||||
vi.mocked(getDashboardDemand).mockResolvedValue([]);
|
vi.mocked(getDashboardDemand).mockResolvedValue([]);
|
||||||
|
|
||||||
const caller = createProtectedCaller({});
|
const caller = createControllerCaller({});
|
||||||
await caller.getDemand({
|
await caller.getDemand({
|
||||||
startDate: "2026-06-01T00:00:00.000Z",
|
startDate: "2026-06-01T00:00:00.000Z",
|
||||||
endDate: "2026-06-30T00:00:00.000Z",
|
endDate: "2026-06-30T00:00:00.000Z",
|
||||||
@@ -208,6 +276,73 @@ describe("dashboard router", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getProjectHealthDetail", () => {
|
||||||
|
it("returns assistant-friendly health detail derived from the canonical dashboard read model", async () => {
|
||||||
|
vi.mocked(getDashboardProjectHealth).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project_critical",
|
||||||
|
projectName: "Critical Project",
|
||||||
|
shortCode: "CRIT",
|
||||||
|
status: "ACTIVE",
|
||||||
|
clientId: "client_1",
|
||||||
|
clientName: "Acme",
|
||||||
|
budgetHealth: 25,
|
||||||
|
staffingHealth: 40,
|
||||||
|
timelineHealth: 30,
|
||||||
|
compositeScore: 35,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "project_healthy",
|
||||||
|
projectName: "Healthy Project",
|
||||||
|
shortCode: "HLTH",
|
||||||
|
status: "ACTIVE",
|
||||||
|
clientId: "client_1",
|
||||||
|
clientName: "Acme",
|
||||||
|
budgetHealth: 90,
|
||||||
|
staffingHealth: 92,
|
||||||
|
timelineHealth: 86,
|
||||||
|
compositeScore: 89,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const caller = createControllerCaller({});
|
||||||
|
const result = await caller.getProjectHealthDetail();
|
||||||
|
|
||||||
|
expect(getDashboardProjectHealth).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toEqual({
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
projectId: "project_critical",
|
||||||
|
projectName: "Critical Project",
|
||||||
|
shortCode: "CRIT",
|
||||||
|
status: "ACTIVE",
|
||||||
|
overall: 35,
|
||||||
|
budget: 25,
|
||||||
|
staffing: 40,
|
||||||
|
timeline: 30,
|
||||||
|
rating: "critical",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectId: "project_healthy",
|
||||||
|
projectName: "Healthy Project",
|
||||||
|
shortCode: "HLTH",
|
||||||
|
status: "ACTIVE",
|
||||||
|
overall: 89,
|
||||||
|
budget: 90,
|
||||||
|
staffing: 92,
|
||||||
|
timeline: 86,
|
||||||
|
rating: "healthy",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
summary: {
|
||||||
|
healthy: 1,
|
||||||
|
atRisk: 0,
|
||||||
|
critical: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── getTopValueResources ─────────────────────────────────────────────────
|
// ─── getTopValueResources ─────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("getTopValueResources", () => {
|
describe("getTopValueResources", () => {
|
||||||
@@ -219,7 +354,7 @@ describe("dashboard router", () => {
|
|||||||
|
|
||||||
vi.mocked(getDashboardTopValueResources).mockResolvedValue(resources);
|
vi.mocked(getDashboardTopValueResources).mockResolvedValue(resources);
|
||||||
|
|
||||||
const caller = createProtectedCaller({});
|
const caller = createControllerCaller({});
|
||||||
const result = await caller.getTopValueResources({ limit: 10 });
|
const result = await caller.getTopValueResources({ limit: 10 });
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
@@ -232,7 +367,7 @@ describe("dashboard router", () => {
|
|||||||
it("respects custom limit", async () => {
|
it("respects custom limit", async () => {
|
||||||
vi.mocked(getDashboardTopValueResources).mockResolvedValue([]);
|
vi.mocked(getDashboardTopValueResources).mockResolvedValue([]);
|
||||||
|
|
||||||
const caller = createProtectedCaller({});
|
const caller = createControllerCaller({});
|
||||||
await caller.getTopValueResources({ limit: 5 });
|
await caller.getTopValueResources({ limit: 5 });
|
||||||
|
|
||||||
expect(getDashboardTopValueResources).toHaveBeenCalledWith(
|
expect(getDashboardTopValueResources).toHaveBeenCalledWith(
|
||||||
@@ -334,7 +469,7 @@ describe("dashboard router", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const caller = createProtectedCaller({});
|
const caller = createControllerCaller({});
|
||||||
const result = await caller.getBudgetForecast();
|
const result = await caller.getBudgetForecast();
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
@@ -351,5 +486,177 @@ describe("dashboard router", () => {
|
|||||||
});
|
});
|
||||||
expect(getDashboardBudgetForecast).toHaveBeenCalledTimes(1);
|
expect(getDashboardBudgetForecast).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns assistant-friendly budget forecast detail derived from the canonical dashboard read model", async () => {
|
||||||
|
vi.mocked(getDashboardBudgetForecast).mockResolvedValue([
|
||||||
|
{
|
||||||
|
projectId: "project_1",
|
||||||
|
projectName: "Alpha",
|
||||||
|
shortCode: "ALPHA",
|
||||||
|
clientId: "client_1",
|
||||||
|
clientName: "Client One",
|
||||||
|
budgetCents: 100_000,
|
||||||
|
spentCents: 40_000,
|
||||||
|
remainingCents: 60_000,
|
||||||
|
burnRate: 10_000,
|
||||||
|
estimatedExhaustionDate: "2026-06-30",
|
||||||
|
pctUsed: 40,
|
||||||
|
activeAssignmentCount: 2,
|
||||||
|
calendarLocations: [
|
||||||
|
{
|
||||||
|
countryCode: "DE",
|
||||||
|
countryName: "Germany",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityName: "Munich",
|
||||||
|
activeAssignmentCount: 2,
|
||||||
|
burnRateCents: 10_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const caller = createControllerCaller({});
|
||||||
|
const result = await caller.getBudgetForecastDetail();
|
||||||
|
|
||||||
|
expect(getDashboardBudgetForecast).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toEqual({
|
||||||
|
forecasts: [
|
||||||
|
expect.objectContaining({
|
||||||
|
projectId: "project_1",
|
||||||
|
projectName: "Alpha",
|
||||||
|
shortCode: "ALPHA",
|
||||||
|
budgetCents: 100_000,
|
||||||
|
spentCents: 40_000,
|
||||||
|
remainingCents: 60_000,
|
||||||
|
projectedCents: 100_000,
|
||||||
|
burnRateCents: 10_000,
|
||||||
|
utilization: "40%",
|
||||||
|
burnStatus: "on_track",
|
||||||
|
calendarLocations: [
|
||||||
|
expect.objectContaining({
|
||||||
|
countryCode: "DE",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityName: "Munich",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getDetail", () => {
|
||||||
|
it("returns the canonical assistant dashboard detail payload", async () => {
|
||||||
|
vi.mocked(getDashboardOverview).mockResolvedValue({
|
||||||
|
budgetBasis: {
|
||||||
|
windowStart: "2026-01-01T00:00:00.000Z",
|
||||||
|
windowEnd: "2026-06-30T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
chapterUtilization: [
|
||||||
|
{
|
||||||
|
chapter: "Delivery",
|
||||||
|
resourceCount: 4,
|
||||||
|
avgChargeabilityTarget: 78,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.mocked(getDashboardPeakTimes).mockResolvedValue([
|
||||||
|
{
|
||||||
|
period: "2026-03",
|
||||||
|
totalHours: 320.4,
|
||||||
|
capacityHours: 400.2,
|
||||||
|
utilizationPct: 80,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(getDashboardTopValueResources).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "res_1",
|
||||||
|
eid: "pparker",
|
||||||
|
displayName: "Peter Parker",
|
||||||
|
chapter: "Delivery",
|
||||||
|
valueScore: 91,
|
||||||
|
lcrCents: 9_500,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(getDashboardDemand).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project_1",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
shortCode: "GDM",
|
||||||
|
allocatedHours: 120,
|
||||||
|
requiredFTEs: 4,
|
||||||
|
resourceCount: 2,
|
||||||
|
derivation: {
|
||||||
|
calendarLocations: [
|
||||||
|
{
|
||||||
|
countryCode: "DE",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityName: "Augsburg",
|
||||||
|
resourceCount: 2,
|
||||||
|
allocatedHours: 120,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const caller = createControllerCaller({});
|
||||||
|
const result = await caller.getDetail({ section: "all" });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
peakTimes: [
|
||||||
|
{
|
||||||
|
month: "2026-03",
|
||||||
|
totalHours: 320.4,
|
||||||
|
totalHoursPerDay: 320.4,
|
||||||
|
capacityHours: 400.2,
|
||||||
|
utilizationPct: 80,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
topResources: [
|
||||||
|
{
|
||||||
|
name: "Peter Parker",
|
||||||
|
eid: "pparker",
|
||||||
|
chapter: "Delivery",
|
||||||
|
lcr: "95,00 EUR",
|
||||||
|
valueScore: 91,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
demandPipeline: [
|
||||||
|
{
|
||||||
|
project: "Gelddruckmaschine (GDM)",
|
||||||
|
needed: 2,
|
||||||
|
requiredFTEs: 4,
|
||||||
|
allocatedResources: 2,
|
||||||
|
allocatedHours: 120,
|
||||||
|
calendarLocations: [
|
||||||
|
{
|
||||||
|
countryCode: "DE",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityName: "Augsburg",
|
||||||
|
resourceCount: 2,
|
||||||
|
allocatedHours: 120,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chargeabilityByChapter: [
|
||||||
|
{
|
||||||
|
chapter: "Delivery",
|
||||||
|
headcount: 4,
|
||||||
|
avgTarget: "78%",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(getDashboardPeakTimes).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-06-30T00:00:00.000Z"),
|
||||||
|
granularity: "month",
|
||||||
|
groupBy: "project",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -368,6 +368,54 @@ describe("entitlement.getBalance", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("entitlement.getBalanceDetail", () => {
|
||||||
|
it("returns assistant-friendly balance detail from the canonical balance workflow", async () => {
|
||||||
|
const entitlement = sampleEntitlement({ carryoverDays: 0, usedDays: 1, pendingDays: 0.5 });
|
||||||
|
const db = {
|
||||||
|
systemSettings: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findUnique: vi.fn().mockImplementation(async ({ select }: { select?: Record<string, unknown> } = {}) => ({
|
||||||
|
...(select?.userId ? { userId: "user_1" } : {}),
|
||||||
|
...(select?.federalState ? { federalState: "BY" } : {}),
|
||||||
|
...(select?.country ? { country: { code: "DE" } } : {}),
|
||||||
|
...(select?.metroCity ? { metroCity: null } : {}),
|
||||||
|
...(select?.displayName ? { displayName: "Alice Example" } : {}),
|
||||||
|
...(select?.eid ? { eid: "EMP-001" } : {}),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
vacationEntitlement: {
|
||||||
|
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||||
|
update: vi.fn().mockResolvedValue(entitlement),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockImplementation(async ({ where }: { where?: { type?: string } } = {}) => {
|
||||||
|
if (where?.type === "SICK") {
|
||||||
|
return [{ startDate: new Date("2026-02-01"), endDate: new Date("2026-02-01"), isHalfDay: false }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.getBalanceDetail({ resourceId: "res_1", year: 2026 });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
resource: "Alice Example",
|
||||||
|
eid: "EMP-001",
|
||||||
|
year: 2026,
|
||||||
|
entitlement: 30,
|
||||||
|
carryOver: 0,
|
||||||
|
taken: 1,
|
||||||
|
pending: 0.5,
|
||||||
|
remaining: 28.5,
|
||||||
|
sickDays: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── get ─────────────────────────────────────────────────────────────────────
|
// ─── get ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("entitlement.get", () => {
|
describe("entitlement.get", () => {
|
||||||
@@ -624,3 +672,67 @@ describe("entitlement.getYearSummary", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("entitlement.getYearSummaryDetail", () => {
|
||||||
|
it("returns assistant-friendly year summary detail from the canonical summary workflow", async () => {
|
||||||
|
const resources = [
|
||||||
|
{ id: "res_1", displayName: "Alice Example", eid: "EMP-001", chapter: "Delivery" },
|
||||||
|
{ id: "res_2", displayName: "Bob Example", eid: "EMP-002", chapter: "CGI" },
|
||||||
|
];
|
||||||
|
const db = {
|
||||||
|
systemSettings: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue(resources),
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
federalState: "BY",
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
vacationEntitlement: {
|
||||||
|
findUnique: vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { resourceId: string; year: number } } }) => {
|
||||||
|
if (where.resourceId_year.year !== 2026) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return sampleEntitlement({
|
||||||
|
id: `ent_${where.resourceId_year.resourceId}`,
|
||||||
|
resourceId: where.resourceId_year.resourceId,
|
||||||
|
year: 2026,
|
||||||
|
entitledDays: 28,
|
||||||
|
carryoverDays: 0,
|
||||||
|
usedDays: 0,
|
||||||
|
pendingDays: 0,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn().mockImplementation(async (args?: { data?: Record<string, unknown>; where?: { id?: string } }) => ({
|
||||||
|
...sampleEntitlement({ entitledDays: 28, carryoverDays: 0, usedDays: 0, pendingDays: 0 }),
|
||||||
|
id: args?.where?.id ?? "ent_updated",
|
||||||
|
...(args?.data ?? {}),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createManagerCaller(db);
|
||||||
|
const result = await caller.getYearSummaryDetail({ year: 2026, resourceName: "alice" });
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
resource: "Alice Example",
|
||||||
|
eid: "EMP-001",
|
||||||
|
chapter: "Delivery",
|
||||||
|
year: 2026,
|
||||||
|
entitled: 28,
|
||||||
|
carryover: 0,
|
||||||
|
used: 0,
|
||||||
|
pending: 0,
|
||||||
|
remaining: 28,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1168,6 +1168,65 @@ describe("estimate router", () => {
|
|||||||
expect.objectContaining({ code: "PRECONDITION_FAILED" }),
|
expect.objectContaining({ code: "PRECONDITION_FAILED" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("throws PRECONDITION_FAILED for demand-line project windows without working days", async () => {
|
||||||
|
const approvedEstimate = {
|
||||||
|
...baseEstimate,
|
||||||
|
projectId: "project_1",
|
||||||
|
status: EstimateStatus.APPROVED,
|
||||||
|
versions: [
|
||||||
|
{
|
||||||
|
...baseVersion,
|
||||||
|
id: "ver_approved",
|
||||||
|
status: EstimateVersionStatus.APPROVED,
|
||||||
|
lockedAt: new Date("2026-03-13"),
|
||||||
|
demandLines: [
|
||||||
|
{
|
||||||
|
id: "line_1",
|
||||||
|
name: "Staffing Gap",
|
||||||
|
hours: 16,
|
||||||
|
fte: 1,
|
||||||
|
resourceId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const findUnique = vi.fn().mockResolvedValue(approvedEstimate);
|
||||||
|
const projectFindUnique = vi.fn().mockResolvedValue({
|
||||||
|
id: "project_1",
|
||||||
|
shortCode: "PRJ1",
|
||||||
|
name: "Weekend Project",
|
||||||
|
status: "ACTIVE",
|
||||||
|
startDate: new Date("2026-03-15"),
|
||||||
|
endDate: new Date("2026-03-15"),
|
||||||
|
orderType: "CHARGEABLE",
|
||||||
|
allocationType: "INT",
|
||||||
|
winProbability: 100,
|
||||||
|
budgetCents: 100_000_00,
|
||||||
|
responsiblePerson: "Test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = {
|
||||||
|
estimate: { findUnique },
|
||||||
|
project: { findUnique: projectFindUnique },
|
||||||
|
demandRequirement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
|
assignment: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
|
resource: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
|
auditLog: { create: vi.fn() },
|
||||||
|
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createManagerCaller(db);
|
||||||
|
await expect(
|
||||||
|
caller.createPlanningHandoff({ estimateId: "est_1" }),
|
||||||
|
).rejects.toThrow(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "PRECONDITION_FAILED",
|
||||||
|
message: 'Project window has no working days for demand line "Staffing Gap"',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── RBAC ──────────────────────────────────────────────────────────────────
|
// ─── RBAC ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
cancelPendingEvents,
|
cancelPendingEvents,
|
||||||
eventBus,
|
eventBus,
|
||||||
flushPendingEvents,
|
flushPendingEvents,
|
||||||
|
permissionAudience,
|
||||||
type SseEvent,
|
type SseEvent,
|
||||||
|
userAudience,
|
||||||
} from "../sse/event-bus.js";
|
} from "../sse/event-bus.js";
|
||||||
|
|
||||||
// Mock Redis so the module loads without a real connection.
|
// Mock Redis so the module loads without a real connection.
|
||||||
@@ -153,4 +155,80 @@ describe("event-bus debounce", () => {
|
|||||||
// The timestamp should be from the first event (not later)
|
// The timestamp should be from the first event (not later)
|
||||||
expect(received[0]!.timestamp).toBe(before);
|
expect(received[0]!.timestamp).toBe(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("delivers scoped events only to matching audiences", () => {
|
||||||
|
const managerReceived: SseEvent[] = [];
|
||||||
|
const userReceived: SseEvent[] = [];
|
||||||
|
const unsubscribeManager = eventBus.subscribe((event) => {
|
||||||
|
managerReceived.push(event);
|
||||||
|
}, {
|
||||||
|
audiences: [permissionAudience("manageAllocations")],
|
||||||
|
includeUnscoped: false,
|
||||||
|
});
|
||||||
|
const unsubscribeUser = eventBus.subscribe((event) => {
|
||||||
|
userReceived.push(event);
|
||||||
|
}, {
|
||||||
|
audiences: [userAudience("user_1")],
|
||||||
|
includeUnscoped: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
eventBus.emit(
|
||||||
|
SSE_EVENT_TYPES.ALLOCATION_CREATED,
|
||||||
|
{ id: "a1" },
|
||||||
|
[permissionAudience("manageAllocations")],
|
||||||
|
);
|
||||||
|
eventBus.emit(
|
||||||
|
SSE_EVENT_TYPES.NOTIFICATION_CREATED,
|
||||||
|
{ notificationId: "n1" },
|
||||||
|
[userAudience("user_1")],
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
expect(managerReceived).toHaveLength(1);
|
||||||
|
expect(managerReceived[0]!.type).toBe(SSE_EVENT_TYPES.ALLOCATION_CREATED);
|
||||||
|
expect(userReceived).toHaveLength(1);
|
||||||
|
expect(userReceived[0]!.type).toBe(SSE_EVENT_TYPES.NOTIFICATION_CREATED);
|
||||||
|
|
||||||
|
unsubscribeManager();
|
||||||
|
unsubscribeUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not batch events from different audiences together", () => {
|
||||||
|
const firstUserReceived: SseEvent[] = [];
|
||||||
|
const secondUserReceived: SseEvent[] = [];
|
||||||
|
const unsubscribeFirst = eventBus.subscribe((event) => {
|
||||||
|
firstUserReceived.push(event);
|
||||||
|
}, {
|
||||||
|
audiences: [userAudience("user_1")],
|
||||||
|
includeUnscoped: false,
|
||||||
|
});
|
||||||
|
const unsubscribeSecond = eventBus.subscribe((event) => {
|
||||||
|
secondUserReceived.push(event);
|
||||||
|
}, {
|
||||||
|
audiences: [userAudience("user_2")],
|
||||||
|
includeUnscoped: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
eventBus.emit(
|
||||||
|
SSE_EVENT_TYPES.NOTIFICATION_CREATED,
|
||||||
|
{ notificationId: "n1" },
|
||||||
|
[userAudience("user_1")],
|
||||||
|
);
|
||||||
|
eventBus.emit(
|
||||||
|
SSE_EVENT_TYPES.NOTIFICATION_CREATED,
|
||||||
|
{ notificationId: "n2" },
|
||||||
|
[userAudience("user_2")],
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
expect(firstUserReceived).toHaveLength(1);
|
||||||
|
expect(firstUserReceived[0]!.payload).toEqual({ notificationId: "n1" });
|
||||||
|
expect(secondUserReceived).toHaveLength(1);
|
||||||
|
expect(secondUserReceived[0]!.payload).toEqual({ notificationId: "n2" });
|
||||||
|
|
||||||
|
unsubscribeFirst();
|
||||||
|
unsubscribeSecond();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,6 +40,114 @@ function createAdminCaller(db: Record<string, unknown>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("holiday calendar router", () => {
|
describe("holiday calendar router", () => {
|
||||||
|
it("lists holiday calendars with assistant-facing detail formatting", async () => {
|
||||||
|
const db = {
|
||||||
|
holidayCalendar: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "cal_by",
|
||||||
|
name: "Bayern Feiertage",
|
||||||
|
scopeType: "STATE",
|
||||||
|
stateCode: "BY",
|
||||||
|
isActive: true,
|
||||||
|
priority: 10,
|
||||||
|
country: { id: "country_de", code: "DE", name: "Deutschland" },
|
||||||
|
metroCity: null,
|
||||||
|
_count: { entries: 2 },
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: "entry_1",
|
||||||
|
date: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
name: "Heilige Drei Koenige",
|
||||||
|
isRecurringAnnual: true,
|
||||||
|
source: "state",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.listCalendarsDetail({
|
||||||
|
countryCode: "DE",
|
||||||
|
scopeType: "STATE",
|
||||||
|
includeInactive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
count: 1,
|
||||||
|
calendars: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "cal_by",
|
||||||
|
name: "Bayern Feiertage",
|
||||||
|
scopeType: "STATE",
|
||||||
|
stateCode: "BY",
|
||||||
|
entryCount: 2,
|
||||||
|
country: { id: "country_de", code: "DE", name: "Deutschland" },
|
||||||
|
entries: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "entry_1",
|
||||||
|
date: "2026-01-06",
|
||||||
|
name: "Heilige Drei Koenige",
|
||||||
|
isRecurringAnnual: true,
|
||||||
|
source: "state",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a holiday calendar by identifier with assistant-facing detail formatting", async () => {
|
||||||
|
const db = {
|
||||||
|
holidayCalendar: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
findFirst: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "cal_augsburg",
|
||||||
|
name: "Augsburg lokal",
|
||||||
|
scopeType: "CITY",
|
||||||
|
stateCode: null,
|
||||||
|
isActive: true,
|
||||||
|
priority: 5,
|
||||||
|
country: { id: "country_de", code: "DE", name: "Deutschland" },
|
||||||
|
metroCity: { id: "city_augsburg", name: "Augsburg" },
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: "entry_1",
|
||||||
|
date: new Date("2026-08-08T00:00:00.000Z"),
|
||||||
|
name: "Friedensfest lokal",
|
||||||
|
isRecurringAnnual: true,
|
||||||
|
source: "manual",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.getCalendarByIdentifierDetail({ identifier: "Augsburg lokal" });
|
||||||
|
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "cal_augsburg",
|
||||||
|
name: "Augsburg lokal",
|
||||||
|
scopeType: "CITY",
|
||||||
|
entryCount: 1,
|
||||||
|
metroCity: { id: "city_augsburg", name: "Augsburg" },
|
||||||
|
entries: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "entry_1",
|
||||||
|
date: "2026-08-08",
|
||||||
|
name: "Friedensfest lokal",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("merges built-in and scoped custom holidays in preview", async () => {
|
it("merges built-in and scoped custom holidays in preview", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
country: {
|
country: {
|
||||||
@@ -106,6 +214,164 @@ describe("holiday calendar router", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("formats preview results for assistant consumption", async () => {
|
||||||
|
const db = {
|
||||||
|
country: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: "country_de", code: "DE", name: "Deutschland" }),
|
||||||
|
},
|
||||||
|
metroCity: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: "city_augsburg", name: "Augsburg", countryId: "country_de" }),
|
||||||
|
},
|
||||||
|
holidayCalendar: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "cal_city",
|
||||||
|
name: "Augsburg lokal",
|
||||||
|
scopeType: "CITY",
|
||||||
|
priority: 10,
|
||||||
|
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: "entry_1",
|
||||||
|
date: new Date("2020-08-08T00:00:00.000Z"),
|
||||||
|
name: "Friedensfest lokal",
|
||||||
|
isRecurringAnnual: true,
|
||||||
|
source: "manual",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.previewResolvedHolidaysDetail({
|
||||||
|
countryId: "country_de",
|
||||||
|
metroCityId: "city_augsburg",
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.locationContext).toEqual({
|
||||||
|
countryId: "country_de",
|
||||||
|
countryCode: "DE",
|
||||||
|
stateCode: null,
|
||||||
|
metroCityId: "city_augsburg",
|
||||||
|
metroCity: "Augsburg",
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
expect(result.summary.byScope).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ scope: "CITY" })]),
|
||||||
|
);
|
||||||
|
expect(result.holidays).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
date: "2026-08-08",
|
||||||
|
name: "Friedensfest lokal",
|
||||||
|
scope: "CITY",
|
||||||
|
calendarName: "Augsburg lokal",
|
||||||
|
sourceType: "CUSTOM",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats resolved holidays by region for assistant consumption", async () => {
|
||||||
|
const db = {
|
||||||
|
holidayCalendar: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.resolveHolidaysDetail({
|
||||||
|
countryCode: "DE",
|
||||||
|
stateCode: "BY",
|
||||||
|
periodStart: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
periodEnd: new Date("2026-12-31T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.locationContext).toEqual({
|
||||||
|
countryId: null,
|
||||||
|
countryCode: "DE",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
metroCity: null,
|
||||||
|
});
|
||||||
|
expect(result.count).toBeGreaterThan(0);
|
||||||
|
expect(result.summary.byScope).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ scope: "STATE" })]),
|
||||||
|
);
|
||||||
|
expect(result.holidays).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06", scope: "STATE" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats resolved holidays for a resource including local city holidays", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "res_1",
|
||||||
|
eid: "bruce.banner",
|
||||||
|
displayName: "Bruce Banner",
|
||||||
|
federalState: "BY",
|
||||||
|
countryId: "country_de",
|
||||||
|
metroCityId: "city_augsburg",
|
||||||
|
country: { code: "DE", name: "Deutschland" },
|
||||||
|
metroCity: { name: "Augsburg" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
holidayCalendar: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "cal_city",
|
||||||
|
name: "Augsburg lokal",
|
||||||
|
scopeType: "CITY",
|
||||||
|
priority: 5,
|
||||||
|
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: "entry_1",
|
||||||
|
date: new Date("2020-08-08T00:00:00.000Z"),
|
||||||
|
name: "Augsburger Friedensfest",
|
||||||
|
isRecurringAnnual: true,
|
||||||
|
source: "manual",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.resolveResourceHolidaysDetail({
|
||||||
|
resourceId: "res_1",
|
||||||
|
periodStart: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
periodEnd: new Date("2026-12-31T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.resource).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
eid: "bruce.banner",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCity: "Augsburg",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.summary.byScope).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ scope: "CITY" })]),
|
||||||
|
);
|
||||||
|
expect(result.holidays).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Augsburger Friedensfest",
|
||||||
|
date: "2026-08-08",
|
||||||
|
scope: "CITY",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects duplicate calendar scopes on create", async () => {
|
it("rejects duplicate calendar scopes on create", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
country: {
|
country: {
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { BlueprintTarget, SystemRole } from "@capakraken/shared";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { blueprintRouter } from "../router/blueprint.js";
|
||||||
|
import { clientRouter } from "../router/client.js";
|
||||||
|
import { countryRouter } from "../router/country.js";
|
||||||
|
import { orgUnitRouter } from "../router/org-unit.js";
|
||||||
|
import { roleRouter } from "../router/role.js";
|
||||||
|
import { createCallerFactory } from "../trpc.js";
|
||||||
|
|
||||||
|
function createProtectedContext(db: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
user: { email: "user@example.com", name: "User", image: null },
|
||||||
|
expires: "2099-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
db: db as never,
|
||||||
|
dbUser: {
|
||||||
|
id: "user_1",
|
||||||
|
systemRole: SystemRole.USER,
|
||||||
|
permissionOverrides: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("identifier resolvers", () => {
|
||||||
|
it("resolves blueprints via a minimal read model", async () => {
|
||||||
|
const findUnique = vi.fn().mockResolvedValue({
|
||||||
|
id: "bp_1",
|
||||||
|
name: "Consulting Blueprint",
|
||||||
|
target: BlueprintTarget.PROJECT,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
const caller = createCallerFactory(blueprintRouter)(createProtectedContext({
|
||||||
|
blueprint: {
|
||||||
|
findUnique,
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await caller.resolveByIdentifier({ identifier: "bp_1" });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: "bp_1",
|
||||||
|
name: "Consulting Blueprint",
|
||||||
|
target: BlueprintTarget.PROJECT,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
expect(findUnique).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
where: { id: "bp_1" },
|
||||||
|
select: expect.objectContaining({
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
target: true,
|
||||||
|
isActive: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves clients by code via a minimal read model", async () => {
|
||||||
|
const findUnique = vi.fn()
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "client_1",
|
||||||
|
name: "Acme",
|
||||||
|
code: "ACME",
|
||||||
|
parentId: null,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
const caller = createCallerFactory(clientRouter)(createProtectedContext({
|
||||||
|
client: {
|
||||||
|
findUnique,
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await caller.resolveByIdentifier({ identifier: "ACME" });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: "client_1",
|
||||||
|
name: "Acme",
|
||||||
|
code: "ACME",
|
||||||
|
parentId: null,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
expect(findUnique).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||||
|
where: { code: "ACME" },
|
||||||
|
select: expect.objectContaining({
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
code: true,
|
||||||
|
parentId: true,
|
||||||
|
isActive: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves countries by code via a minimal read model", async () => {
|
||||||
|
const findUnique = vi.fn().mockResolvedValue(null);
|
||||||
|
const findFirst = vi.fn().mockResolvedValue({
|
||||||
|
id: "country_de",
|
||||||
|
code: "DE",
|
||||||
|
name: "Germany",
|
||||||
|
isActive: true,
|
||||||
|
dailyWorkingHours: 8,
|
||||||
|
});
|
||||||
|
const caller = createCallerFactory(countryRouter)(createProtectedContext({
|
||||||
|
country: {
|
||||||
|
findUnique,
|
||||||
|
findFirst,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await caller.resolveByIdentifier({ identifier: "de" });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: "country_de",
|
||||||
|
code: "DE",
|
||||||
|
name: "Germany",
|
||||||
|
isActive: true,
|
||||||
|
dailyWorkingHours: 8,
|
||||||
|
});
|
||||||
|
expect(findFirst).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||||
|
where: { code: { equals: "DE", mode: "insensitive" } },
|
||||||
|
select: expect.objectContaining({
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
isActive: true,
|
||||||
|
dailyWorkingHours: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves org units by short name via a minimal read model", async () => {
|
||||||
|
const findUnique = vi.fn().mockResolvedValue(null);
|
||||||
|
const findFirst = vi.fn()
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "ou_1",
|
||||||
|
name: "Delivery",
|
||||||
|
shortName: "DEL",
|
||||||
|
level: 5,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({
|
||||||
|
orgUnit: {
|
||||||
|
findUnique,
|
||||||
|
findFirst,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await caller.resolveByIdentifier({ identifier: "DEL" });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: "ou_1",
|
||||||
|
name: "Delivery",
|
||||||
|
shortName: "DEL",
|
||||||
|
level: 5,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
expect(findFirst).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||||
|
where: { shortName: { equals: "DEL", mode: "insensitive" } },
|
||||||
|
select: expect.objectContaining({
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
shortName: true,
|
||||||
|
level: true,
|
||||||
|
isActive: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves roles without loading planning counts", async () => {
|
||||||
|
const findUnique = vi.fn()
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "role_1",
|
||||||
|
name: "Designer",
|
||||||
|
color: "#123456",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
const caller = createCallerFactory(roleRouter)(createProtectedContext({
|
||||||
|
role: {
|
||||||
|
findUnique,
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await caller.resolveByIdentifier({ identifier: "Designer" });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: "role_1",
|
||||||
|
name: "Designer",
|
||||||
|
color: "#123456",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
expect(findUnique).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||||
|
where: { name: "Designer" },
|
||||||
|
select: expect.objectContaining({
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
color: true,
|
||||||
|
isActive: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { SystemRole } from "@capakraken/shared";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { insightsRouter } from "../router/insights.js";
|
||||||
|
import { createCallerFactory } from "../trpc.js";
|
||||||
|
|
||||||
|
const createCaller = createCallerFactory(insightsRouter);
|
||||||
|
|
||||||
|
function createControllerCaller(db: Record<string, unknown>) {
|
||||||
|
return createCaller({
|
||||||
|
session: {
|
||||||
|
user: { email: "controller@example.com", name: "Controller", image: null },
|
||||||
|
expires: "2099-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
db: db as never,
|
||||||
|
dbUser: {
|
||||||
|
id: "user_controller",
|
||||||
|
systemRole: SystemRole.CONTROLLER,
|
||||||
|
permissionOverrides: null,
|
||||||
|
},
|
||||||
|
roleDefaults: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("insights router", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives the summary from the same canonical anomaly snapshot", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-03-29T12:00:00.000Z"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project_1",
|
||||||
|
name: "Apollo",
|
||||||
|
budgetCents: 100_000,
|
||||||
|
startDate: new Date("2026-03-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-03-31T00:00:00.000Z"),
|
||||||
|
demandRequirements: [
|
||||||
|
{
|
||||||
|
headcount: 3,
|
||||||
|
startDate: new Date("2026-03-20T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-05T00:00:00.000Z"),
|
||||||
|
_count: { assignments: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
resourceId: "res_1",
|
||||||
|
startDate: new Date("2026-03-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-05T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 12,
|
||||||
|
dailyCostCents: 10_000,
|
||||||
|
status: "ACTIVE",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "res_1",
|
||||||
|
displayName: "Peter Parker",
|
||||||
|
availability: {
|
||||||
|
monday: 8,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
resourceId: "res_1",
|
||||||
|
hoursPerDay: 12,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const anomalies = await caller.detectAnomalies();
|
||||||
|
const summary = await caller.getInsightsSummary();
|
||||||
|
|
||||||
|
expect(anomalies).toEqual([
|
||||||
|
expect.objectContaining({ type: "budget", severity: "critical", entityName: "Apollo" }),
|
||||||
|
expect.objectContaining({ type: "staffing", severity: "critical", entityName: "Apollo" }),
|
||||||
|
expect.objectContaining({ type: "utilization", severity: "critical", entityName: "Peter Parker" }),
|
||||||
|
expect.objectContaining({ type: "timeline", severity: "warning", entityName: "Apollo" }),
|
||||||
|
]);
|
||||||
|
expect(summary).toEqual({
|
||||||
|
total: anomalies.length,
|
||||||
|
criticalCount: anomalies.filter((anomaly) => anomaly.severity === "critical").length,
|
||||||
|
budget: anomalies.filter((anomaly) => anomaly.type === "budget").length,
|
||||||
|
staffing: anomalies.filter((anomaly) => anomaly.type === "staffing").length,
|
||||||
|
timeline: anomalies.filter((anomaly) => anomaly.type === "timeline").length,
|
||||||
|
utilization: anomalies.filter((anomaly) => anomaly.type === "utilization").length,
|
||||||
|
});
|
||||||
|
expect(db.project.findMany).toHaveBeenCalledTimes(2);
|
||||||
|
expect(db.resource.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
availability: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns assistant-friendly anomaly detail from the canonical snapshot", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-03-15T00:00:00.000Z"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project_1",
|
||||||
|
name: "Apollo",
|
||||||
|
budgetCents: 100_000,
|
||||||
|
startDate: new Date("2026-03-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-03-31T00:00:00.000Z"),
|
||||||
|
demandRequirements: [
|
||||||
|
{
|
||||||
|
headcount: 3,
|
||||||
|
startDate: new Date("2026-03-10T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-03-20T00:00:00.000Z"),
|
||||||
|
_count: { assignments: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
resourceId: "res_1",
|
||||||
|
startDate: new Date("2026-03-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-05T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 12,
|
||||||
|
dailyCostCents: 10_000,
|
||||||
|
status: "ACTIVE",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "res_1",
|
||||||
|
displayName: "Peter Parker",
|
||||||
|
availability: {
|
||||||
|
monday: 8,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
resourceId: "res_1",
|
||||||
|
hoursPerDay: 12,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.getAnomalyDetail();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
count: 4,
|
||||||
|
anomalies: [
|
||||||
|
expect.objectContaining({ type: "budget", severity: "critical", entityName: "Apollo" }),
|
||||||
|
expect.objectContaining({ type: "staffing", severity: "critical", entityName: "Apollo" }),
|
||||||
|
expect.objectContaining({ type: "utilization", severity: "critical", entityName: "Peter Parker" }),
|
||||||
|
expect.objectContaining({ type: "timeline", severity: "warning", entityName: "Apollo" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(db.project.findMany).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { OrderType, AllocationType, ProjectStatus, SystemRole } from "@capakraken/shared";
|
import { OrderType, AllocationType, ProjectStatus, SystemRole } from "@capakraken/shared";
|
||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { invalidateDashboardCache } from "../lib/cache.js";
|
||||||
|
import { logger } from "../lib/logger.js";
|
||||||
import { projectRouter } from "../router/project.js";
|
import { projectRouter } from "../router/project.js";
|
||||||
|
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
||||||
import { createCallerFactory } from "../trpc.js";
|
import { createCallerFactory } from "../trpc.js";
|
||||||
|
|
||||||
vi.mock("@capakraken/application", async (importOriginal) => {
|
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||||
@@ -26,6 +29,19 @@ vi.mock("../lib/cache.js", () => ({
|
|||||||
invalidateDashboardCache: vi.fn().mockResolvedValue(undefined),
|
invalidateDashboardCache: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/webhook-dispatcher.js", () => ({
|
||||||
|
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/logger.js", () => ({
|
||||||
|
logger: {
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../ai-client.js", () => ({
|
vi.mock("../ai-client.js", () => ({
|
||||||
isDalleConfigured: vi.fn().mockReturnValue(false),
|
isDalleConfigured: vi.fn().mockReturnValue(false),
|
||||||
createDalleClient: vi.fn(),
|
createDalleClient: vi.fn(),
|
||||||
@@ -155,6 +171,47 @@ describe("project router", () => {
|
|||||||
expect(db.auditLog.create).toHaveBeenCalled();
|
expect(db.auditLog.create).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("logs and swallows background cache and webhook failures during create", async () => {
|
||||||
|
vi.mocked(invalidateDashboardCache).mockRejectedValueOnce(new Error("redis unavailable"));
|
||||||
|
vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook unavailable"));
|
||||||
|
|
||||||
|
const created = { ...sampleProject, id: "project_safe_create" };
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
create: vi.fn().mockResolvedValue(created),
|
||||||
|
},
|
||||||
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||||
|
webhook: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createManagerCaller(db);
|
||||||
|
const result = await caller.create({
|
||||||
|
shortCode: "SAFE-001",
|
||||||
|
name: "Safe Project",
|
||||||
|
responsiblePerson: "Alice",
|
||||||
|
orderType: OrderType.CHARGEABLE,
|
||||||
|
allocationType: AllocationType.INT,
|
||||||
|
winProbability: 80,
|
||||||
|
budgetCents: 500_000_00,
|
||||||
|
startDate: new Date("2026-01-01"),
|
||||||
|
endDate: new Date("2026-06-30"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(result.id).toBe("project_safe_create");
|
||||||
|
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ effectName: "invalidateDashboardCache" }),
|
||||||
|
"Project background side effect failed",
|
||||||
|
);
|
||||||
|
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ effectName: "dispatchWebhooks", event: "project.created" }),
|
||||||
|
"Project background side effect failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("throws CONFLICT when shortCode already exists", async () => {
|
it("throws CONFLICT when shortCode already exists", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
project: {
|
project: {
|
||||||
@@ -208,7 +265,7 @@ describe("project router", () => {
|
|||||||
// ─── getById ──────────────────────────────────────────────────────────────
|
// ─── getById ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("getById", () => {
|
describe("getById", () => {
|
||||||
it("returns the correct project with allocations and demands", async () => {
|
it("returns the correct project with allocations and demands for controller-level access", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
project: {
|
project: {
|
||||||
findUnique: vi.fn().mockResolvedValue({ ...sampleProject, blueprint: null }),
|
findUnique: vi.fn().mockResolvedValue({ ...sampleProject, blueprint: null }),
|
||||||
@@ -218,7 +275,7 @@ describe("project router", () => {
|
|||||||
assignment: { findMany: vi.fn().mockResolvedValue([]) },
|
assignment: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createProtectedCaller(db);
|
const caller = createControllerCaller(db);
|
||||||
const result = await caller.getById({ id: "project_1" });
|
const result = await caller.getById({ id: "project_1" });
|
||||||
|
|
||||||
expect(result.id).toBe("project_1");
|
expect(result.id).toBe("project_1");
|
||||||
@@ -236,11 +293,22 @@ describe("project router", () => {
|
|||||||
assignment: { findMany: vi.fn().mockResolvedValue([]) },
|
assignment: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createProtectedCaller(db);
|
const caller = createControllerCaller(db);
|
||||||
await expect(caller.getById({ id: "missing" })).rejects.toThrow(
|
await expect(caller.getById({ id: "missing" })).rejects.toThrow(
|
||||||
expect.objectContaining({ code: "NOT_FOUND" }),
|
expect.objectContaining({ code: "NOT_FOUND" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks USER role from loading full project planning context", async () => {
|
||||||
|
const db = {
|
||||||
|
project: { findUnique: vi.fn() },
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
await expect(caller.getById({ id: "project_1" })).rejects.toThrow(
|
||||||
|
expect.objectContaining({ code: "FORBIDDEN" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getShoringRatio", () => {
|
describe("getShoringRatio", () => {
|
||||||
@@ -292,7 +360,7 @@ describe("project router", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createProtectedCaller(db);
|
const caller = createControllerCaller(db);
|
||||||
const result = await caller.getShoringRatio({ projectId: "project_1" });
|
const result = await caller.getShoringRatio({ projectId: "project_1" });
|
||||||
|
|
||||||
expect(result.totalHours).toBe(24);
|
expect(result.totalHours).toBe(24);
|
||||||
@@ -373,6 +441,38 @@ describe("project router", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("logs and swallows background failures during status changes", async () => {
|
||||||
|
vi.mocked(invalidateDashboardCache).mockRejectedValueOnce(new Error("redis unavailable"));
|
||||||
|
vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook unavailable"));
|
||||||
|
|
||||||
|
const updated = { ...sampleProject, status: ProjectStatus.COMPLETED };
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
update: vi.fn().mockResolvedValue(updated),
|
||||||
|
},
|
||||||
|
webhook: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createManagerCaller(db);
|
||||||
|
const result = await caller.updateStatus({
|
||||||
|
id: "project_1",
|
||||||
|
status: ProjectStatus.COMPLETED,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(result.status).toBe(ProjectStatus.COMPLETED);
|
||||||
|
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ effectName: "invalidateDashboardCache" }),
|
||||||
|
"Project background side effect failed",
|
||||||
|
);
|
||||||
|
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ effectName: "dispatchWebhooks", event: "project.status_changed" }),
|
||||||
|
"Project background side effect failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── batchUpdateStatus ────────────────────────────────────────────────────
|
// ─── batchUpdateStatus ────────────────────────────────────────────────────
|
||||||
@@ -547,4 +647,212 @@ describe("project router", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("assistant-facing detail routes", () => {
|
||||||
|
it("returns lightweight project search summaries from the canonical router", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project_1",
|
||||||
|
shortCode: "GDM",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
status: ProjectStatus.ACTIVE,
|
||||||
|
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-03-31T00:00:00.000Z"),
|
||||||
|
client: { name: "Acme Mobility" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.searchSummaries({ search: "Gelddruckmaschine", limit: 10 });
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
id: "project_1",
|
||||||
|
code: "GDM",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
status: "ACTIVE",
|
||||||
|
start: "2026-01-01",
|
||||||
|
end: "2026-03-31",
|
||||||
|
client: "Acme Mobility",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns formatted project search summaries from the canonical router", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project_1",
|
||||||
|
shortCode: "GDM",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
status: ProjectStatus.ACTIVE,
|
||||||
|
budgetCents: 500000,
|
||||||
|
winProbability: 100,
|
||||||
|
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-03-31T00:00:00.000Z"),
|
||||||
|
client: { name: "Acme Mobility" },
|
||||||
|
_count: { assignments: 3, estimates: 1 },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.searchSummariesDetail({ search: "Gelddruckmaschine", limit: 10 });
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
id: "project_1",
|
||||||
|
code: "GDM",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
status: "ACTIVE",
|
||||||
|
budget: "5.000,00 EUR",
|
||||||
|
winProbability: "100%",
|
||||||
|
start: "2026-01-01",
|
||||||
|
end: "2026-03-31",
|
||||||
|
client: "Acme Mobility",
|
||||||
|
assignmentCount: 3,
|
||||||
|
estimateCount: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks USER role from detailed project search summaries", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
await expect(
|
||||||
|
caller.searchSummariesDetail({ search: "Gelddruckmaschine", limit: 10 }),
|
||||||
|
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns lightweight project identifier reads from the canonical router", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({
|
||||||
|
id: "project_1",
|
||||||
|
shortCode: "GDM",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
status: ProjectStatus.ACTIVE,
|
||||||
|
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-03-31T00:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.getByIdentifier({ identifier: "GDM" });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: "project_1",
|
||||||
|
shortCode: "GDM",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
status: ProjectStatus.ACTIVE,
|
||||||
|
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-03-31T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns formatted project details from the canonical router", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findUnique: vi.fn()
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "project_1",
|
||||||
|
shortCode: "GDM",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
status: ProjectStatus.ACTIVE,
|
||||||
|
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-03-31T00:00:00.000Z"),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "project_1",
|
||||||
|
shortCode: "GDM",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
status: ProjectStatus.ACTIVE,
|
||||||
|
orderType: OrderType.CHARGEABLE,
|
||||||
|
allocationType: AllocationType.INT,
|
||||||
|
budgetCents: 500000,
|
||||||
|
winProbability: 100,
|
||||||
|
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-03-31T00:00:00.000Z"),
|
||||||
|
responsiblePerson: "Bruce Banner",
|
||||||
|
client: { name: "Acme Mobility" },
|
||||||
|
utilizationCategory: { code: "BILLABLE", name: "Billable" },
|
||||||
|
_count: { assignments: 3, estimates: 1 },
|
||||||
|
}),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
resource: { displayName: "Bruce Banner", eid: "EMP-001" },
|
||||||
|
role: "Lead",
|
||||||
|
status: "ACTIVE",
|
||||||
|
hoursPerDay: 8,
|
||||||
|
startDate: new Date("2026-02-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-02-28T00:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.getByIdentifierDetail({ identifier: "GDM" });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: "project_1",
|
||||||
|
code: "GDM",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
status: "ACTIVE",
|
||||||
|
orderType: "CHARGEABLE",
|
||||||
|
allocationType: "INT",
|
||||||
|
budget: "5.000,00 EUR",
|
||||||
|
budgetCents: 500000,
|
||||||
|
winProbability: "100%",
|
||||||
|
start: "2026-01-01",
|
||||||
|
end: "2026-03-31",
|
||||||
|
responsible: "Bruce Banner",
|
||||||
|
client: "Acme Mobility",
|
||||||
|
category: "Billable",
|
||||||
|
assignmentCount: 3,
|
||||||
|
estimateCount: 1,
|
||||||
|
topAllocations: [
|
||||||
|
{
|
||||||
|
resource: "Bruce Banner",
|
||||||
|
eid: "EMP-001",
|
||||||
|
role: "Lead",
|
||||||
|
status: "ACTIVE",
|
||||||
|
hoursPerDay: 8,
|
||||||
|
start: "2026-02-01",
|
||||||
|
end: "2026-02-28",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks USER role from detailed project identifier reads", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
await expect(
|
||||||
|
caller.getByIdentifierDetail({ identifier: "GDM" }),
|
||||||
|
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { inferProcedureInput } from "@trpc/server";
|
import { SystemRole } from "@capakraken/shared";
|
||||||
import type { AppRouter } from "../router/index.js";
|
import { rateCardRouter } from "../router/rate-card.js";
|
||||||
|
import { createCallerFactory } from "../trpc.js";
|
||||||
|
|
||||||
// Minimal mock helpers
|
const createCaller = createCallerFactory(rateCardRouter);
|
||||||
function mockCtx(overrides: Record<string, unknown> = {}) {
|
|
||||||
return {
|
function createControllerCaller(db: Record<string, unknown>) {
|
||||||
ctx: {
|
return createCaller({
|
||||||
session: { user: { id: "user_1", systemRole: "MANAGER" } },
|
session: {
|
||||||
db: overrides,
|
user: { email: "controller@example.com", name: "Controller", image: null },
|
||||||
|
expires: "2099-01-01T00:00:00.000Z",
|
||||||
},
|
},
|
||||||
};
|
db: db as never,
|
||||||
|
dbUser: {
|
||||||
|
id: "user_1",
|
||||||
|
systemRole: SystemRole.CONTROLLER,
|
||||||
|
permissionOverrides: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("rateCard router", () => {
|
describe("rateCard router", () => {
|
||||||
@@ -60,6 +68,52 @@ describe("rateCard router", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveRate", () => {
|
describe("resolveRate", () => {
|
||||||
|
it("resolves a resource-based rate through the canonical router query", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "res_1",
|
||||||
|
displayName: "Bruce Banner",
|
||||||
|
chapter: "Delivery",
|
||||||
|
areaRole: { name: "Pipeline TD" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "role_1" }),
|
||||||
|
},
|
||||||
|
rateCard: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "rc_2026",
|
||||||
|
name: "Standard 2026",
|
||||||
|
client: null,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
id: "line_1",
|
||||||
|
chapter: "Delivery",
|
||||||
|
seniority: "Senior",
|
||||||
|
costRateCents: 12_000,
|
||||||
|
billRateCents: 18_000,
|
||||||
|
role: { id: "role_1", name: "Pipeline TD" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.resolveBestRate({ resourceId: "res_1" });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
rateCard: "Standard 2026",
|
||||||
|
resource: "Bruce Banner",
|
||||||
|
rate: "120,00 EUR",
|
||||||
|
rateCents: 12000,
|
||||||
|
matchedBy: "role: Pipeline TD",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("returns the most specific matching line", () => {
|
it("returns the most specific matching line", () => {
|
||||||
const lines = [
|
const lines = [
|
||||||
{ id: "rcl_1", roleId: null, chapter: "Digital Content Production", costRateCents: 7000, billRateCents: 12000 },
|
{ id: "rcl_1", roleId: null, chapter: "Digital Content Production", costRateCents: 7000, billRateCents: 12000 },
|
||||||
|
|||||||
@@ -115,4 +115,80 @@ describe("report router", () => {
|
|||||||
expect(result.csv).toContain("Name,Country Code,Holiday Dates,Holiday Hours Deduction,Absence Hours Deduction,SAH,Target Hours,Unassigned Hours");
|
expect(result.csv).toContain("Name,Country Code,Holiday Dates,Holiday Hours Deduction,Absence Hours Deduction,SAH,Target Hours,Unassigned Hours");
|
||||||
expect(result.csv).toContain("Alice,DE,1,8,4,156,124.8,156");
|
expect(result.csv).toContain("Alice,DE,1,8,4,156,124.8,156");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects invalid resource_month period months instead of silently normalizing them", async () => {
|
||||||
|
const caller = createControllerCaller({});
|
||||||
|
|
||||||
|
await expect(caller.getReportData({
|
||||||
|
entity: "resource_month",
|
||||||
|
columns: ["displayName"],
|
||||||
|
filters: [],
|
||||||
|
periodMonth: "2026-13",
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: expect.stringContaining("Invalid"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown columns instead of silently dropping them", async () => {
|
||||||
|
const caller = createControllerCaller({
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
count: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(caller.getReportData({
|
||||||
|
entity: "resource",
|
||||||
|
columns: ["displayName", "unknownColumn"],
|
||||||
|
filters: [],
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: expect.stringContaining("unknownColumn"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsupported relation filters instead of silently ignoring them", async () => {
|
||||||
|
const caller = createControllerCaller({
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
count: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(caller.getReportData({
|
||||||
|
entity: "assignment",
|
||||||
|
columns: ["id", "resource.displayName"],
|
||||||
|
filters: [{ field: "resource.displayName", op: "contains", value: "Alice" }],
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: expect.stringContaining("resource.displayName"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid numeric filter values instead of silently dropping them", async () => {
|
||||||
|
const caller = createControllerCaller({
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
count: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(caller.getReportData({
|
||||||
|
entity: "resource",
|
||||||
|
columns: ["displayName"],
|
||||||
|
filters: [{ field: "lcrCents", op: "gte", value: "not-a-number" }],
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: expect.stringContaining("lcrCents"),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -112,10 +112,10 @@ describe("resource router CRUD", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── list ─────────────────────────────────────────────────────────────────
|
// ─── listStaff ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("list", () => {
|
describe("listStaff", () => {
|
||||||
it("returns paginated results with total count", async () => {
|
it("returns paginated results with total count for staff callers", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
findMany: vi.fn().mockResolvedValue([sampleResource]),
|
findMany: vi.fn().mockResolvedValue([sampleResource]),
|
||||||
@@ -123,15 +123,15 @@ describe("resource router CRUD", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createProtectedCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
const result = await caller.list({ limit: 50 });
|
const result = await caller.listStaff({ limit: 50 });
|
||||||
|
|
||||||
expect(result.resources).toHaveLength(1);
|
expect(result.resources).toHaveLength(1);
|
||||||
expect(result.resources[0]?.displayName).toBe("Alice");
|
expect(result.resources[0]?.displayName).toBe("Alice");
|
||||||
expect(db.resource.findMany).toHaveBeenCalled();
|
expect(db.resource.findMany).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies search filter", async () => {
|
it("applies search filter for staff callers", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
@@ -139,8 +139,8 @@ describe("resource router CRUD", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createProtectedCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
await caller.list({ search: "Alice", limit: 50 });
|
await caller.listStaff({ search: "Alice", limit: 50 });
|
||||||
|
|
||||||
expect(db.resource.findMany).toHaveBeenCalled();
|
expect(db.resource.findMany).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -152,7 +152,8 @@ describe("resource router CRUD", () => {
|
|||||||
it("returns correct resource", async () => {
|
it("returns correct resource", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
findUnique: vi.fn().mockResolvedValue(sampleResource),
|
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ ...sampleResource, userId: "user_1" }),
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
systemSettings: {
|
systemSettings: {
|
||||||
@@ -170,6 +171,7 @@ describe("resource router CRUD", () => {
|
|||||||
it("throws NOT_FOUND when resource does not exist", async () => {
|
it("throws NOT_FOUND when resource does not exist", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||||
findUnique: vi.fn().mockResolvedValue(null),
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
@@ -188,6 +190,7 @@ describe("resource router CRUD", () => {
|
|||||||
const ownedResource = { ...sampleResource, userId: "user_1" };
|
const ownedResource = { ...sampleResource, userId: "user_1" };
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||||
findUnique: vi.fn().mockResolvedValue(ownedResource),
|
findUnique: vi.fn().mockResolvedValue(ownedResource),
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
@@ -201,6 +204,21 @@ describe("resource router CRUD", () => {
|
|||||||
|
|
||||||
expect(result.isOwnedByCurrentUser).toBe(true);
|
expect(result.isOwnedByCurrentUser).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects foreign resources for regular users", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
|
||||||
|
findUnique: vi.fn().mockResolvedValue(sampleResource),
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
await expect(caller.getById({ id: "res_1" })).rejects.toThrow(
|
||||||
|
expect.objectContaining({ code: "FORBIDDEN" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── create ───────────────────────────────────────────────────────────────
|
// ─── create ───────────────────────────────────────────────────────────────
|
||||||
@@ -349,6 +367,7 @@ describe("resource router CRUD", () => {
|
|||||||
it("returns expected shape with key fields", async () => {
|
it("returns expected shape with key fields", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||||
findUnique: vi.fn().mockResolvedValue({
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
id: "res_1",
|
id: "res_1",
|
||||||
displayName: "Alice",
|
displayName: "Alice",
|
||||||
@@ -387,7 +406,10 @@ describe("resource router CRUD", () => {
|
|||||||
|
|
||||||
it("throws NOT_FOUND for missing resource", async () => {
|
it("throws NOT_FOUND for missing resource", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
resource: { findUnique: vi.fn().mockResolvedValue(null) },
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
systemSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
systemSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -396,6 +418,37 @@ describe("resource router CRUD", () => {
|
|||||||
expect.objectContaining({ code: "NOT_FOUND" }),
|
expect.objectContaining({ code: "NOT_FOUND" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects foreign hover-card access for regular users", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "res_1",
|
||||||
|
displayName: "Alice",
|
||||||
|
eid: "E-001",
|
||||||
|
email: "alice@example.com",
|
||||||
|
chapter: "CGI",
|
||||||
|
lcrCents: 5000,
|
||||||
|
ucrCents: 9000,
|
||||||
|
currency: "EUR",
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
skills: [],
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
isActive: true,
|
||||||
|
areaRole: null,
|
||||||
|
country: null,
|
||||||
|
managementLevel: null,
|
||||||
|
resourceType: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
await expect(caller.getHoverCard({ id: "res_1" })).rejects.toThrow(
|
||||||
|
expect.objectContaining({ code: "FORBIDDEN" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── importSkillMatrix ────────────────────────────────────────────────────
|
// ─── importSkillMatrix ────────────────────────────────────────────────────
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
import { listAssignmentBookings } from "@capakraken/application";
|
||||||
import { SystemRole } from "@capakraken/shared";
|
import { SystemRole } from "@capakraken/shared";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { staffingRouter } from "../router/staffing.js";
|
import { staffingRouter } from "../router/staffing.js";
|
||||||
@@ -245,6 +246,303 @@ describe("staffing.getSuggestions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("staffing.getProjectStaffingSuggestions", () => {
|
||||||
|
it("returns canonical project-scoped staffing suggestions with defaults and role filter", async () => {
|
||||||
|
vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "project_1",
|
||||||
|
shortCode: "GDM",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
sampleResource({
|
||||||
|
id: "res_by",
|
||||||
|
displayName: "Bavaria",
|
||||||
|
eid: "BY-1",
|
||||||
|
areaRole: { name: "Consultant" },
|
||||||
|
country: { code: "DE", name: "Germany" },
|
||||||
|
}),
|
||||||
|
sampleResource({
|
||||||
|
id: "res_hh",
|
||||||
|
displayName: "Hamburg",
|
||||||
|
eid: "HH-1",
|
||||||
|
federalState: "HH",
|
||||||
|
areaRole: { name: "Artist" },
|
||||||
|
country: { code: "DE", name: "Germany" },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.getProjectStaffingSuggestions({
|
||||||
|
projectId: "project_1",
|
||||||
|
roleName: "artist",
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
project: "Gelddruckmaschine (GDM)",
|
||||||
|
period: "2026-01-06 to 2026-01-06",
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
id: "res_hh",
|
||||||
|
name: "Hamburg",
|
||||||
|
eid: "HH-1",
|
||||||
|
role: "Artist",
|
||||||
|
chapter: "VFX",
|
||||||
|
fte: expect.any(Number),
|
||||||
|
lcr: "75,00 EUR",
|
||||||
|
workingDays: expect.any(Number),
|
||||||
|
availableHours: expect.any(Number),
|
||||||
|
bookedHours: 0,
|
||||||
|
availableHoursPerDay: expect.any(Number),
|
||||||
|
utilization: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(db.project.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: "project_1" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
shortCode: true,
|
||||||
|
name: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("staffing.getBestProjectResourceDetail", () => {
|
||||||
|
it("returns canonical project resource ranking with holiday-aware capacity details", async () => {
|
||||||
|
const assignmentFindMany = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
resourceId: "res_carol",
|
||||||
|
hoursPerDay: 2,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
status: "PROPOSED",
|
||||||
|
resource: {
|
||||||
|
id: "res_carol",
|
||||||
|
eid: "carol.danvers",
|
||||||
|
displayName: "Carol Danvers",
|
||||||
|
chapter: "Delivery",
|
||||||
|
lcrCents: 7664,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "HH",
|
||||||
|
metroCityId: "city_hamburg",
|
||||||
|
country: { code: "DE", name: "Deutschland" },
|
||||||
|
metroCity: { name: "Hamburg" },
|
||||||
|
areaRole: { name: "Artist" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resourceId: "res_steve",
|
||||||
|
hoursPerDay: 4,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
status: "CONFIRMED",
|
||||||
|
resource: {
|
||||||
|
id: "res_steve",
|
||||||
|
eid: "steve.rogers",
|
||||||
|
displayName: "Steve Rogers",
|
||||||
|
chapter: "Delivery",
|
||||||
|
lcrCents: 13377,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: "city_augsburg",
|
||||||
|
country: { code: "DE", name: "Deutschland" },
|
||||||
|
metroCity: { name: "Augsburg" },
|
||||||
|
areaRole: { name: "Artist" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
resourceId: "res_carol",
|
||||||
|
projectId: "project_lari",
|
||||||
|
hoursPerDay: 2,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
status: "PROPOSED",
|
||||||
|
project: { name: "Gelddruckmaschine", shortCode: "LARI" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resourceId: "res_steve",
|
||||||
|
projectId: "project_lari",
|
||||||
|
hoursPerDay: 4,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
status: "CONFIRMED",
|
||||||
|
project: { name: "Gelddruckmaschine", shortCode: "LARI" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "project_lari",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
shortCode: "LARI",
|
||||||
|
status: "ACTIVE",
|
||||||
|
responsiblePerson: "Larissa Joos",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: assignmentFindMany,
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.getBestProjectResourceDetail({
|
||||||
|
projectId: "project_lari",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
minHoursPerDay: 3,
|
||||||
|
rankingMode: "lowest_lcr",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.project).toEqual({
|
||||||
|
id: "project_lari",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
shortCode: "LARI",
|
||||||
|
status: "ACTIVE",
|
||||||
|
responsiblePerson: "Larissa Joos",
|
||||||
|
});
|
||||||
|
expect(result.period).toEqual({
|
||||||
|
startDate: "2026-01-05",
|
||||||
|
endDate: "2026-01-16",
|
||||||
|
minHoursPerDay: 3,
|
||||||
|
rankingMode: "lowest_lcr",
|
||||||
|
});
|
||||||
|
expect(result.filters).toEqual({
|
||||||
|
chapter: null,
|
||||||
|
roleName: null,
|
||||||
|
});
|
||||||
|
expect(result.candidateCount).toBe(2);
|
||||||
|
expect(result.bestMatch).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Carol Danvers",
|
||||||
|
remainingHoursPerDay: 6,
|
||||||
|
lcrCents: 7664,
|
||||||
|
federalState: "HH",
|
||||||
|
metroCity: "Hamburg",
|
||||||
|
baseAvailableHours: 80,
|
||||||
|
holidaySummary: expect.objectContaining({ count: 0 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.candidates).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Carol Danvers",
|
||||||
|
remainingHoursPerDay: 6,
|
||||||
|
workingDays: 10,
|
||||||
|
baseAvailableHours: 80,
|
||||||
|
holidaySummary: expect.objectContaining({ count: 0, hoursDeduction: 0 }),
|
||||||
|
capacityBreakdown: expect.objectContaining({ holidayHoursDeduction: 0 }),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Steve Rogers",
|
||||||
|
remainingHoursPerDay: 4,
|
||||||
|
workingDays: 9,
|
||||||
|
baseAvailableHours: 80,
|
||||||
|
holidaySummary: expect.objectContaining({ count: 1, hoursDeduction: 8 }),
|
||||||
|
capacityBreakdown: expect.objectContaining({ holidayHoursDeduction: 8 }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("staffing.searchCapacity", () => {
|
||||||
|
it("returns holiday-aware capacity across multiple resources", async () => {
|
||||||
|
vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
sampleResource({
|
||||||
|
id: "res_by",
|
||||||
|
displayName: "Bavaria",
|
||||||
|
eid: "BY-1",
|
||||||
|
chapter: "CGI",
|
||||||
|
areaRole: { name: "Consultant" },
|
||||||
|
federalState: "BY",
|
||||||
|
}),
|
||||||
|
sampleResource({
|
||||||
|
id: "res_hh",
|
||||||
|
displayName: "Hamburg",
|
||||||
|
eid: "HH-1",
|
||||||
|
chapter: "CGI",
|
||||||
|
areaRole: { name: "Consultant" },
|
||||||
|
federalState: "HH",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.searchCapacity({
|
||||||
|
startDate: new Date("2026-01-06"),
|
||||||
|
endDate: new Date("2026-01-06"),
|
||||||
|
minHoursPerDay: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.results).toHaveLength(1);
|
||||||
|
expect(result.results[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Hamburg",
|
||||||
|
availableHours: 8,
|
||||||
|
availableHoursPerDay: 8,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies role and chapter filters in the resource query", async () => {
|
||||||
|
vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
await caller.searchCapacity({
|
||||||
|
startDate: new Date("2026-04-01"),
|
||||||
|
endDate: new Date("2026-04-02"),
|
||||||
|
minHoursPerDay: 4,
|
||||||
|
roleName: "Consult",
|
||||||
|
chapter: "CG",
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.resource.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
isActive: true,
|
||||||
|
areaRole: { name: { contains: "Consult", mode: "insensitive" } },
|
||||||
|
chapter: { contains: "CG", mode: "insensitive" },
|
||||||
|
}),
|
||||||
|
take: 100,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── analyzeUtilization ──────────────────────────────────────────────────────
|
// ─── analyzeUtilization ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("staffing.analyzeUtilization", () => {
|
describe("staffing.analyzeUtilization", () => {
|
||||||
|
|||||||
@@ -0,0 +1,638 @@
|
|||||||
|
import { SystemRole } from "@capakraken/shared";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
listAssignmentBookings: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../lib/anonymization.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../lib/anonymization.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getAnonymizationDirectory: vi.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { listAssignmentBookings } from "@capakraken/application";
|
||||||
|
import { timelineRouter } from "../router/timeline.js";
|
||||||
|
import { createCallerFactory } from "../trpc.js";
|
||||||
|
|
||||||
|
const createCaller = createCallerFactory(timelineRouter);
|
||||||
|
|
||||||
|
function createAdminCaller(db: Record<string, unknown>) {
|
||||||
|
return createCaller({
|
||||||
|
session: {
|
||||||
|
user: { email: "admin@example.com", name: "Admin", image: null },
|
||||||
|
expires: "2026-03-29T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
db: db as never,
|
||||||
|
dbUser: {
|
||||||
|
id: "user_admin",
|
||||||
|
systemRole: SystemRole.ADMIN,
|
||||||
|
permissionOverrides: null,
|
||||||
|
},
|
||||||
|
roleDefaults: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProtectedCaller(db: Record<string, unknown>) {
|
||||||
|
return createCaller({
|
||||||
|
session: {
|
||||||
|
user: { email: "user@example.com", name: "User", image: null },
|
||||||
|
expires: "2026-03-29T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
db: db as never,
|
||||||
|
dbUser: {
|
||||||
|
id: "user_1",
|
||||||
|
systemRole: SystemRole.USER,
|
||||||
|
permissionOverrides: null,
|
||||||
|
},
|
||||||
|
roleDefaults: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("timeline router detail views", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a self-service timeline view scoped to the caller's linked resource", async () => {
|
||||||
|
const demandFindMany = vi.fn();
|
||||||
|
const assignmentFindMany = vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "asg_self",
|
||||||
|
projectId: "project_1",
|
||||||
|
resourceId: "res_self",
|
||||||
|
role: "Artist",
|
||||||
|
hoursPerDay: 6,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-09T00:00:00.000Z"),
|
||||||
|
status: "CONFIRMED",
|
||||||
|
metadata: null,
|
||||||
|
resource: {
|
||||||
|
id: "res_self",
|
||||||
|
displayName: "Alice",
|
||||||
|
eid: "EMP-SELF",
|
||||||
|
chapter: "Delivery",
|
||||||
|
lcrCents: 10000,
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
id: "project_1",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
shortCode: "GDM",
|
||||||
|
clientId: "client_1",
|
||||||
|
budgetCents: 100000,
|
||||||
|
winProbability: 100,
|
||||||
|
status: "ACTIVE",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
staffingReqs: null,
|
||||||
|
responsiblePerson: "Larissa",
|
||||||
|
color: "#fff",
|
||||||
|
orderType: "CHARGEABLE",
|
||||||
|
},
|
||||||
|
roleEntity: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const caller = createProtectedCaller({
|
||||||
|
demandRequirement: {
|
||||||
|
findMany: demandFindMany,
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: assignmentFindMany,
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_self" }),
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await caller.getMyEntriesView({
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-09T00:00:00.000Z"),
|
||||||
|
resourceIds: ["res_other"],
|
||||||
|
chapters: ["Finance"],
|
||||||
|
eids: ["EMP-OTHER"],
|
||||||
|
countryCodes: ["US"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.assignments).toHaveLength(1);
|
||||||
|
expect(result.assignments[0]?.resourceId).toBe("res_self");
|
||||||
|
expect(demandFindMany).not.toHaveBeenCalled();
|
||||||
|
expect(assignmentFindMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
resourceId: { in: ["res_self"] },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns self-service holiday overlays for the caller's linked resource", async () => {
|
||||||
|
const demandFindMany = vi.fn();
|
||||||
|
const assignmentFindMany = vi.fn().mockResolvedValue([]);
|
||||||
|
const resourceFindMany = vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "res_self",
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: "city_munich",
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: { name: "Muenchen" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const caller = createProtectedCaller({
|
||||||
|
demandRequirement: {
|
||||||
|
findMany: demandFindMany,
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: assignmentFindMany,
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_self" }),
|
||||||
|
findMany: resourceFindMany,
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await caller.getMyHolidayOverlays({
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-09T00:00:00.000Z"),
|
||||||
|
resourceIds: ["res_other"],
|
||||||
|
chapters: ["Finance"],
|
||||||
|
eids: ["EMP-OTHER"],
|
||||||
|
countryCodes: ["US"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
resourceId: "res_self",
|
||||||
|
note: "Heilige Drei Könige",
|
||||||
|
scope: "STATE",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(demandFindMany).not.toHaveBeenCalled();
|
||||||
|
expect(assignmentFindMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
resourceId: { in: ["res_self"] },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
expect(resourceFindMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
where: { id: { in: ["res_self"] } },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty self-service timeline data when the caller has no linked resource", async () => {
|
||||||
|
const demandFindMany = vi.fn();
|
||||||
|
const assignmentFindMany = vi.fn();
|
||||||
|
|
||||||
|
const caller = createProtectedCaller({
|
||||||
|
demandRequirement: {
|
||||||
|
findMany: demandFindMany,
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: assignmentFindMany,
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await caller.getMyEntriesView({
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-09T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.allocations).toEqual([]);
|
||||||
|
expect(result.demands).toEqual([]);
|
||||||
|
expect(result.assignments).toEqual([]);
|
||||||
|
expect(demandFindMany).not.toHaveBeenCalled();
|
||||||
|
expect(assignmentFindMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a detailed timeline entries view with holiday overlays and summary", async () => {
|
||||||
|
const caller = createAdminCaller({
|
||||||
|
demandRequirement: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "dem_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
resourceId: null,
|
||||||
|
role: "Artist",
|
||||||
|
hoursPerDay: 6,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-09T00:00:00.000Z"),
|
||||||
|
status: "OPEN",
|
||||||
|
metadata: null,
|
||||||
|
project: {
|
||||||
|
id: "project_1",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
shortCode: "GDM",
|
||||||
|
clientId: "client_1",
|
||||||
|
budgetCents: 100000,
|
||||||
|
winProbability: 100,
|
||||||
|
status: "ACTIVE",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
staffingReqs: null,
|
||||||
|
responsiblePerson: "Larissa",
|
||||||
|
color: "#fff",
|
||||||
|
orderType: "CHARGEABLE",
|
||||||
|
},
|
||||||
|
roleEntity: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "asg_by",
|
||||||
|
projectId: "project_1",
|
||||||
|
resourceId: "res_by",
|
||||||
|
role: "Artist",
|
||||||
|
hoursPerDay: 6,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-09T00:00:00.000Z"),
|
||||||
|
status: "CONFIRMED",
|
||||||
|
metadata: null,
|
||||||
|
resource: {
|
||||||
|
id: "res_by",
|
||||||
|
displayName: "Alice",
|
||||||
|
eid: "EMP-BY",
|
||||||
|
chapter: "Delivery",
|
||||||
|
lcrCents: 10000,
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
id: "project_1",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
shortCode: "GDM",
|
||||||
|
clientId: "client_1",
|
||||||
|
budgetCents: 100000,
|
||||||
|
winProbability: 100,
|
||||||
|
status: "ACTIVE",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
staffingReqs: null,
|
||||||
|
responsiblePerson: "Larissa",
|
||||||
|
color: "#fff",
|
||||||
|
orderType: "CHARGEABLE",
|
||||||
|
},
|
||||||
|
roleEntity: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "asg_hh",
|
||||||
|
projectId: "project_1",
|
||||||
|
resourceId: "res_hh",
|
||||||
|
role: "Artist",
|
||||||
|
hoursPerDay: 6,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-09T00:00:00.000Z"),
|
||||||
|
status: "CONFIRMED",
|
||||||
|
metadata: null,
|
||||||
|
resource: {
|
||||||
|
id: "res_hh",
|
||||||
|
displayName: "Bob",
|
||||||
|
eid: "EMP-HH",
|
||||||
|
chapter: "Delivery",
|
||||||
|
lcrCents: 10000,
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
id: "project_1",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
shortCode: "GDM",
|
||||||
|
clientId: "client_1",
|
||||||
|
budgetCents: 100000,
|
||||||
|
winProbability: 100,
|
||||||
|
status: "ACTIVE",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
staffingReqs: null,
|
||||||
|
responsiblePerson: "Larissa",
|
||||||
|
color: "#fff",
|
||||||
|
orderType: "CHARGEABLE",
|
||||||
|
},
|
||||||
|
roleEntity: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "res_by",
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: "city_munich",
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: { name: "Muenchen" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "res_hh",
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "HH",
|
||||||
|
metroCityId: "city_hamburg",
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: { name: "Hamburg" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await caller.getEntriesDetail({
|
||||||
|
startDate: "2026-01-05",
|
||||||
|
endDate: "2026-01-09",
|
||||||
|
projectIds: ["project_1"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.period).toEqual({
|
||||||
|
startDate: "2026-01-05",
|
||||||
|
endDate: "2026-01-09",
|
||||||
|
});
|
||||||
|
expect(result.summary).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
demandCount: 1,
|
||||||
|
assignmentCount: 2,
|
||||||
|
overlayCount: 1,
|
||||||
|
resourceCount: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.demands).toHaveLength(1);
|
||||||
|
expect(result.assignments).toHaveLength(2);
|
||||||
|
expect(result.holidayOverlays).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
resourceId: "res_by",
|
||||||
|
startDate: "2026-01-06",
|
||||||
|
note: "Heilige Drei Könige",
|
||||||
|
scope: "STATE",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns detailed project timeline context with overlap summaries", async () => {
|
||||||
|
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "asg_project",
|
||||||
|
projectId: "project_ctx",
|
||||||
|
resourceId: "res_1",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 6,
|
||||||
|
dailyCostCents: 0,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
project: { id: "project_ctx", name: "Gelddruckmaschine", shortCode: "GDM", status: "ACTIVE", orderType: "CHARGEABLE", dynamicFields: null },
|
||||||
|
resource: { id: "res_1", displayName: "Alice", chapter: "Delivery" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "asg_other",
|
||||||
|
projectId: "project_other",
|
||||||
|
resourceId: "res_1",
|
||||||
|
startDate: new Date("2026-01-08T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-10T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 4,
|
||||||
|
dailyCostCents: 0,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
project: { id: "project_other", name: "Other Project", shortCode: "OTH", status: "ACTIVE", orderType: "CHARGEABLE", dynamicFields: null },
|
||||||
|
resource: { id: "res_1", displayName: "Alice", chapter: "Delivery" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const project = {
|
||||||
|
id: "project_ctx",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
shortCode: "GDM",
|
||||||
|
orderType: "CHARGEABLE",
|
||||||
|
budgetCents: 100000,
|
||||||
|
winProbability: 100,
|
||||||
|
status: "ACTIVE",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
staffingReqs: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createAdminCaller({
|
||||||
|
project: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(project),
|
||||||
|
},
|
||||||
|
demandRequirement: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "dem_ctx",
|
||||||
|
projectId: "project_ctx",
|
||||||
|
resourceId: null,
|
||||||
|
role: "Artist",
|
||||||
|
hoursPerDay: 6,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
status: "OPEN",
|
||||||
|
metadata: null,
|
||||||
|
project,
|
||||||
|
roleEntity: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "asg_project",
|
||||||
|
projectId: "project_ctx",
|
||||||
|
resourceId: "res_1",
|
||||||
|
role: "Artist",
|
||||||
|
hoursPerDay: 6,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
status: "CONFIRMED",
|
||||||
|
metadata: null,
|
||||||
|
resource: {
|
||||||
|
id: "res_1",
|
||||||
|
displayName: "Alice",
|
||||||
|
eid: "EMP-1",
|
||||||
|
chapter: "Delivery",
|
||||||
|
lcrCents: 10000,
|
||||||
|
},
|
||||||
|
project,
|
||||||
|
roleEntity: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "res_1",
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: "city_munich",
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: { name: "Muenchen" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await caller.getProjectContextDetail({
|
||||||
|
projectId: "project_ctx",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.project).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "project_ctx",
|
||||||
|
shortCode: "GDM",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.summary).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
demandCount: 1,
|
||||||
|
assignmentCount: 1,
|
||||||
|
conflictedAssignmentCount: 1,
|
||||||
|
overlayCount: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.assignmentConflicts).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
assignmentId: "asg_project",
|
||||||
|
crossProjectOverlapCount: 1,
|
||||||
|
overlaps: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
projectShortCode: "OTH",
|
||||||
|
sameProject: false,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(result.holidayOverlays).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
startDate: "2026-01-06",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns detailed project shift preview metadata and validation", async () => {
|
||||||
|
const projectFindUnique = vi.fn().mockImplementation((args: { select?: Record<string, unknown> }) => {
|
||||||
|
if (args.select && "budgetCents" in args.select) {
|
||||||
|
return Promise.resolve({
|
||||||
|
id: "project_shift",
|
||||||
|
budgetCents: 100000,
|
||||||
|
winProbability: 100,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
id: "project_shift",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
shortCode: "GDM",
|
||||||
|
status: "ACTIVE",
|
||||||
|
responsiblePerson: "Larissa",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const caller = createAdminCaller({
|
||||||
|
project: {
|
||||||
|
findUnique: projectFindUnique,
|
||||||
|
},
|
||||||
|
demandRequirement: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await caller.getShiftPreviewDetail({
|
||||||
|
projectId: "project_shift",
|
||||||
|
newStartDate: new Date("2026-01-19T00:00:00.000Z"),
|
||||||
|
newEndDate: new Date("2026-01-30T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.project).toEqual({
|
||||||
|
id: "project_shift",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
shortCode: "GDM",
|
||||||
|
status: "ACTIVE",
|
||||||
|
responsiblePerson: "Larissa",
|
||||||
|
startDate: "2026-01-05",
|
||||||
|
endDate: "2026-01-16",
|
||||||
|
});
|
||||||
|
expect(result.requestedShift).toEqual({
|
||||||
|
newStartDate: "2026-01-19",
|
||||||
|
newEndDate: "2026-01-30",
|
||||||
|
});
|
||||||
|
expect(result.preview).toEqual({
|
||||||
|
valid: true,
|
||||||
|
errors: [],
|
||||||
|
warnings: [],
|
||||||
|
conflictDetails: [],
|
||||||
|
costImpact: {
|
||||||
|
currentTotalCents: 0,
|
||||||
|
newTotalCents: 0,
|
||||||
|
deltaCents: 0,
|
||||||
|
budgetCents: 100000,
|
||||||
|
budgetUtilizationBefore: 0,
|
||||||
|
budgetUtilizationAfter: 0,
|
||||||
|
wouldExceedBudget: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks USER role from broad timeline detail reads", async () => {
|
||||||
|
const db = {
|
||||||
|
demandRequirement: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
await expect(
|
||||||
|
caller.getEntriesDetail({
|
||||||
|
startDate: "2026-01-05",
|
||||||
|
endDate: "2026-01-09",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks USER role from project timeline context reads", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
demandRequirement: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
await expect(
|
||||||
|
caller.getProjectContextDetail({ projectId: "project_ctx" }),
|
||||||
|
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { SystemRole } from "@capakraken/shared";
|
import { SystemRole } from "@capakraken/shared";
|
||||||
import { VacationStatus, VacationType } from "@capakraken/db";
|
import { VacationStatus, VacationType } from "@capakraken/db";
|
||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { createNotification } from "../lib/create-notification.js";
|
||||||
|
import { logger } from "../lib/logger.js";
|
||||||
|
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
||||||
import { vacationRouter } from "../router/vacation.js";
|
import { vacationRouter } from "../router/vacation.js";
|
||||||
import { createCallerFactory } from "../trpc.js";
|
import { createCallerFactory } from "../trpc.js";
|
||||||
|
|
||||||
@@ -33,6 +36,15 @@ vi.mock("../lib/audit.js", () => ({
|
|||||||
createAuditEntry: vi.fn().mockResolvedValue(undefined),
|
createAuditEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/logger.js", () => ({
|
||||||
|
logger: {
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const createCaller = createCallerFactory(vacationRouter);
|
const createCaller = createCallerFactory(vacationRouter);
|
||||||
|
|
||||||
function createProtectedCaller(db: Record<string, unknown>) {
|
function createProtectedCaller(db: Record<string, unknown>) {
|
||||||
@@ -163,6 +175,9 @@ describe("vacation router", () => {
|
|||||||
describe("list", () => {
|
describe("list", () => {
|
||||||
it("returns vacations with default filters", async () => {
|
it("returns vacations with default filters", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||||
|
},
|
||||||
vacation: {
|
vacation: {
|
||||||
findMany: vi.fn().mockResolvedValue([sampleVacation]),
|
findMany: vi.fn().mockResolvedValue([sampleVacation]),
|
||||||
},
|
},
|
||||||
@@ -183,6 +198,9 @@ describe("vacation router", () => {
|
|||||||
|
|
||||||
it("applies resourceId filter", async () => {
|
it("applies resourceId filter", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||||
|
},
|
||||||
vacation: {
|
vacation: {
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
@@ -198,8 +216,48 @@ describe("vacation router", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("scopes regular users to their own resource when no filter is provided", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
await caller.list({});
|
||||||
|
|
||||||
|
expect(db.vacation.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({ resourceId: "res_own" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forbids regular users from listing another resource's vacations", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
await expect(caller.list({ resourceId: "res_other" })).rejects.toThrow(
|
||||||
|
"You can only view vacation data for your own resource",
|
||||||
|
);
|
||||||
|
expect(db.vacation.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("applies status and type filters", async () => {
|
it("applies status and type filters", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||||
|
},
|
||||||
vacation: {
|
vacation: {
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
@@ -229,11 +287,110 @@ describe("vacation router", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("background side effects", () => {
|
||||||
|
it("logs and swallows async notification failures during approval", async () => {
|
||||||
|
vi.mocked(createNotification).mockRejectedValueOnce(new Error("notification down"));
|
||||||
|
|
||||||
|
const db = createVacationDb({
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
|
||||||
|
const select = args?.select ?? {};
|
||||||
|
return {
|
||||||
|
...(select.displayName ? { displayName: "Alice" } : {}),
|
||||||
|
...(select.user
|
||||||
|
? { user: { id: "user_1", email: "user@example.com", name: "User" } }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
...sampleVacation,
|
||||||
|
status: VacationStatus.PENDING,
|
||||||
|
}),
|
||||||
|
update: vi.fn().mockResolvedValue({
|
||||||
|
...sampleVacation,
|
||||||
|
status: VacationStatus.APPROVED,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const caller = createManagerCaller(db);
|
||||||
|
const result = await caller.approve({ id: "vac_1" });
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(result.status).toBe(VacationStatus.APPROVED);
|
||||||
|
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
effectName: "notifyVacationStatus",
|
||||||
|
vacationId: "vac_1",
|
||||||
|
resourceId: "res_1",
|
||||||
|
newStatus: VacationStatus.APPROVED,
|
||||||
|
}),
|
||||||
|
"Vacation background side effect failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and swallows webhook failures during approval", async () => {
|
||||||
|
vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook down"));
|
||||||
|
|
||||||
|
const db = createVacationDb({
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
|
||||||
|
const select = args?.select ?? {};
|
||||||
|
return {
|
||||||
|
...(select.displayName ? { displayName: "Alice" } : {}),
|
||||||
|
...(select.user
|
||||||
|
? { user: { id: "user_1", email: "user@example.com", name: "User" } }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
...sampleVacation,
|
||||||
|
status: VacationStatus.PENDING,
|
||||||
|
}),
|
||||||
|
update: vi.fn().mockResolvedValue({
|
||||||
|
...sampleVacation,
|
||||||
|
status: VacationStatus.APPROVED,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const caller = createManagerCaller(db);
|
||||||
|
const result = await caller.approve({ id: "vac_1" });
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(result.status).toBe(VacationStatus.APPROVED);
|
||||||
|
expect(vi.mocked(logger.error)).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ effectName: "dispatchWebhooks", event: "vacation.approved" }),
|
||||||
|
"Vacation background side effect failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getById", () => {
|
describe("getById", () => {
|
||||||
it("returns vacation by id", async () => {
|
it("returns vacation by id", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
vacation: {
|
vacation: {
|
||||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
...sampleVacation,
|
||||||
|
resource: { ...sampleVacation.resource, userId: "user_1" },
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -248,6 +405,23 @@ describe("vacation router", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forbids regular users from reading another user's vacation", async () => {
|
||||||
|
const db = {
|
||||||
|
vacation: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
...sampleVacation,
|
||||||
|
requestedById: "someone_else",
|
||||||
|
resource: { ...sampleVacation.resource, userId: "someone_else" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
await expect(caller.getById({ id: "vac_1" })).rejects.toThrow(
|
||||||
|
"You can only view your own vacation data",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("throws NOT_FOUND for missing vacation", async () => {
|
it("throws NOT_FOUND for missing vacation", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
vacation: {
|
vacation: {
|
||||||
@@ -890,6 +1064,9 @@ describe("vacation router", () => {
|
|||||||
describe("getForResource", () => {
|
describe("getForResource", () => {
|
||||||
it("returns approved vacations in date range", async () => {
|
it("returns approved vacations in date range", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||||
|
},
|
||||||
vacation: {
|
vacation: {
|
||||||
findMany: vi.fn().mockResolvedValue([
|
findMany: vi.fn().mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -920,6 +1097,27 @@ describe("vacation router", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forbids regular users from reading another resource's approved vacations", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
await expect(
|
||||||
|
caller.getForResource({
|
||||||
|
resourceId: "res_other",
|
||||||
|
startDate: new Date("2026-01-01"),
|
||||||
|
endDate: new Date("2026-12-31"),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("You can only view vacation data for your own resource");
|
||||||
|
expect(db.vacation.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getPendingApprovals", () => {
|
describe("getPendingApprovals", () => {
|
||||||
@@ -952,6 +1150,7 @@ describe("vacation router", () => {
|
|||||||
it("returns overlapping vacations for the same chapter", async () => {
|
it("returns overlapping vacations for the same chapter", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||||
findUnique: vi.fn().mockResolvedValue({ chapter: "Animation" }),
|
findUnique: vi.fn().mockResolvedValue({ chapter: "Animation" }),
|
||||||
},
|
},
|
||||||
vacation: {
|
vacation: {
|
||||||
@@ -987,6 +1186,7 @@ describe("vacation router", () => {
|
|||||||
it("returns empty array when resource has no chapter", async () => {
|
it("returns empty array when resource has no chapter", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||||
findUnique: vi.fn().mockResolvedValue({ chapter: null }),
|
findUnique: vi.fn().mockResolvedValue({ chapter: null }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1000,6 +1200,76 @@ describe("vacation router", () => {
|
|||||||
|
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forbids regular users from reading another resource's team overlap", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_own" }),
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
await expect(
|
||||||
|
caller.getTeamOverlap({
|
||||||
|
resourceId: "res_other",
|
||||||
|
startDate: new Date("2026-06-01"),
|
||||||
|
endDate: new Date("2026-06-05"),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("You can only view vacation data for your own resource");
|
||||||
|
expect(db.resource.findUnique).not.toHaveBeenCalled();
|
||||||
|
expect(db.vacation.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTeamOverlapDetail", () => {
|
||||||
|
it("returns assistant-friendly overlap detail from the canonical overlap query", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "res_1" }),
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ displayName: "Bruce Banner", chapter: "CGI" }),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
...sampleVacation,
|
||||||
|
id: "vac_other",
|
||||||
|
resourceId: "res_2",
|
||||||
|
status: VacationStatus.APPROVED,
|
||||||
|
resource: { id: "res_2", displayName: "Clark Kent", eid: "E-002" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.getTeamOverlapDetail({
|
||||||
|
resourceId: "res_1",
|
||||||
|
startDate: new Date("2026-08-10T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-08-12T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
resource: "Bruce Banner",
|
||||||
|
chapter: "CGI",
|
||||||
|
period: "2026-08-10 to 2026-08-12",
|
||||||
|
overlapCount: 1,
|
||||||
|
overlappingVacations: [
|
||||||
|
{
|
||||||
|
resource: "Clark Kent",
|
||||||
|
type: VacationType.ANNUAL,
|
||||||
|
status: VacationStatus.APPROVED,
|
||||||
|
start: "2026-06-01",
|
||||||
|
end: "2026-06-05",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("batchCreatePublicHolidays", () => {
|
describe("batchCreatePublicHolidays", () => {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export const ROLE_BRIEF_SELECT = { id: true, name: true, color: true } as const;
|
export const ROLE_BRIEF_SELECT = { id: true, name: true, color: true } as const;
|
||||||
export const PROJECT_BRIEF_SELECT = { id: true, name: true, shortCode: true, status: true, endDate: true } as const;
|
export const PROJECT_BRIEF_SELECT = { id: true, name: true, shortCode: true, status: true, endDate: true } as const;
|
||||||
export const RESOURCE_BRIEF_SELECT = { id: true, displayName: true, eid: true, lcrCents: true } as const;
|
export const RESOURCE_BRIEF_SELECT = { id: true, displayName: true, eid: true, lcrCents: true, chapter: true } as const;
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ export function generateSummary(
|
|||||||
export async function createAuditEntry(params: CreateAuditEntryParams): Promise<void> {
|
export async function createAuditEntry(params: CreateAuditEntryParams): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { db, entityType, entityId, entityName, action, userId, before, after, source, metadata } = params;
|
const { db, entityType, entityId, entityName, action, userId, before, after, source, metadata } = params;
|
||||||
|
const auditLog = (db as Partial<PrismaClient>).auditLog;
|
||||||
|
|
||||||
|
if (!auditLog || typeof auditLog.create !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Compute diff if both snapshots are available
|
// Compute diff if both snapshots are available
|
||||||
const diff = before && after ? computeDiff(before, after) : undefined;
|
const diff = before && after ? computeDiff(before, after) : undefined;
|
||||||
@@ -111,7 +116,7 @@ export async function createAuditEntry(params: CreateAuditEntryParams): Promise<
|
|||||||
if (diff) changes.diff = diff;
|
if (diff) changes.diff = diff;
|
||||||
if (metadata) changes.metadata = metadata;
|
if (metadata) changes.metadata = metadata;
|
||||||
|
|
||||||
await db.auditLog.create({
|
await auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
entityType,
|
entityType,
|
||||||
entityId,
|
entityId,
|
||||||
|
|||||||
@@ -15,12 +15,17 @@ interface RateLimitResult {
|
|||||||
resetAt: Date;
|
resetAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RateLimiter {
|
||||||
|
(key: string): RateLimitResult;
|
||||||
|
reset(): void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a sliding-window rate limiter.
|
* Creates a sliding-window rate limiter.
|
||||||
* @param windowMs - Time window in milliseconds
|
* @param windowMs - Time window in milliseconds
|
||||||
* @param maxRequests - Maximum requests allowed within the window
|
* @param maxRequests - Maximum requests allowed within the window
|
||||||
*/
|
*/
|
||||||
export function createRateLimiter(windowMs: number, maxRequests: number) {
|
export function createRateLimiter(windowMs: number, maxRequests: number): RateLimiter {
|
||||||
const store = new Map<string, RateLimitEntry>();
|
const store = new Map<string, RateLimitEntry>();
|
||||||
|
|
||||||
// Periodically clean up expired entries to prevent memory leaks
|
// Periodically clean up expired entries to prevent memory leaks
|
||||||
@@ -38,7 +43,7 @@ export function createRateLimiter(windowMs: number, maxRequests: number) {
|
|||||||
cleanupInterval.unref();
|
cleanupInterval.unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
return function check(key: string): RateLimitResult {
|
const check = function check(key: string): RateLimitResult {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const existing = store.get(key);
|
const existing = store.get(key);
|
||||||
|
|
||||||
@@ -61,7 +66,13 @@ export function createRateLimiter(windowMs: number, maxRequests: number) {
|
|||||||
remaining: Math.max(0, maxRequests - existing.count),
|
remaining: Math.max(0, maxRequests - existing.count),
|
||||||
resetAt: new Date(existing.resetAt),
|
resetAt: new Date(existing.resetAt),
|
||||||
};
|
};
|
||||||
|
} as RateLimiter;
|
||||||
|
|
||||||
|
check.reset = () => {
|
||||||
|
store.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return check;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** General API rate limiter: 100 requests per 15 minutes per key */
|
/** General API rate limiter: 100 requests per 15 minutes per key */
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+3322
-5125
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { AssistantApprovalStatus, type PrismaClient } from "@capakraken/db";
|
import { AssistantApprovalStatus, Prisma, type PrismaClient } from "@capakraken/db";
|
||||||
import { PermissionKey, resolvePermissions, type PermissionOverrides, SystemRole } from "@capakraken/shared";
|
import { PermissionKey, resolvePermissions, type PermissionOverrides, SystemRole } from "@capakraken/shared";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||||
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
|
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
|
||||||
@@ -19,11 +19,92 @@ import { logger } from "../lib/logger.js";
|
|||||||
const MAX_TOOL_ITERATIONS = 8;
|
const MAX_TOOL_ITERATIONS = 8;
|
||||||
const PENDING_APPROVAL_TTL_MS = 15 * 60 * 1000;
|
const PENDING_APPROVAL_TTL_MS = 15 * 60 * 1000;
|
||||||
export const ASSISTANT_CONFIRMATION_PREFIX = "CONFIRMATION_REQUIRED:";
|
export const ASSISTANT_CONFIRMATION_PREFIX = "CONFIRMATION_REQUIRED:";
|
||||||
|
const ASSISTANT_APPROVALS_TABLE_NAME = "public.assistant_approvals";
|
||||||
|
const MAX_OPENAI_TOOL_DEFINITIONS = 128;
|
||||||
|
|
||||||
|
const ALWAYS_INCLUDED_TOOL_NAMES = new Set([
|
||||||
|
"get_current_user",
|
||||||
|
"search_resources",
|
||||||
|
"get_resource",
|
||||||
|
"search_projects",
|
||||||
|
"get_project",
|
||||||
|
"list_allocations",
|
||||||
|
"get_statistics",
|
||||||
|
"navigate_to_page",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const MUTATION_INTENT_KEYWORDS = [
|
||||||
|
"create", "add", "new", "update", "change", "edit", "delete", "remove", "cancel", "approve", "reject",
|
||||||
|
"anlegen", "erstellen", "neu", "aendern", "ändern", "bearbeiten", "loeschen", "löschen", "entfernen",
|
||||||
|
"stornieren", "genehmigen", "ablehnen", "setzen",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOOL_SELECTION_HINTS = [
|
||||||
|
{
|
||||||
|
keywords: ["holiday", "holidays", "feiertag", "feiertage", "vacation", "vacations", "urlaub", "ferien", "abwesen"],
|
||||||
|
nameFragments: ["holiday", "vacation", "entitlement"],
|
||||||
|
exactTools: ["list_holidays_by_region", "get_resource_holidays", "list_holiday_calendars", "get_holiday_calendar", "preview_resolved_holiday_calendar"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ["resource", "resources", "ressource", "ressourcen", "employee", "mitarbeiter", "person", "people", "team", "chapter", "skill", "skills"],
|
||||||
|
nameFragments: ["resource", "skill", "role", "user", "staffing", "capacity"],
|
||||||
|
exactTools: ["search_resources", "get_resource", "search_by_skill", "check_resource_availability", "get_staffing_suggestions", "find_capacity"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ["capacity", "availability", "available", "kapazitaet", "kapazität", "verfuegbar", "verfügbar", "auslastung", "chargeability", "sah", "lcr"],
|
||||||
|
nameFragments: ["capacity", "availability", "chargeability", "staffing", "rate", "budget"],
|
||||||
|
exactTools: ["check_resource_availability", "get_staffing_suggestions", "find_capacity", "get_chargeability", "find_best_project_resource", "resolve_rate"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ["project", "projects", "projekt", "projekte", "allocation", "allocations", "allokation", "allokationen", "assignment", "assignments", "demand", "demands", "timeline"],
|
||||||
|
nameFragments: ["project", "allocation", "demand", "timeline", "assignment", "blueprint"],
|
||||||
|
exactTools: ["search_projects", "get_project", "list_allocations", "list_demands", "get_timeline_entries_view", "get_project_timeline_context"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ["dashboard", "widget", "widgets", "peak", "forecast", "insight", "insights", "anomaly", "anomalies", "report", "reports", "analyse", "analysis", "bericht"],
|
||||||
|
nameFragments: ["dashboard", "statistics", "report", "insight", "anomal", "health", "forecast", "skill"],
|
||||||
|
exactTools: ["get_statistics", "get_dashboard_detail", "detect_anomalies", "get_skill_gaps", "get_project_health", "get_budget_forecast", "get_insights_summary", "run_report"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ["estimate", "estimates", "angebot", "angebote", "budget", "budgets", "cost", "costs", "kosten", "rate", "rates", "preis", "preise"],
|
||||||
|
nameFragments: ["estimate", "budget", "rate", "cost"],
|
||||||
|
exactTools: ["get_budget_status", "list_rate_cards", "resolve_rate", "lookup_rate", "search_estimates", "get_estimate_detail"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ["notification", "notifications", "benachrichtigung", "benachrichtigungen", "task", "tasks", "aufgabe", "aufgaben", "reminder", "reminders", "broadcast"],
|
||||||
|
nameFragments: ["notification", "task", "reminder", "broadcast"],
|
||||||
|
exactTools: ["list_notifications", "get_unread_notification_count", "list_tasks", "get_task_counts", "list_reminders", "get_broadcast_detail"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ["country", "countries", "land", "laender", "länder", "city", "cities", "stadt", "staedte", "städte", "region", "regions", "state", "bundesland"],
|
||||||
|
nameFragments: ["country", "metro_city", "holiday_calendar"],
|
||||||
|
exactTools: ["list_countries", "get_country", "list_holidays_by_region", "list_holiday_calendars"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ["user", "users", "permission", "permissions", "rolle", "rollen", "admin", "system", "webhook", "import", "audit", "history", "rechte"],
|
||||||
|
nameFragments: ["user", "permission", "role", "system", "webhook", "import", "audit", "history", "org_unit", "country"],
|
||||||
|
exactTools: ["list_users", "get_effective_user_permissions", "list_audit_log_entries", "query_change_history", "get_system_settings", "list_webhooks"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOOL_SELECTION_STOP_WORDS = new Set([
|
||||||
|
"the", "and", "for", "with", "from", "that", "this", "what", "when", "where", "who", "how",
|
||||||
|
"und", "der", "die", "das", "ein", "eine", "einer", "einem", "einen", "mit", "von", "fuer", "für",
|
||||||
|
"auf", "ist", "sind", "im", "in", "am", "an", "zu", "zum", "zur", "mir", "bitte", "can", "you",
|
||||||
|
"mir", "alle", "all", "den", "dem", "des",
|
||||||
|
]);
|
||||||
|
|
||||||
type ChatMessage = { role: "user" | "assistant"; content: string };
|
type ChatMessage = { role: "user" | "assistant"; content: string };
|
||||||
|
|
||||||
type AssistantApprovalStore = Pick<PrismaClient, "assistantApproval">;
|
type AssistantApprovalStore = Pick<PrismaClient, "assistantApproval">;
|
||||||
|
|
||||||
|
class AssistantApprovalStorageUnavailableError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Assistant approval storage is unavailable.");
|
||||||
|
this.name = "AssistantApprovalStorageUnavailableError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface PendingAssistantApproval {
|
export interface PendingAssistantApproval {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -83,29 +164,32 @@ Datenmodell:
|
|||||||
- Projekte: ShortCode, Budget (Cent), Win-Probability, Status (DRAFT/ACTIVE/ON_HOLD/COMPLETED/CANCELLED)
|
- Projekte: ShortCode, Budget (Cent), Win-Probability, Status (DRAFT/ACTIVE/ON_HOLD/COMPLETED/CANCELLED)
|
||||||
- Allokationen (Assignments): resourceId + projectId, hoursPerDay, dailyCostCents, Zeitraum, Status (PROPOSED/CONFIRMED/ACTIVE/COMPLETED/CANCELLED)
|
- Allokationen (Assignments): resourceId + projectId, hoursPerDay, dailyCostCents, Zeitraum, Status (PROPOSED/CONFIRMED/ACTIVE/COMPLETED/CANCELLED)
|
||||||
- Chargeability = gebuchte/verfügbare Stunden × 100%
|
- Chargeability = gebuchte/verfügbare Stunden × 100%
|
||||||
- Urlaub: Typen VACATION/SICK/PARENTAL/SPECIAL/PUBLIC_HOLIDAY, Status PENDING/APPROVED/REJECTED/CANCELLED
|
- Urlaub: Typen ANNUAL/SICK/OTHER/PUBLIC_HOLIDAY, Status PENDING/APPROVED/REJECTED/CANCELLED. PUBLIC_HOLIDAY wird nicht manuell beantragt, sondern über Feiertagskalender verwaltet.
|
||||||
- Feiertage: können je nach Land, Bundesland und Stadt unterschiedlich sein; nutze Feiertags-Tools statt zu raten
|
- Feiertage: können je nach Land, Bundesland und Stadt unterschiedlich sein; nutze Feiertags-Tools statt zu raten
|
||||||
`;
|
`;
|
||||||
|
|
||||||
/** Map tool names to the permission required to use them */
|
/** Map tool names to the permission required to use them */
|
||||||
const TOOL_PERMISSION_MAP: Record<string, string> = {
|
const TOOL_PERMISSION_MAP: Record<string, string> = {
|
||||||
list_users: PermissionKey.MANAGE_USERS,
|
|
||||||
// Resource management
|
// Resource management
|
||||||
update_resource: "manageResources",
|
update_resource: "manageResources",
|
||||||
create_resource: "manageResources",
|
create_resource: "manageResources",
|
||||||
deactivate_resource: "manageResources",
|
deactivate_resource: "manageResources",
|
||||||
create_role: "manageResources",
|
create_role: PermissionKey.MANAGE_ROLES,
|
||||||
update_role: "manageResources",
|
update_role: PermissionKey.MANAGE_ROLES,
|
||||||
delete_role: "manageResources",
|
delete_role: PermissionKey.MANAGE_ROLES,
|
||||||
create_org_unit: "manageResources",
|
|
||||||
update_org_unit: "manageResources",
|
|
||||||
// Project management
|
// Project management
|
||||||
update_project: "manageProjects",
|
update_project: "manageProjects",
|
||||||
create_project: "manageProjects",
|
create_project: "manageProjects",
|
||||||
delete_project: "manageProjects",
|
delete_project: "manageProjects",
|
||||||
create_client: "manageProjects",
|
|
||||||
update_client: "manageProjects",
|
|
||||||
create_estimate: "manageProjects",
|
create_estimate: "manageProjects",
|
||||||
|
clone_estimate: "manageProjects",
|
||||||
|
update_estimate_draft: "manageProjects",
|
||||||
|
submit_estimate_version: "manageProjects",
|
||||||
|
approve_estimate_version: "manageProjects",
|
||||||
|
create_estimate_revision: "manageProjects",
|
||||||
|
create_estimate_export: "manageProjects",
|
||||||
|
generate_estimate_weekly_phasing: "manageProjects",
|
||||||
|
update_estimate_commercial_terms: "manageProjects",
|
||||||
generate_project_cover: "manageProjects",
|
generate_project_cover: "manageProjects",
|
||||||
remove_project_cover: "manageProjects",
|
remove_project_cover: "manageProjects",
|
||||||
import_csv_data: PermissionKey.IMPORT_DATA,
|
import_csv_data: PermissionKey.IMPORT_DATA,
|
||||||
@@ -120,15 +204,9 @@ const TOOL_PERMISSION_MAP: Record<string, string> = {
|
|||||||
batch_shift_timeline_allocations: "manageAllocations",
|
batch_shift_timeline_allocations: "manageAllocations",
|
||||||
create_demand: "manageAllocations",
|
create_demand: "manageAllocations",
|
||||||
fill_demand: "manageAllocations",
|
fill_demand: "manageAllocations",
|
||||||
|
create_estimate_planning_handoff: "manageAllocations",
|
||||||
// Vacation management
|
// Vacation management
|
||||||
create_vacation: "manageVacations",
|
|
||||||
approve_vacation: "manageVacations",
|
|
||||||
reject_vacation: "manageVacations",
|
|
||||||
cancel_vacation: "manageVacations",
|
|
||||||
set_entitlement: "manageVacations",
|
|
||||||
// Task management
|
// Task management
|
||||||
create_task_for_user: "manageProjects",
|
|
||||||
send_broadcast: "manageProjects",
|
|
||||||
execute_task_action: "manageAllocations",
|
execute_task_action: "manageAllocations",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,6 +220,7 @@ const COST_TOOLS = new Set([
|
|||||||
"resolve_rate",
|
"resolve_rate",
|
||||||
"list_rate_cards",
|
"list_rate_cards",
|
||||||
"get_estimate_detail",
|
"get_estimate_detail",
|
||||||
|
"get_estimate_version_snapshot",
|
||||||
"find_best_project_resource",
|
"find_best_project_resource",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -158,22 +237,90 @@ const CONTROLLER_ONLY_TOOLS = new Set([
|
|||||||
"get_chargeability_report",
|
"get_chargeability_report",
|
||||||
"get_resource_computation_graph",
|
"get_resource_computation_graph",
|
||||||
"get_project_computation_graph",
|
"get_project_computation_graph",
|
||||||
|
"get_estimate_detail",
|
||||||
|
"list_estimate_versions",
|
||||||
|
"get_estimate_version_snapshot",
|
||||||
|
"get_estimate_weekly_phasing",
|
||||||
|
"get_estimate_commercial_terms",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Tools that follow managerProcedure access rules in the main API. */
|
/** Tools that follow managerProcedure access rules in the main API. */
|
||||||
const MANAGER_ONLY_TOOLS = new Set([
|
const MANAGER_ONLY_TOOLS = new Set([
|
||||||
"import_csv_data",
|
"import_csv_data",
|
||||||
|
"list_assignable_users",
|
||||||
|
"create_notification",
|
||||||
"update_timeline_allocation_inline",
|
"update_timeline_allocation_inline",
|
||||||
"apply_timeline_project_shift",
|
"apply_timeline_project_shift",
|
||||||
"quick_assign_timeline_resource",
|
"quick_assign_timeline_resource",
|
||||||
"batch_quick_assign_timeline_resources",
|
"batch_quick_assign_timeline_resources",
|
||||||
"batch_shift_timeline_allocations",
|
"batch_shift_timeline_allocations",
|
||||||
|
"create_estimate",
|
||||||
|
"clone_estimate",
|
||||||
|
"update_estimate_draft",
|
||||||
|
"submit_estimate_version",
|
||||||
|
"approve_estimate_version",
|
||||||
|
"create_estimate_revision",
|
||||||
|
"create_estimate_export",
|
||||||
|
"create_estimate_planning_handoff",
|
||||||
|
"generate_estimate_weekly_phasing",
|
||||||
|
"update_estimate_commercial_terms",
|
||||||
|
"create_task_for_user",
|
||||||
|
"assign_task",
|
||||||
|
"send_broadcast",
|
||||||
|
"list_broadcasts",
|
||||||
|
"get_broadcast_detail",
|
||||||
|
"approve_vacation",
|
||||||
|
"reject_vacation",
|
||||||
|
"get_pending_vacation_approvals",
|
||||||
|
"get_entitlement_summary",
|
||||||
|
"set_entitlement",
|
||||||
|
"create_role",
|
||||||
|
"update_role",
|
||||||
|
"delete_role",
|
||||||
|
"create_client",
|
||||||
|
"update_client",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Tools that are intentionally limited to ADMIN because the backing routers are admin-only today. */
|
/** Tools that are intentionally limited to ADMIN because the backing routers are admin-only today. */
|
||||||
const ADMIN_ONLY_TOOLS = new Set([
|
const ADMIN_ONLY_TOOLS = new Set([
|
||||||
|
"list_users",
|
||||||
|
"get_active_user_count",
|
||||||
|
"create_user",
|
||||||
|
"set_user_password",
|
||||||
|
"update_user_role",
|
||||||
|
"update_user_name",
|
||||||
|
"link_user_resource",
|
||||||
|
"auto_link_users_by_email",
|
||||||
|
"set_user_permissions",
|
||||||
|
"reset_user_permissions",
|
||||||
|
"get_effective_user_permissions",
|
||||||
|
"disable_user_totp",
|
||||||
"list_dispo_import_batches",
|
"list_dispo_import_batches",
|
||||||
"get_dispo_import_batch",
|
"get_dispo_import_batch",
|
||||||
|
"stage_dispo_import_batch",
|
||||||
|
"validate_dispo_import_batch",
|
||||||
|
"cancel_dispo_import_batch",
|
||||||
|
"list_dispo_staged_resources",
|
||||||
|
"list_dispo_staged_projects",
|
||||||
|
"list_dispo_staged_assignments",
|
||||||
|
"list_dispo_staged_vacations",
|
||||||
|
"list_dispo_staged_unresolved_records",
|
||||||
|
"resolve_dispo_staged_record",
|
||||||
|
"commit_dispo_import_batch",
|
||||||
|
"get_system_settings",
|
||||||
|
"update_system_settings",
|
||||||
|
"test_ai_connection",
|
||||||
|
"test_smtp_connection",
|
||||||
|
"test_gemini_connection",
|
||||||
|
"update_system_role_config",
|
||||||
|
"list_webhooks",
|
||||||
|
"get_webhook",
|
||||||
|
"create_webhook",
|
||||||
|
"update_webhook",
|
||||||
|
"delete_webhook",
|
||||||
|
"test_webhook",
|
||||||
|
"create_org_unit",
|
||||||
|
"update_org_unit",
|
||||||
"create_country",
|
"create_country",
|
||||||
"update_country",
|
"update_country",
|
||||||
"create_metro_city",
|
"create_metro_city",
|
||||||
@@ -220,6 +367,96 @@ export function getAvailableAssistantTools(permissions: Set<PermissionKey>, user
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAssistantText(input: string): string {
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/\p{Diacritic}/gu, " ")
|
||||||
|
.replace(/[^a-z0-9_]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeAssistantIntent(input: string): string[] {
|
||||||
|
return normalizeAssistantText(input)
|
||||||
|
.split(" ")
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter((token) => token.length >= 3 && !TOOL_SELECTION_STOP_WORDS.has(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectAssistantToolsForRequest(
|
||||||
|
availableTools: typeof TOOL_DEFINITIONS,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
pageContext?: string,
|
||||||
|
) {
|
||||||
|
if (availableTools.length <= MAX_OPENAI_TOOL_DEFINITIONS) {
|
||||||
|
return availableTools;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentUserText = messages
|
||||||
|
.filter((message) => message.role === "user")
|
||||||
|
.slice(-4)
|
||||||
|
.map((message) => message.content)
|
||||||
|
.join(" ");
|
||||||
|
const intentText = [recentUserText, pageContext ?? ""].filter(Boolean).join(" ");
|
||||||
|
const normalizedIntent = normalizeAssistantText(intentText);
|
||||||
|
const intentTokens = tokenizeAssistantIntent(intentText);
|
||||||
|
const mutationIntent = MUTATION_INTENT_KEYWORDS.some((keyword) => normalizedIntent.includes(normalizeAssistantText(keyword)));
|
||||||
|
|
||||||
|
const selectedHintTools = new Set<string>();
|
||||||
|
for (const hint of TOOL_SELECTION_HINTS) {
|
||||||
|
const matchedKeyword = hint.keywords.some((keyword) => normalizedIntent.includes(normalizeAssistantText(keyword)));
|
||||||
|
if (!matchedKeyword) continue;
|
||||||
|
for (const toolName of hint.exactTools) {
|
||||||
|
selectedHintTools.add(toolName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoredTools = availableTools
|
||||||
|
.map((tool, index) => {
|
||||||
|
const name = tool.function.name;
|
||||||
|
const normalizedName = normalizeAssistantText(name.replace(/_/g, " "));
|
||||||
|
const normalizedDescription = normalizeAssistantText(tool.function.description);
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
if (ALWAYS_INCLUDED_TOOL_NAMES.has(name)) score += 1000;
|
||||||
|
if (selectedHintTools.has(name)) score += 400;
|
||||||
|
|
||||||
|
for (const hint of TOOL_SELECTION_HINTS) {
|
||||||
|
const matchedKeyword = hint.keywords.some((keyword) => normalizedIntent.includes(normalizeAssistantText(keyword)));
|
||||||
|
if (!matchedKeyword) continue;
|
||||||
|
if (hint.exactTools.includes(name)) score += 160;
|
||||||
|
if (hint.nameFragments.some((fragment) => name.includes(fragment))) score += 120;
|
||||||
|
if (hint.nameFragments.some((fragment) => normalizedDescription.includes(normalizeAssistantText(fragment)))) score += 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const token of intentTokens) {
|
||||||
|
if (normalizedName.includes(token)) score += 45;
|
||||||
|
if (normalizedDescription.includes(token)) score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.startsWith("search_")) score += 18;
|
||||||
|
if (name.startsWith("get_")) score += 12;
|
||||||
|
if (name.startsWith("list_")) score += 10;
|
||||||
|
|
||||||
|
if (MUTATION_TOOLS.has(name)) {
|
||||||
|
score += mutationIntent ? 40 : -30;
|
||||||
|
} else {
|
||||||
|
score += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tool, index, score };
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (right.score !== left.score) return right.score - left.score;
|
||||||
|
return left.index - right.index;
|
||||||
|
});
|
||||||
|
|
||||||
|
return scoredTools
|
||||||
|
.slice(0, MAX_OPENAI_TOOL_DEFINITIONS)
|
||||||
|
.map((entry) => entry.tool);
|
||||||
|
}
|
||||||
|
|
||||||
function mergeInsights(existing: AssistantInsight[], next: AssistantInsight): AssistantInsight[] {
|
function mergeInsights(existing: AssistantInsight[], next: AssistantInsight): AssistantInsight[] {
|
||||||
const duplicateIndex = existing.findIndex((item) => item.kind === next.kind && item.title === next.title && item.subtitle === next.subtitle);
|
const duplicateIndex = existing.findIndex((item) => item.kind === next.kind && item.title === next.title && item.subtitle === next.subtitle);
|
||||||
if (duplicateIndex >= 0) {
|
if (duplicateIndex >= 0) {
|
||||||
@@ -307,31 +544,87 @@ function toApprovalPayload(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAssistantApprovalTableMissingError(error: unknown): boolean {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
if (error.code !== "P2021") return false;
|
||||||
|
const table = typeof error.meta?.table === "string" ? error.meta.table : "";
|
||||||
|
return table.includes("assistant_approvals") || error.message.includes("assistant_approvals");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error !== "object" || error === null || !("code" in error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = error as {
|
||||||
|
code?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
meta?: {
|
||||||
|
table?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const code = typeof candidate.code === "string" ? candidate.code : "";
|
||||||
|
if (code !== "P2021") return false;
|
||||||
|
|
||||||
|
const message = typeof candidate.message === "string"
|
||||||
|
? candidate.message
|
||||||
|
: "";
|
||||||
|
const metaTable = typeof candidate.meta?.table === "string"
|
||||||
|
? candidate.meta.table
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return metaTable.includes("assistant_approvals") || message.includes("assistant_approvals");
|
||||||
|
}
|
||||||
|
|
||||||
|
function logAssistantApprovalStorageUnavailable(error: unknown) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
err: error,
|
||||||
|
table: ASSISTANT_APPROVALS_TABLE_NAME,
|
||||||
|
},
|
||||||
|
"Assistant approval storage is unavailable",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withAssistantApprovalFallback<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
fallback: () => T,
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isAssistantApprovalTableMissingError(error)) throw error;
|
||||||
|
logAssistantApprovalStorageUnavailable(error);
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function listPendingAssistantApprovals(
|
export async function listPendingAssistantApprovals(
|
||||||
db: AssistantApprovalStore,
|
db: AssistantApprovalStore,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<PendingAssistantApproval[]> {
|
): Promise<PendingAssistantApproval[]> {
|
||||||
await db.assistantApproval.updateMany({
|
return withAssistantApprovalFallback(async () => {
|
||||||
where: {
|
await db.assistantApproval.updateMany({
|
||||||
userId,
|
where: {
|
||||||
status: AssistantApprovalStatus.PENDING,
|
userId,
|
||||||
expiresAt: { lte: new Date() },
|
status: AssistantApprovalStatus.PENDING,
|
||||||
},
|
expiresAt: { lte: new Date() },
|
||||||
data: {
|
},
|
||||||
status: AssistantApprovalStatus.EXPIRED,
|
data: {
|
||||||
},
|
status: AssistantApprovalStatus.EXPIRED,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const approvals = await db.assistantApproval.findMany({
|
const approvals = await db.assistantApproval.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
status: AssistantApprovalStatus.PENDING,
|
status: AssistantApprovalStatus.PENDING,
|
||||||
expiresAt: { gt: new Date() },
|
expiresAt: { gt: new Date() },
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return approvals.map(mapPendingApproval);
|
return approvals.map(mapPendingApproval);
|
||||||
|
}, () => []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearPendingAssistantApproval(
|
export async function clearPendingAssistantApproval(
|
||||||
@@ -339,17 +632,19 @@ export async function clearPendingAssistantApproval(
|
|||||||
userId: string,
|
userId: string,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await db.assistantApproval.updateMany({
|
await withAssistantApprovalFallback(async () => {
|
||||||
where: {
|
await db.assistantApproval.updateMany({
|
||||||
userId,
|
where: {
|
||||||
conversationId,
|
userId,
|
||||||
status: AssistantApprovalStatus.PENDING,
|
conversationId,
|
||||||
},
|
status: AssistantApprovalStatus.PENDING,
|
||||||
data: {
|
},
|
||||||
status: AssistantApprovalStatus.CANCELLED,
|
data: {
|
||||||
cancelledAt: new Date(),
|
status: AssistantApprovalStatus.CANCELLED,
|
||||||
},
|
cancelledAt: new Date(),
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
}, () => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function peekPendingAssistantApproval(
|
export async function peekPendingAssistantApproval(
|
||||||
@@ -357,28 +652,30 @@ export async function peekPendingAssistantApproval(
|
|||||||
userId: string,
|
userId: string,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
): Promise<PendingAssistantApproval | null> {
|
): Promise<PendingAssistantApproval | null> {
|
||||||
await db.assistantApproval.updateMany({
|
return withAssistantApprovalFallback(async () => {
|
||||||
where: {
|
await db.assistantApproval.updateMany({
|
||||||
userId,
|
where: {
|
||||||
conversationId,
|
userId,
|
||||||
status: AssistantApprovalStatus.PENDING,
|
conversationId,
|
||||||
expiresAt: { lte: new Date() },
|
status: AssistantApprovalStatus.PENDING,
|
||||||
},
|
expiresAt: { lte: new Date() },
|
||||||
data: {
|
},
|
||||||
status: AssistantApprovalStatus.EXPIRED,
|
data: {
|
||||||
},
|
status: AssistantApprovalStatus.EXPIRED,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const pending = await db.assistantApproval.findFirst({
|
const pending = await db.assistantApproval.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
conversationId,
|
conversationId,
|
||||||
status: AssistantApprovalStatus.PENDING,
|
status: AssistantApprovalStatus.PENDING,
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
if (!pending) return null;
|
if (!pending) return null;
|
||||||
return mapPendingApproval(pending);
|
return mapPendingApproval(pending);
|
||||||
|
}, () => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function consumePendingAssistantApproval(
|
export async function consumePendingAssistantApproval(
|
||||||
@@ -426,19 +723,25 @@ export async function createPendingAssistantApproval(
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const expiresAt = new Date(now.getTime() + (options?.ttlMs ?? PENDING_APPROVAL_TTL_MS));
|
const expiresAt = new Date(now.getTime() + (options?.ttlMs ?? PENDING_APPROVAL_TTL_MS));
|
||||||
const summary = options?.summary ?? buildApprovalSummary(toolName, toolArguments);
|
const summary = options?.summary ?? buildApprovalSummary(toolName, toolArguments);
|
||||||
await clearPendingAssistantApproval(db, userId, conversationId);
|
try {
|
||||||
const pendingApproval = await db.assistantApproval.create({
|
await clearPendingAssistantApproval(db, userId, conversationId);
|
||||||
data: {
|
const pendingApproval = await db.assistantApproval.create({
|
||||||
userId,
|
data: {
|
||||||
conversationId,
|
userId,
|
||||||
toolName,
|
conversationId,
|
||||||
toolArguments,
|
toolName,
|
||||||
summary,
|
toolArguments,
|
||||||
createdAt: now,
|
summary,
|
||||||
expiresAt,
|
createdAt: now,
|
||||||
},
|
expiresAt,
|
||||||
});
|
},
|
||||||
return mapPendingApproval(pendingApproval);
|
});
|
||||||
|
return mapPendingApproval(pendingApproval);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isAssistantApprovalTableMissingError(error)) throw error;
|
||||||
|
logAssistantApprovalStorageUnavailable(error);
|
||||||
|
throw new AssistantApprovalStorageUnavailableError();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAffirmativeConfirmationReply(content: string): boolean {
|
function isAffirmativeConfirmationReply(content: string): boolean {
|
||||||
@@ -669,7 +972,11 @@ export const assistantRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Filter tools based on granular permissions
|
// 4. Filter tools based on granular permissions
|
||||||
const availableTools = getAvailableAssistantTools(permissions, userRole);
|
const availableTools = selectAssistantToolsForRequest(
|
||||||
|
getAvailableAssistantTools(permissions, userRole),
|
||||||
|
input.messages,
|
||||||
|
input.pageContext,
|
||||||
|
);
|
||||||
|
|
||||||
// 5. Function calling loop
|
// 5. Function calling loop
|
||||||
const toolCtx: ToolContext = {
|
const toolCtx: ToolContext = {
|
||||||
@@ -799,13 +1106,26 @@ export const assistantRouter = createTRPCRouter({
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
for (const toolCall of msg.tool_calls as Array<{ id: string; function: { name: string; arguments: string } }>) {
|
for (const toolCall of msg.tool_calls as Array<{ id: string; function: { name: string; arguments: string } }>) {
|
||||||
if (MUTATION_TOOLS.has(toolCall.function.name)) {
|
if (MUTATION_TOOLS.has(toolCall.function.name)) {
|
||||||
const approval = await createPendingAssistantApproval(
|
let approval: PendingAssistantApproval;
|
||||||
ctx.db,
|
try {
|
||||||
userId,
|
approval = await createPendingAssistantApproval(
|
||||||
conversationId,
|
ctx.db,
|
||||||
toolCall.function.name,
|
userId,
|
||||||
toolCall.function.arguments,
|
conversationId,
|
||||||
);
|
toolCall.function.name,
|
||||||
|
toolCall.function.arguments,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof AssistantApprovalStorageUnavailableError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: "Schreibende Assistant-Aktionen sind gerade nicht verfuegbar, weil der Bestaetigungsspeicher in der Datenbank fehlt. Bitte die CapaKraken-DB-Migration anwenden und dann erneut versuchen.",
|
||||||
|
role: "assistant" as const,
|
||||||
|
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
|
||||||
|
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
void createAuditEntry({
|
void createAuditEntry({
|
||||||
db: ctx.db,
|
db: ctx.db,
|
||||||
|
|||||||
@@ -1,6 +1,235 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
||||||
|
|
||||||
|
type AuditUser = { id: string; name: string | null; email: string | null } | null | undefined;
|
||||||
|
|
||||||
|
type AuditEntryShape = {
|
||||||
|
id: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
entityName?: string | null;
|
||||||
|
action: string;
|
||||||
|
userId?: string | null;
|
||||||
|
source?: string | null;
|
||||||
|
summary?: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
user?: AuditUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuditDetailEntryShape = AuditEntryShape & {
|
||||||
|
changes?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatAuditListEntry(entry: AuditEntryShape) {
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
entityType: entry.entityType,
|
||||||
|
entityId: entry.entityId,
|
||||||
|
entityName: entry.entityName ?? null,
|
||||||
|
action: entry.action,
|
||||||
|
userId: entry.userId ?? null,
|
||||||
|
source: entry.source ?? null,
|
||||||
|
summary: entry.summary ?? null,
|
||||||
|
createdAt: entry.createdAt.toISOString(),
|
||||||
|
user: entry.user
|
||||||
|
? {
|
||||||
|
id: entry.user.id,
|
||||||
|
name: entry.user.name,
|
||||||
|
email: entry.user.email,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAuditDetailEntry(entry: AuditDetailEntryShape) {
|
||||||
|
return {
|
||||||
|
...formatAuditListEntry(entry),
|
||||||
|
changes: entry.changes ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditListInput = {
|
||||||
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
userId?: string;
|
||||||
|
action?: string;
|
||||||
|
source?: string;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
search?: string;
|
||||||
|
limit: number;
|
||||||
|
cursor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuditTimelineInput = {
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toAuditListInput(input: {
|
||||||
|
entityType?: string | undefined;
|
||||||
|
entityId?: string | undefined;
|
||||||
|
userId?: string | undefined;
|
||||||
|
action?: string | undefined;
|
||||||
|
source?: string | undefined;
|
||||||
|
startDate?: Date | undefined;
|
||||||
|
endDate?: Date | undefined;
|
||||||
|
search?: string | undefined;
|
||||||
|
limit: number;
|
||||||
|
cursor?: string | undefined;
|
||||||
|
}): AuditListInput {
|
||||||
|
return {
|
||||||
|
limit: input.limit,
|
||||||
|
...(input.entityType !== undefined ? { entityType: input.entityType } : {}),
|
||||||
|
...(input.entityId !== undefined ? { entityId: input.entityId } : {}),
|
||||||
|
...(input.userId !== undefined ? { userId: input.userId } : {}),
|
||||||
|
...(input.action !== undefined ? { action: input.action } : {}),
|
||||||
|
...(input.source !== undefined ? { source: input.source } : {}),
|
||||||
|
...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
|
||||||
|
...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
|
||||||
|
...(input.search !== undefined ? { search: input.search } : {}),
|
||||||
|
...(input.cursor !== undefined ? { cursor: input.cursor } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAuditTimelineInput(input: {
|
||||||
|
startDate?: Date | undefined;
|
||||||
|
endDate?: Date | undefined;
|
||||||
|
limit: number;
|
||||||
|
}): AuditTimelineInput {
|
||||||
|
return {
|
||||||
|
limit: input.limit,
|
||||||
|
...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
|
||||||
|
...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuditListWhere(input: Omit<AuditListInput, "limit" | "cursor">) {
|
||||||
|
const { entityType, entityId, userId, action, source, startDate, endDate, search } = input;
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (entityType) where.entityType = entityType;
|
||||||
|
if (entityId) where.entityId = entityId;
|
||||||
|
if (userId) where.userId = userId;
|
||||||
|
if (action) where.action = action;
|
||||||
|
if (source) where.source = source;
|
||||||
|
|
||||||
|
if (startDate || endDate) {
|
||||||
|
const createdAt: Record<string, Date> = {};
|
||||||
|
if (startDate) createdAt.gte = startDate;
|
||||||
|
if (endDate) createdAt.lte = endDate;
|
||||||
|
where.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ entityName: { contains: search, mode: "insensitive" } },
|
||||||
|
{ summary: { contains: search, mode: "insensitive" } },
|
||||||
|
{ entityType: { contains: search, mode: "insensitive" } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!startDate && !endDate && !entityId) {
|
||||||
|
const thirtyDaysAgo = new Date();
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
where.createdAt = { ...(where.createdAt as Record<string, Date> ?? {}), gte: thirtyDaysAgo };
|
||||||
|
}
|
||||||
|
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAuditEntries(
|
||||||
|
db: { auditLog: { findMany: Function } },
|
||||||
|
input: AuditListInput,
|
||||||
|
) {
|
||||||
|
const items = await db.auditLog.findMany({
|
||||||
|
where: buildAuditListWhere(input),
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
entityType: true,
|
||||||
|
entityId: true,
|
||||||
|
entityName: true,
|
||||||
|
action: true,
|
||||||
|
userId: true,
|
||||||
|
source: true,
|
||||||
|
summary: true,
|
||||||
|
createdAt: true,
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: input.limit + 1,
|
||||||
|
...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextCursor: string | undefined;
|
||||||
|
if (items.length > input.limit) {
|
||||||
|
const next = items.pop();
|
||||||
|
nextCursor = next?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items, nextCursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuditEntryById(
|
||||||
|
db: { auditLog: { findUniqueOrThrow: Function } },
|
||||||
|
id: string,
|
||||||
|
) {
|
||||||
|
return db.auditLog.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuditEntriesByEntity(
|
||||||
|
db: { auditLog: { findMany: Function } },
|
||||||
|
input: { entityType: string; entityId: string; limit: number },
|
||||||
|
) {
|
||||||
|
return db.auditLog.findMany({
|
||||||
|
where: {
|
||||||
|
entityType: input.entityType,
|
||||||
|
entityId: input.entityId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: input.limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuditTimeline(
|
||||||
|
db: { auditLog: { findMany: Function } },
|
||||||
|
input: AuditTimelineInput,
|
||||||
|
) {
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (input.startDate || input.endDate) {
|
||||||
|
const createdAt: Record<string, Date> = {};
|
||||||
|
if (input.startDate) createdAt.gte = input.startDate;
|
||||||
|
if (input.endDate) createdAt.lte = input.endDate;
|
||||||
|
where.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await db.auditLog.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: input.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const grouped: Record<string, typeof entries> = {};
|
||||||
|
for (const entry of entries) {
|
||||||
|
const dateKey = entry.createdAt.toISOString().slice(0, 10);
|
||||||
|
if (!grouped[dateKey]) grouped[dateKey] = [];
|
||||||
|
grouped[dateKey].push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const auditLogRouter = createTRPCRouter({
|
export const auditLogRouter = createTRPCRouter({
|
||||||
@@ -24,65 +253,52 @@ export const auditLogRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const { entityType, entityId, userId, action, source, startDate, endDate, search, limit, cursor } = input;
|
return listAuditEntries(ctx.db, toAuditListInput({
|
||||||
|
entityType: input.entityType,
|
||||||
|
entityId: input.entityId,
|
||||||
|
userId: input.userId,
|
||||||
|
action: input.action,
|
||||||
|
source: input.source,
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
search: input.search,
|
||||||
|
limit: input.limit,
|
||||||
|
cursor: input.cursor,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
listDetail: controllerProcedure
|
||||||
|
.input(
|
||||||
if (entityType) where.entityType = entityType;
|
z.object({
|
||||||
if (entityId) where.entityId = entityId;
|
entityType: z.string().optional(),
|
||||||
if (userId) where.userId = userId;
|
entityId: z.string().optional(),
|
||||||
if (action) where.action = action;
|
userId: z.string().optional(),
|
||||||
if (source) where.source = source;
|
action: z.string().optional(),
|
||||||
|
source: z.string().optional(),
|
||||||
if (startDate || endDate) {
|
startDate: z.date().optional(),
|
||||||
const createdAt: Record<string, Date> = {};
|
endDate: z.date().optional(),
|
||||||
if (startDate) createdAt.gte = startDate;
|
search: z.string().optional(),
|
||||||
if (endDate) createdAt.lte = endDate;
|
limit: z.number().min(1).max(100).default(50),
|
||||||
where.createdAt = createdAt;
|
cursor: z.string().optional(),
|
||||||
}
|
}),
|
||||||
|
)
|
||||||
if (search) {
|
.query(async ({ ctx, input }) => {
|
||||||
where.OR = [
|
const result = await listAuditEntries(ctx.db, toAuditListInput({
|
||||||
{ entityName: { contains: search, mode: "insensitive" } },
|
entityType: input.entityType,
|
||||||
{ summary: { contains: search, mode: "insensitive" } },
|
entityId: input.entityId,
|
||||||
{ entityType: { contains: search, mode: "insensitive" } },
|
userId: input.userId,
|
||||||
];
|
action: input.action,
|
||||||
}
|
source: input.source,
|
||||||
|
startDate: input.startDate,
|
||||||
// Default to last 30 days if no date filter to avoid full table scan
|
endDate: input.endDate,
|
||||||
if (!startDate && !endDate && !entityId) {
|
search: input.search,
|
||||||
const thirtyDaysAgo = new Date();
|
limit: input.limit,
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
cursor: input.cursor,
|
||||||
where.createdAt = { ...(where.createdAt as Record<string, Date> ?? {}), gte: thirtyDaysAgo };
|
}));
|
||||||
}
|
return {
|
||||||
|
items: result.items.map(formatAuditListEntry),
|
||||||
const items = await ctx.db.auditLog.findMany({
|
nextCursor: result.nextCursor ?? null,
|
||||||
where,
|
};
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
entityType: true,
|
|
||||||
entityId: true,
|
|
||||||
entityName: true,
|
|
||||||
action: true,
|
|
||||||
userId: true,
|
|
||||||
source: true,
|
|
||||||
summary: true,
|
|
||||||
createdAt: true,
|
|
||||||
user: { select: { id: true, name: true, email: true } },
|
|
||||||
// Exclude 'changes' from list query — fetch on demand when expanding
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
take: limit + 1,
|
|
||||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
let nextCursor: string | undefined;
|
|
||||||
if (items.length > limit) {
|
|
||||||
const next = items.pop();
|
|
||||||
nextCursor = next?.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { items, nextCursor };
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,10 +307,14 @@ export const auditLogRouter = createTRPCRouter({
|
|||||||
getById: controllerProcedure
|
getById: controllerProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return ctx.db.auditLog.findUniqueOrThrow({
|
return getAuditEntryById(ctx.db, input.id);
|
||||||
where: { id: input.id },
|
}),
|
||||||
include: { user: { select: { id: true, name: true, email: true } } },
|
|
||||||
});
|
getByIdDetail: controllerProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const entry = await getAuditEntryById(ctx.db, input.id);
|
||||||
|
return formatAuditDetailEntry(entry);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,17 +329,26 @@ export const auditLogRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return ctx.db.auditLog.findMany({
|
return getAuditEntriesByEntity(ctx.db, input);
|
||||||
where: {
|
}),
|
||||||
entityType: input.entityType,
|
|
||||||
entityId: input.entityId,
|
getByEntityDetail: controllerProcedure
|
||||||
},
|
.input(
|
||||||
include: {
|
z.object({
|
||||||
user: { select: { id: true, name: true, email: true } },
|
entityType: z.string(),
|
||||||
},
|
entityId: z.string(),
|
||||||
orderBy: { createdAt: "desc" },
|
limit: z.number().min(1).max(200).default(50),
|
||||||
take: input.limit,
|
}),
|
||||||
});
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const entries = await getAuditEntriesByEntity(ctx.db, input);
|
||||||
|
return {
|
||||||
|
entityType: input.entityType,
|
||||||
|
entityId: input.entityId,
|
||||||
|
entityName: entries[0]?.entityName ?? null,
|
||||||
|
itemCount: entries.length,
|
||||||
|
items: entries.map(formatAuditDetailEntry),
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,33 +363,33 @@ export const auditLogRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const where: Record<string, unknown> = {};
|
return getAuditTimeline(ctx.db, toAuditTimelineInput({
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
limit: input.limit,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
|
||||||
if (input.startDate || input.endDate) {
|
getTimelineDetail: controllerProcedure
|
||||||
const createdAt: Record<string, Date> = {};
|
.input(
|
||||||
if (input.startDate) createdAt.gte = input.startDate;
|
z.object({
|
||||||
if (input.endDate) createdAt.lte = input.endDate;
|
startDate: z.date().optional(),
|
||||||
where.createdAt = createdAt;
|
endDate: z.date().optional(),
|
||||||
}
|
limit: z.number().min(1).max(500).default(200),
|
||||||
|
}),
|
||||||
const entries = await ctx.db.auditLog.findMany({
|
)
|
||||||
where,
|
.query(async ({ ctx, input }) => {
|
||||||
include: {
|
const timeline = await getAuditTimeline(ctx.db, toAuditTimelineInput({
|
||||||
user: { select: { id: true, name: true, email: true } },
|
startDate: input.startDate,
|
||||||
},
|
endDate: input.endDate,
|
||||||
orderBy: { createdAt: "desc" },
|
limit: input.limit,
|
||||||
take: input.limit,
|
}));
|
||||||
});
|
return Object.fromEntries(
|
||||||
|
Object.entries(timeline).map(([dateKey, entries]) => [
|
||||||
// Group by date string (YYYY-MM-DD)
|
dateKey,
|
||||||
const grouped: Record<string, typeof entries> = {};
|
entries.map(formatAuditDetailEntry),
|
||||||
for (const entry of entries) {
|
]),
|
||||||
const dateKey = entry.createdAt.toISOString().slice(0, 10);
|
);
|
||||||
if (!grouped[dateKey]) grouped[dateKey] = [];
|
|
||||||
grouped[dateKey].push(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
return grouped;
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js
|
|||||||
import { createAuditEntry } from "../lib/audit.js";
|
import { createAuditEntry } from "../lib/audit.js";
|
||||||
|
|
||||||
export const blueprintRouter = createTRPCRouter({
|
export const blueprintRouter = createTRPCRouter({
|
||||||
|
listSummaries: protectedProcedure
|
||||||
|
.query(async ({ ctx }) => {
|
||||||
|
return ctx.db.blueprint.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
_count: { select: { projects: true } },
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
list: protectedProcedure
|
list: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -33,6 +45,70 @@ export const blueprintRouter = createTRPCRouter({
|
|||||||
return blueprint;
|
return blueprint;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
resolveByIdentifier: protectedProcedure
|
||||||
|
.input(z.object({ identifier: z.string().trim().min(1) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const identifier = input.identifier.trim();
|
||||||
|
const select = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
target: true,
|
||||||
|
isActive: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let blueprint = await ctx.db.blueprint.findUnique({
|
||||||
|
where: { id: identifier },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!blueprint) {
|
||||||
|
blueprint = await ctx.db.blueprint.findFirst({
|
||||||
|
where: { name: { equals: identifier, mode: "insensitive" } },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!blueprint) {
|
||||||
|
blueprint = await ctx.db.blueprint.findFirst({
|
||||||
|
where: { name: { contains: identifier, mode: "insensitive" } },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!blueprint) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: `Blueprint not found: ${identifier}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return blueprint;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getByIdentifier: protectedProcedure
|
||||||
|
.input(z.object({ identifier: z.string().trim().min(1) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const identifier = input.identifier.trim();
|
||||||
|
let blueprint = await ctx.db.blueprint.findUnique({
|
||||||
|
where: { id: identifier },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!blueprint) {
|
||||||
|
blueprint = await ctx.db.blueprint.findFirst({
|
||||||
|
where: { name: { equals: identifier, mode: "insensitive" } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!blueprint) {
|
||||||
|
blueprint = await ctx.db.blueprint.findFirst({
|
||||||
|
where: { name: { contains: identifier, mode: "insensitive" } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!blueprint) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: `Blueprint not found: ${identifier}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return blueprint;
|
||||||
|
}),
|
||||||
|
|
||||||
create: adminProcedure
|
create: adminProcedure
|
||||||
.input(CreateBlueprintSchema)
|
.input(CreateBlueprintSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import {
|
|||||||
getMonthKeys,
|
getMonthKeys,
|
||||||
type AssignmentSlice,
|
type AssignmentSlice,
|
||||||
} from "@capakraken/engine";
|
} from "@capakraken/engine";
|
||||||
|
import type { PrismaClient } from "@capakraken/db";
|
||||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||||
|
import { PermissionKey } from "@capakraken/shared";
|
||||||
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
|
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
import { createTRPCRouter, controllerProcedure, requirePermission } from "../trpc.js";
|
||||||
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
|
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||||
import {
|
import {
|
||||||
calculateEffectiveAvailableHours,
|
calculateEffectiveAvailableHours,
|
||||||
@@ -18,221 +20,299 @@ import {
|
|||||||
loadResourceDailyAvailabilityContexts,
|
loadResourceDailyAvailabilityContexts,
|
||||||
} from "../lib/resource-capacity.js";
|
} from "../lib/resource-capacity.js";
|
||||||
|
|
||||||
export const chargeabilityReportRouter = createTRPCRouter({
|
function round1(value: number): number {
|
||||||
getReport: controllerProcedure
|
return Math.round(value * 10) / 10;
|
||||||
.input(
|
}
|
||||||
z.object({
|
|
||||||
startMonth: z.string().regex(/^\d{4}-\d{2}$/), // "2026-01"
|
|
||||||
endMonth: z.string().regex(/^\d{4}-\d{2}$/),
|
|
||||||
orgUnitId: z.string().optional(),
|
|
||||||
managementLevelGroupId: z.string().optional(),
|
|
||||||
countryId: z.string().optional(),
|
|
||||||
includeProposed: z.boolean().default(false),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
const { startMonth, endMonth, includeProposed } = input;
|
|
||||||
|
|
||||||
// Parse month range
|
const reportInputSchema = z.object({
|
||||||
const [startYear, startMo] = startMonth.split("-").map(Number) as [number, number];
|
startMonth: z.string().regex(/^\d{4}-\d{2}$/),
|
||||||
const [endYear, endMo] = endMonth.split("-").map(Number) as [number, number];
|
endMonth: z.string().regex(/^\d{4}-\d{2}$/),
|
||||||
const rangeStart = getMonthRange(startYear, startMo).start;
|
orgUnitId: z.string().optional(),
|
||||||
const rangeEnd = getMonthRange(endYear, endMo).end;
|
managementLevelGroupId: z.string().optional(),
|
||||||
const monthKeys = getMonthKeys(rangeStart, rangeEnd);
|
countryId: z.string().optional(),
|
||||||
|
includeProposed: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch resources with filters
|
const detailedReportInputSchema = reportInputSchema.extend({
|
||||||
const resourceWhere = {
|
resourceQuery: z.string().optional(),
|
||||||
isActive: true,
|
resourceLimit: z.number().int().min(1).max(100).optional(),
|
||||||
chgResponsibility: true,
|
});
|
||||||
departed: false,
|
|
||||||
rolledOff: false,
|
|
||||||
...(input.orgUnitId ? { orgUnitId: input.orgUnitId } : {}),
|
|
||||||
...(input.managementLevelGroupId ? { managementLevelGroupId: input.managementLevelGroupId } : {}),
|
|
||||||
...(input.countryId ? { countryId: input.countryId } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const resources = await ctx.db.resource.findMany({
|
type ChargeabilityReportDbClient = Pick<
|
||||||
where: resourceWhere,
|
PrismaClient,
|
||||||
select: {
|
"assignment" | "resource" | "project" | "vacation" | "holidayCalendar" | "systemSettings"
|
||||||
id: true,
|
>;
|
||||||
eid: true,
|
|
||||||
displayName: true,
|
async function queryChargeabilityReport(
|
||||||
fte: true,
|
db: ChargeabilityReportDbClient,
|
||||||
availability: true,
|
input: z.infer<typeof reportInputSchema>,
|
||||||
countryId: true,
|
) {
|
||||||
federalState: true,
|
const { startMonth, endMonth, includeProposed } = input;
|
||||||
metroCityId: true,
|
|
||||||
chargeabilityTarget: true,
|
const [startYear, startMo] = startMonth.split("-").map(Number) as [number, number];
|
||||||
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
|
const [endYear, endMo] = endMonth.split("-").map(Number) as [number, number];
|
||||||
orgUnit: { select: { id: true, name: true } },
|
const rangeStart = getMonthRange(startYear, startMo).start;
|
||||||
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
|
const rangeEnd = getMonthRange(endYear, endMo).end;
|
||||||
managementLevel: { select: { id: true, name: true } },
|
const monthKeys = getMonthKeys(rangeStart, rangeEnd);
|
||||||
metroCity: { select: { id: true, name: true } },
|
|
||||||
},
|
const resourceWhere = {
|
||||||
orderBy: { displayName: "asc" },
|
isActive: true,
|
||||||
|
chgResponsibility: true,
|
||||||
|
departed: false,
|
||||||
|
rolledOff: false,
|
||||||
|
...(input.orgUnitId ? { orgUnitId: input.orgUnitId } : {}),
|
||||||
|
...(input.managementLevelGroupId ? { managementLevelGroupId: input.managementLevelGroupId } : {}),
|
||||||
|
...(input.countryId ? { countryId: input.countryId } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const resources = await db.resource.findMany({
|
||||||
|
where: resourceWhere,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
eid: true,
|
||||||
|
displayName: true,
|
||||||
|
fte: true,
|
||||||
|
availability: true,
|
||||||
|
countryId: true,
|
||||||
|
federalState: true,
|
||||||
|
metroCityId: true,
|
||||||
|
chargeabilityTarget: true,
|
||||||
|
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
|
||||||
|
orgUnit: { select: { id: true, name: true } },
|
||||||
|
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
|
||||||
|
managementLevel: { select: { id: true, name: true } },
|
||||||
|
metroCity: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { displayName: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resources.length === 0) {
|
||||||
|
return {
|
||||||
|
monthKeys,
|
||||||
|
resources: [],
|
||||||
|
groupTotals: monthKeys.map((key) => ({
|
||||||
|
monthKey: key,
|
||||||
|
totalFte: 0,
|
||||||
|
chg: 0,
|
||||||
|
target: 0,
|
||||||
|
gap: 0,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceIds = resources.map((resource) => resource.id);
|
||||||
|
const allBookings = await listAssignmentBookings(db, {
|
||||||
|
startDate: rangeStart,
|
||||||
|
endDate: rangeEnd,
|
||||||
|
resourceIds,
|
||||||
|
});
|
||||||
|
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
|
||||||
|
db,
|
||||||
|
resources.map((resource) => ({
|
||||||
|
id: resource.id,
|
||||||
|
availability: resource.availability as unknown as WeekdayAvailability,
|
||||||
|
countryId: resource.countryId,
|
||||||
|
countryCode: resource.country?.code,
|
||||||
|
federalState: resource.federalState,
|
||||||
|
metroCityId: resource.metroCityId,
|
||||||
|
metroCityName: resource.metroCity?.name,
|
||||||
|
})),
|
||||||
|
rangeStart,
|
||||||
|
rangeEnd,
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectIds = [...new Set(allBookings.map((booking) => booking.projectId))];
|
||||||
|
const projectUtilCats = projectIds.length > 0
|
||||||
|
? await db.project.findMany({
|
||||||
|
where: { id: { in: projectIds } },
|
||||||
|
select: { id: true, utilizationCategory: { select: { code: true } } },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const projectUtilCatMap = new Map(
|
||||||
|
projectUtilCats.map((project) => [project.id, project.utilizationCategory?.code ?? null]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const assignments = allBookings
|
||||||
|
.filter((booking) => booking.resourceId !== null)
|
||||||
|
.filter((booking) => isChargeabilityActualBooking(booking, includeProposed))
|
||||||
|
.map((booking) => ({
|
||||||
|
resourceId: booking.resourceId!,
|
||||||
|
startDate: booking.startDate,
|
||||||
|
endDate: booking.endDate,
|
||||||
|
hoursPerDay: booking.hoursPerDay,
|
||||||
|
project: {
|
||||||
|
status: booking.project.status,
|
||||||
|
utilizationCategory: { code: projectUtilCatMap.get(booking.projectId) ?? null },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resourceRows = await Promise.all(resources.map(async (resource) => {
|
||||||
|
const resourceAssignments = assignments.filter((assignment) => assignment.resourceId === resource.id);
|
||||||
|
const targetPct = resource.managementLevelGroup?.targetPercentage
|
||||||
|
?? (resource.chargeabilityTarget / 100);
|
||||||
|
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||||
|
const context = availabilityContexts.get(resource.id);
|
||||||
|
|
||||||
|
const months = await Promise.all(monthKeys.map(async (key) => {
|
||||||
|
const [year, month] = key.split("-").map(Number) as [number, number];
|
||||||
|
const { start: monthStart, end: monthEnd } = getMonthRange(year, month);
|
||||||
|
const availableHours = calculateEffectiveAvailableHours({
|
||||||
|
availability,
|
||||||
|
periodStart: monthStart,
|
||||||
|
periodEnd: monthEnd,
|
||||||
|
context,
|
||||||
});
|
});
|
||||||
|
const slices: AssignmentSlice[] = resourceAssignments.flatMap((assignment) => {
|
||||||
|
const totalChargeableHours = calculateEffectiveBookedHours({
|
||||||
|
availability,
|
||||||
|
startDate: assignment.startDate,
|
||||||
|
endDate: assignment.endDate,
|
||||||
|
hoursPerDay: assignment.hoursPerDay,
|
||||||
|
periodStart: monthStart,
|
||||||
|
periodEnd: monthEnd,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
if (totalChargeableHours <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
if (resources.length === 0) {
|
const categoryCode = assignment.project.utilizationCategory?.code;
|
||||||
return {
|
|
||||||
monthKeys,
|
|
||||||
resources: [],
|
|
||||||
groupTotals: monthKeys.map((key) => ({
|
|
||||||
monthKey: key,
|
|
||||||
totalFte: 0,
|
|
||||||
chg: 0,
|
|
||||||
target: 0,
|
|
||||||
gap: 0,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch all bookings (assignments + legacy allocations) in the date range
|
|
||||||
const resourceIds = resources.map((r) => r.id);
|
|
||||||
const allBookings = await listAssignmentBookings(ctx.db, {
|
|
||||||
startDate: rangeStart,
|
|
||||||
endDate: rangeEnd,
|
|
||||||
resourceIds,
|
|
||||||
});
|
|
||||||
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
|
|
||||||
ctx.db,
|
|
||||||
resources.map((resource) => ({
|
|
||||||
id: resource.id,
|
|
||||||
availability: resource.availability as unknown as WeekdayAvailability,
|
|
||||||
countryId: resource.countryId,
|
|
||||||
countryCode: resource.country?.code,
|
|
||||||
federalState: resource.federalState,
|
|
||||||
metroCityId: resource.metroCityId,
|
|
||||||
metroCityName: resource.metroCity?.name,
|
|
||||||
})),
|
|
||||||
rangeStart,
|
|
||||||
rangeEnd,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Enrich with utilization category — fetch project util categories in bulk
|
|
||||||
const projectIds = [...new Set(allBookings.map((b) => b.projectId))];
|
|
||||||
const projectUtilCats = projectIds.length > 0
|
|
||||||
? await ctx.db.project.findMany({
|
|
||||||
where: { id: { in: projectIds } },
|
|
||||||
select: { id: true, utilizationCategory: { select: { code: true } } },
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
const projectUtilCatMap = new Map(
|
|
||||||
projectUtilCats.map((p) => [p.id, p.utilizationCategory?.code ?? null]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Normalize bookings to a common shape
|
|
||||||
const assignments = allBookings
|
|
||||||
.filter((booking) => booking.resourceId !== null)
|
|
||||||
.filter((booking) => isChargeabilityActualBooking(booking, includeProposed))
|
|
||||||
.map((b) => ({
|
|
||||||
resourceId: b.resourceId!,
|
|
||||||
startDate: b.startDate,
|
|
||||||
endDate: b.endDate,
|
|
||||||
hoursPerDay: b.hoursPerDay,
|
|
||||||
project: {
|
|
||||||
status: b.project.status,
|
|
||||||
utilizationCategory: { code: projectUtilCatMap.get(b.projectId) ?? null },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Build per-resource, per-month forecasts
|
|
||||||
const resourceRows = await Promise.all(resources.map(async (resource) => {
|
|
||||||
const resourceAssignments = assignments.filter((a) => a.resourceId === resource.id);
|
|
||||||
// Prefer mgmt level group target; fall back to legacy chargeabilityTarget (0-100 → 0-1)
|
|
||||||
const targetPct = resource.managementLevelGroup?.targetPercentage
|
|
||||||
?? (resource.chargeabilityTarget / 100);
|
|
||||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
|
||||||
const context = availabilityContexts.get(resource.id);
|
|
||||||
|
|
||||||
const months = await Promise.all(monthKeys.map(async (key) => {
|
|
||||||
const [y, m] = key.split("-").map(Number) as [number, number];
|
|
||||||
const { start: monthStart, end: monthEnd } = getMonthRange(y, m);
|
|
||||||
const availableHours = calculateEffectiveAvailableHours({
|
|
||||||
availability,
|
|
||||||
periodStart: monthStart,
|
|
||||||
periodEnd: monthEnd,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
const slices: AssignmentSlice[] = resourceAssignments.flatMap((a) => {
|
|
||||||
const totalChargeableHours = calculateEffectiveBookedHours({
|
|
||||||
availability,
|
|
||||||
startDate: a.startDate,
|
|
||||||
endDate: a.endDate,
|
|
||||||
hoursPerDay: a.hoursPerDay,
|
|
||||||
periodStart: monthStart,
|
|
||||||
periodEnd: monthEnd,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
if (totalChargeableHours <= 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
hoursPerDay: a.hoursPerDay,
|
|
||||||
workingDays: 0,
|
|
||||||
categoryCode: a.project.utilizationCategory?.code ?? "Chg",
|
|
||||||
totalChargeableHours,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const forecast = deriveResourceForecast({
|
|
||||||
fte: resource.fte,
|
|
||||||
targetPercentage: targetPct,
|
|
||||||
assignments: slices,
|
|
||||||
sah: availableHours,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
monthKey: key,
|
|
||||||
sah: availableHours,
|
|
||||||
...forecast,
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: resource.id,
|
hoursPerDay: assignment.hoursPerDay,
|
||||||
eid: resource.eid,
|
workingDays: 0,
|
||||||
displayName: resource.displayName,
|
categoryCode: typeof categoryCode === "string" && categoryCode.length > 0 ? categoryCode : "Chg",
|
||||||
fte: resource.fte,
|
totalChargeableHours,
|
||||||
country: resource.country?.code ?? null,
|
|
||||||
city: resource.metroCity?.name ?? null,
|
|
||||||
orgUnit: resource.orgUnit?.name ?? null,
|
|
||||||
mgmtGroup: resource.managementLevelGroup?.name ?? null,
|
|
||||||
mgmtLevel: resource.managementLevel?.name ?? null,
|
|
||||||
targetPct,
|
|
||||||
months,
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Compute group totals per month
|
|
||||||
const groupTotals = monthKeys.map((key, monthIdx) => {
|
|
||||||
const groupInputs = resourceRows.map((r) => ({
|
|
||||||
fte: r.fte,
|
|
||||||
chargeability: r.months[monthIdx]!.chg,
|
|
||||||
}));
|
|
||||||
const targetInputs = resourceRows.map((r) => ({
|
|
||||||
fte: r.fte,
|
|
||||||
targetPercentage: r.targetPct,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const chg = calculateGroupChargeability(groupInputs);
|
|
||||||
const target = calculateGroupTarget(targetInputs);
|
|
||||||
|
|
||||||
return {
|
|
||||||
monthKey: key,
|
|
||||||
totalFte: sumFte(resourceRows),
|
|
||||||
chg,
|
|
||||||
target,
|
|
||||||
gap: chg - target,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const directory = await getAnonymizationDirectory(ctx.db);
|
const forecast = deriveResourceForecast({
|
||||||
|
fte: resource.fte,
|
||||||
|
targetPercentage: targetPct,
|
||||||
|
assignments: slices,
|
||||||
|
sah: availableHours,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
monthKeys,
|
monthKey: key,
|
||||||
resources: anonymizeResources(resourceRows, directory),
|
sah: availableHours,
|
||||||
groupTotals,
|
...forecast,
|
||||||
};
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: resource.id,
|
||||||
|
eid: resource.eid,
|
||||||
|
displayName: resource.displayName,
|
||||||
|
fte: resource.fte,
|
||||||
|
country: resource.country?.code ?? null,
|
||||||
|
city: resource.metroCity?.name ?? null,
|
||||||
|
orgUnit: resource.orgUnit?.name ?? null,
|
||||||
|
mgmtGroup: resource.managementLevelGroup?.name ?? null,
|
||||||
|
mgmtLevel: resource.managementLevel?.name ?? null,
|
||||||
|
targetPct,
|
||||||
|
months,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
const groupTotals = monthKeys.map((key, monthIdx) => {
|
||||||
|
const groupInputs = resourceRows.map((resource) => ({
|
||||||
|
fte: resource.fte,
|
||||||
|
chargeability: resource.months[monthIdx]!.chg,
|
||||||
|
}));
|
||||||
|
const targetInputs = resourceRows.map((resource) => ({
|
||||||
|
fte: resource.fte,
|
||||||
|
targetPercentage: resource.targetPct,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const chg = calculateGroupChargeability(groupInputs);
|
||||||
|
const target = calculateGroupTarget(targetInputs);
|
||||||
|
|
||||||
|
return {
|
||||||
|
monthKey: key,
|
||||||
|
totalFte: sumFte(resourceRows),
|
||||||
|
chg,
|
||||||
|
target,
|
||||||
|
gap: chg - target,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const directory = await getAnonymizationDirectory(db);
|
||||||
|
|
||||||
|
return {
|
||||||
|
monthKeys,
|
||||||
|
resources: anonymizeResources(resourceRows, directory),
|
||||||
|
groupTotals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChargeabilityReportDetail(
|
||||||
|
report: Awaited<ReturnType<typeof queryChargeabilityReport>>,
|
||||||
|
input: z.infer<typeof detailedReportInputSchema>,
|
||||||
|
) {
|
||||||
|
const resourceQuery = input.resourceQuery?.trim().toLowerCase();
|
||||||
|
const matchingResources = resourceQuery
|
||||||
|
? report.resources.filter((resource) => (
|
||||||
|
resource.displayName.toLowerCase().includes(resourceQuery)
|
||||||
|
|| resource.eid.toLowerCase().includes(resourceQuery)
|
||||||
|
))
|
||||||
|
: report.resources;
|
||||||
|
const resourceLimit = Math.min(Math.max(input.resourceLimit ?? 25, 1), 100);
|
||||||
|
const resources = matchingResources.slice(0, resourceLimit).map((resource) => ({
|
||||||
|
id: resource.id,
|
||||||
|
eid: resource.eid,
|
||||||
|
displayName: resource.displayName,
|
||||||
|
fte: round1(resource.fte),
|
||||||
|
country: resource.country,
|
||||||
|
city: resource.city,
|
||||||
|
orgUnit: resource.orgUnit,
|
||||||
|
managementLevelGroup: resource.mgmtGroup,
|
||||||
|
managementLevel: resource.mgmtLevel,
|
||||||
|
targetPct: round1(resource.targetPct * 100),
|
||||||
|
months: resource.months.map((month) => ({
|
||||||
|
monthKey: month.monthKey,
|
||||||
|
sah: round1(month.sah),
|
||||||
|
chargeabilityPct: round1(month.chg * 100),
|
||||||
|
targetPct: round1(resource.targetPct * 100),
|
||||||
|
gapPct: round1((month.chg - resource.targetPct) * 100),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
filters: {
|
||||||
|
startMonth: input.startMonth,
|
||||||
|
endMonth: input.endMonth,
|
||||||
|
orgUnitId: input.orgUnitId ?? null,
|
||||||
|
managementLevelGroupId: input.managementLevelGroupId ?? null,
|
||||||
|
countryId: input.countryId ?? null,
|
||||||
|
includeProposed: input.includeProposed ?? false,
|
||||||
|
resourceQuery: input.resourceQuery ?? null,
|
||||||
|
},
|
||||||
|
monthKeys: report.monthKeys,
|
||||||
|
groupTotals: report.groupTotals.map((group) => ({
|
||||||
|
monthKey: group.monthKey,
|
||||||
|
totalFte: round1(group.totalFte),
|
||||||
|
chargeabilityPct: round1(group.chg * 100),
|
||||||
|
targetPct: round1(group.target * 100),
|
||||||
|
gapPct: round1(group.gap * 100),
|
||||||
|
})),
|
||||||
|
resourceCount: matchingResources.length,
|
||||||
|
returnedResourceCount: resources.length,
|
||||||
|
truncated: resources.length < matchingResources.length,
|
||||||
|
resources,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chargeabilityReportRouter = createTRPCRouter({
|
||||||
|
getReport: controllerProcedure
|
||||||
|
.input(reportInputSchema)
|
||||||
|
.query(async ({ ctx, input }) => queryChargeabilityReport(ctx.db, input)),
|
||||||
|
|
||||||
|
getDetail: controllerProcedure
|
||||||
|
.input(detailedReportInputSchema)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
requirePermission(ctx, PermissionKey.VIEW_COSTS);
|
||||||
|
const report = await queryChargeabilityReport(ctx.db, input);
|
||||||
|
return buildChargeabilityReportDetail(report, input);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,12 @@ export const clientRouter = createTRPCRouter({
|
|||||||
...(input?.parentId !== undefined ? { parentId: input.parentId } : {}),
|
...(input?.parentId !== undefined ? { parentId: input.parentId } : {}),
|
||||||
...(input?.isActive !== undefined ? { isActive: input.isActive } : {}),
|
...(input?.isActive !== undefined ? { isActive: input.isActive } : {}),
|
||||||
...(input?.search
|
...(input?.search
|
||||||
? { name: { contains: input.search, mode: "insensitive" as const } }
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: input.search, mode: "insensitive" as const } },
|
||||||
|
{ code: { contains: input.search, mode: "insensitive" as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
include: { _count: { select: { children: true, projects: true } } },
|
include: { _count: { select: { children: true, projects: true } } },
|
||||||
@@ -81,6 +86,98 @@ export const clientRouter = createTRPCRouter({
|
|||||||
return client;
|
return client;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
resolveByIdentifier: protectedProcedure
|
||||||
|
.input(z.object({ identifier: z.string().trim().min(1) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const identifier = input.identifier.trim();
|
||||||
|
const select = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
code: true,
|
||||||
|
parentId: true,
|
||||||
|
isActive: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let client = await ctx.db.client.findUnique({
|
||||||
|
where: { id: identifier },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
client = await ctx.db.client.findUnique({
|
||||||
|
where: { code: identifier },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
client = await ctx.db.client.findFirst({
|
||||||
|
where: { name: { equals: identifier, mode: "insensitive" } },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
client = await ctx.db.client.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: identifier, mode: "insensitive" } },
|
||||||
|
{ code: { contains: identifier, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: `Client not found: ${identifier}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getByIdentifier: protectedProcedure
|
||||||
|
.input(z.object({ identifier: z.string().trim().min(1) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const identifier = input.identifier.trim();
|
||||||
|
let client = await ctx.db.client.findUnique({
|
||||||
|
where: { id: identifier },
|
||||||
|
include: { _count: { select: { projects: true, children: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
client = await ctx.db.client.findUnique({
|
||||||
|
where: { code: identifier },
|
||||||
|
include: { _count: { select: { projects: true, children: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
client = await ctx.db.client.findFirst({
|
||||||
|
where: { name: { equals: identifier, mode: "insensitive" } },
|
||||||
|
include: { _count: { select: { projects: true, children: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
client = await ctx.db.client.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: identifier, mode: "insensitive" } },
|
||||||
|
{ code: { contains: identifier, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: { _count: { select: { projects: true, children: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: `Client not found: ${identifier}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}),
|
||||||
|
|
||||||
create: managerProcedure
|
create: managerProcedure
|
||||||
.input(CreateClientSchema)
|
.input(CreateClientSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,100 @@ export const countryRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
resolveByIdentifier: protectedProcedure
|
||||||
|
.input(z.object({ identifier: z.string().trim().min(1) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const identifier = input.identifier.trim();
|
||||||
|
const select = {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
isActive: true,
|
||||||
|
dailyWorkingHours: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let country = await ctx.db.country.findUnique({
|
||||||
|
where: { id: identifier },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!country) {
|
||||||
|
country = await ctx.db.country.findFirst({
|
||||||
|
where: { code: { equals: identifier.toUpperCase(), mode: "insensitive" } },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!country) {
|
||||||
|
country = await ctx.db.country.findFirst({
|
||||||
|
where: { name: { equals: identifier, mode: "insensitive" } },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!country) {
|
||||||
|
country = await ctx.db.country.findFirst({
|
||||||
|
where: { name: { contains: identifier, mode: "insensitive" } },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!country) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: `Country not found: ${identifier}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return country;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getByIdentifier: protectedProcedure
|
||||||
|
.input(z.object({ identifier: z.string().trim().min(1) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const identifier = input.identifier.trim();
|
||||||
|
let country = await ctx.db.country.findUnique({
|
||||||
|
where: { id: identifier },
|
||||||
|
include: {
|
||||||
|
metroCities: { orderBy: { name: "asc" } },
|
||||||
|
_count: { select: { resources: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!country) {
|
||||||
|
country = await ctx.db.country.findFirst({
|
||||||
|
where: { code: { equals: identifier.toUpperCase(), mode: "insensitive" } },
|
||||||
|
include: {
|
||||||
|
metroCities: { orderBy: { name: "asc" } },
|
||||||
|
_count: { select: { resources: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!country) {
|
||||||
|
country = await ctx.db.country.findFirst({
|
||||||
|
where: { name: { equals: identifier, mode: "insensitive" } },
|
||||||
|
include: {
|
||||||
|
metroCities: { orderBy: { name: "asc" } },
|
||||||
|
_count: { select: { resources: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!country) {
|
||||||
|
country = await ctx.db.country.findFirst({
|
||||||
|
where: { name: { contains: identifier, mode: "insensitive" } },
|
||||||
|
include: {
|
||||||
|
metroCities: { orderBy: { name: "asc" } },
|
||||||
|
_count: { select: { resources: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!country) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: `Country not found: ${identifier}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return country;
|
||||||
|
}),
|
||||||
|
|
||||||
getById: protectedProcedure
|
getById: protectedProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
@@ -46,6 +140,19 @@ export const countryRouter = createTRPCRouter({
|
|||||||
return country;
|
return country;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getCityById: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const city = await findUniqueOrThrow(
|
||||||
|
ctx.db.metroCity.findUnique({
|
||||||
|
where: { id: input.id },
|
||||||
|
select: { id: true, name: true, countryId: true },
|
||||||
|
}),
|
||||||
|
"Metro city",
|
||||||
|
);
|
||||||
|
return city;
|
||||||
|
}),
|
||||||
|
|
||||||
create: adminProcedure
|
create: adminProcedure
|
||||||
.input(CreateCountrySchema)
|
.input(CreateCountrySchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -207,6 +314,6 @@ export const countryRouter = createTRPCRouter({
|
|||||||
source: "ui",
|
source: "ui",
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true, id: city.id, name: city.name };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, protectedProcedure, controllerProcedure } from "../trpc.js";
|
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
||||||
import {
|
import {
|
||||||
getDashboardChargeabilityOverview,
|
getDashboardChargeabilityOverview,
|
||||||
getDashboardDemand,
|
getDashboardDemand,
|
||||||
@@ -8,25 +8,182 @@ import {
|
|||||||
getDashboardTopValueResources,
|
getDashboardTopValueResources,
|
||||||
getDashboardBudgetForecast,
|
getDashboardBudgetForecast,
|
||||||
getDashboardSkillGaps,
|
getDashboardSkillGaps,
|
||||||
|
getDashboardSkillGapSummary,
|
||||||
getDashboardProjectHealth,
|
getDashboardProjectHealth,
|
||||||
} from "@capakraken/application";
|
} from "@capakraken/application";
|
||||||
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
|
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||||
import { cacheGet, cacheSet } from "../lib/cache.js";
|
import { cacheGet, cacheSet } from "../lib/cache.js";
|
||||||
|
import { fmtEur } from "../lib/format-utils.js";
|
||||||
|
|
||||||
const DEFAULT_TTL = 60; // seconds
|
const DEFAULT_TTL = 60; // seconds
|
||||||
|
|
||||||
export const dashboardRouter = createTRPCRouter({
|
function round1(value: number): number {
|
||||||
getOverview: protectedProcedure.query(async ({ ctx }) => {
|
return Math.round(value * 10) / 10;
|
||||||
const cacheKey = "overview";
|
}
|
||||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardOverview>>>(cacheKey);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const result = await getDashboardOverview(ctx.db);
|
function mapProjectHealthDetailRows(rows: Awaited<ReturnType<typeof getDashboardProjectHealth>>) {
|
||||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
const projects = rows
|
||||||
return result;
|
.map((project) => {
|
||||||
|
const overall = project.compositeScore;
|
||||||
|
return {
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.projectName,
|
||||||
|
shortCode: project.shortCode,
|
||||||
|
status: project.status,
|
||||||
|
overall,
|
||||||
|
budget: project.budgetHealth,
|
||||||
|
staffing: project.staffingHealth,
|
||||||
|
timeline: project.timelineHealth,
|
||||||
|
rating: overall >= 80 ? "healthy" : overall >= 50 ? "at_risk" : "critical",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((left, right) => left.overall - right.overall);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects,
|
||||||
|
summary: {
|
||||||
|
healthy: projects.filter((project) => project.rating === "healthy").length,
|
||||||
|
atRisk: projects.filter((project) => project.rating === "at_risk").length,
|
||||||
|
critical: projects.filter((project) => project.rating === "critical").length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBudgetForecastDetailRows(rows: Awaited<ReturnType<typeof getDashboardBudgetForecast>>) {
|
||||||
|
return {
|
||||||
|
forecasts: rows.map((forecast) => ({
|
||||||
|
projectId: forecast.projectId ?? null,
|
||||||
|
projectName: forecast.projectName,
|
||||||
|
shortCode: forecast.shortCode,
|
||||||
|
clientId: forecast.clientId,
|
||||||
|
clientName: forecast.clientName,
|
||||||
|
budget: fmtEur(forecast.budgetCents),
|
||||||
|
budgetCents: forecast.budgetCents,
|
||||||
|
spent: fmtEur(forecast.spentCents),
|
||||||
|
spentCents: forecast.spentCents,
|
||||||
|
remaining: fmtEur(forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents)),
|
||||||
|
remainingCents: forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents),
|
||||||
|
projected: forecast.burnRate > 0
|
||||||
|
? fmtEur(forecast.spentCents + Math.max(0, forecast.budgetCents - forecast.spentCents))
|
||||||
|
: fmtEur(forecast.spentCents),
|
||||||
|
projectedCents: forecast.burnRate > 0
|
||||||
|
? Math.max(forecast.spentCents, forecast.budgetCents)
|
||||||
|
: forecast.spentCents,
|
||||||
|
burnRate: fmtEur(forecast.burnRate),
|
||||||
|
burnRateCents: forecast.burnRate,
|
||||||
|
utilization: `${forecast.pctUsed}%`,
|
||||||
|
estimatedExhaustionDate: forecast.estimatedExhaustionDate,
|
||||||
|
activeAssignmentCount: forecast.activeAssignmentCount ?? null,
|
||||||
|
calendarLocations: forecast.calendarLocations ?? [],
|
||||||
|
burnStatus: forecast.pctUsed >= 100
|
||||||
|
? "ahead"
|
||||||
|
: forecast.burnRate > 0
|
||||||
|
? "on_track"
|
||||||
|
: "not_started",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStatisticsDetail(overview: Awaited<ReturnType<typeof getDashboardOverview>>) {
|
||||||
|
return {
|
||||||
|
activeResources: overview.activeResources,
|
||||||
|
totalProjects: overview.totalProjects,
|
||||||
|
activeProjects: overview.activeProjects,
|
||||||
|
totalAllocations: overview.totalAllocations,
|
||||||
|
approvedVacations: overview.approvedVacations,
|
||||||
|
totalEstimates: overview.totalEstimates,
|
||||||
|
totalBudget: overview.budgetSummary.totalBudgetCents > 0
|
||||||
|
? fmtEur(overview.budgetSummary.totalBudgetCents)
|
||||||
|
: "N/A",
|
||||||
|
projectsByStatus: Object.fromEntries(
|
||||||
|
overview.projectsByStatus.map((entry) => [entry.status, entry.count]),
|
||||||
|
),
|
||||||
|
topChapters: [...overview.chapterUtilization]
|
||||||
|
.sort((left, right) => right.resourceCount - left.resourceCount)
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((chapter) => ({
|
||||||
|
chapter: chapter.chapter,
|
||||||
|
count: chapter.resourceCount,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOverviewCached(db: Parameters<typeof getDashboardOverview>[0]) {
|
||||||
|
const cacheKey = "overview";
|
||||||
|
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardOverview>>>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const result = await getDashboardOverview(db);
|
||||||
|
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPeakTimesCached(
|
||||||
|
db: Parameters<typeof getDashboardPeakTimes>[0],
|
||||||
|
input: { startDate: string; endDate: string; granularity: "week" | "month"; groupBy: "project" | "chapter" | "resource" },
|
||||||
|
) {
|
||||||
|
const cacheKey = `peakTimes:${input.startDate}:${input.endDate}:${input.granularity}:${input.groupBy}`;
|
||||||
|
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardPeakTimes>>>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const result = await getDashboardPeakTimes(db, {
|
||||||
|
startDate: new Date(input.startDate),
|
||||||
|
endDate: new Date(input.endDate),
|
||||||
|
granularity: input.granularity,
|
||||||
|
groupBy: input.groupBy,
|
||||||
|
});
|
||||||
|
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDemandCached(
|
||||||
|
db: Parameters<typeof getDashboardDemand>[0],
|
||||||
|
input: { startDate: string; endDate: string; groupBy: "project" | "person" | "chapter" },
|
||||||
|
) {
|
||||||
|
const cacheKey = `demand:${input.startDate}:${input.endDate}:${input.groupBy}`;
|
||||||
|
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardDemand>>>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const result = await getDashboardDemand(db, {
|
||||||
|
startDate: new Date(input.startDate),
|
||||||
|
endDate: new Date(input.endDate),
|
||||||
|
groupBy: input.groupBy,
|
||||||
|
});
|
||||||
|
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTopValueResourcesCached(
|
||||||
|
db: Parameters<typeof getDashboardTopValueResources>[0],
|
||||||
|
input: { limit: number; userRole: string },
|
||||||
|
) {
|
||||||
|
const cacheKey = `topValue:${input.limit}:${input.userRole}`;
|
||||||
|
const cached = await cacheGet<Awaited<ReturnType<typeof anonymizeResources>>>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const [resources, directory] = await Promise.all([
|
||||||
|
getDashboardTopValueResources(db, {
|
||||||
|
limit: input.limit,
|
||||||
|
userRole: input.userRole,
|
||||||
|
}),
|
||||||
|
getAnonymizationDirectory(db),
|
||||||
|
]);
|
||||||
|
const result = anonymizeResources(resources, directory);
|
||||||
|
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dashboardRouter = createTRPCRouter({
|
||||||
|
getOverview: controllerProcedure.query(async ({ ctx }) => {
|
||||||
|
return getOverviewCached(ctx.db);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getPeakTimes: protectedProcedure
|
getStatisticsDetail: controllerProcedure.query(async ({ ctx }) => {
|
||||||
|
const overview = await getOverviewCached(ctx.db);
|
||||||
|
return mapStatisticsDetail(overview);
|
||||||
|
}),
|
||||||
|
|
||||||
|
getPeakTimes: controllerProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
startDate: z.string().datetime(),
|
startDate: z.string().datetime(),
|
||||||
@@ -36,42 +193,18 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const cacheKey = `peakTimes:${input.startDate}:${input.endDate}:${input.granularity}:${input.groupBy}`;
|
return getPeakTimesCached(ctx.db, input);
|
||||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardPeakTimes>>>(cacheKey);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const result = await getDashboardPeakTimes(ctx.db, {
|
|
||||||
startDate: new Date(input.startDate),
|
|
||||||
endDate: new Date(input.endDate),
|
|
||||||
granularity: input.granularity,
|
|
||||||
groupBy: input.groupBy,
|
|
||||||
});
|
|
||||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
|
||||||
return result;
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getTopValueResources: protectedProcedure
|
getTopValueResources: controllerProcedure
|
||||||
.input(z.object({ limit: z.number().int().min(1).max(50).default(10) }))
|
.input(z.object({ limit: z.number().int().min(1).max(50).default(10) }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const userRole =
|
const userRole =
|
||||||
(ctx.session.user as { role?: string } | undefined)?.role ?? "USER";
|
(ctx.session.user as { role?: string } | undefined)?.role ?? "USER";
|
||||||
const cacheKey = `topValue:${input.limit}:${userRole}`;
|
return getTopValueResourcesCached(ctx.db, { limit: input.limit, userRole });
|
||||||
const cached = await cacheGet<Awaited<ReturnType<typeof anonymizeResources>>>(cacheKey);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const [resources, directory] = await Promise.all([
|
|
||||||
getDashboardTopValueResources(ctx.db, {
|
|
||||||
limit: input.limit,
|
|
||||||
userRole,
|
|
||||||
}),
|
|
||||||
getAnonymizationDirectory(ctx.db),
|
|
||||||
]);
|
|
||||||
const result = anonymizeResources(resources, directory);
|
|
||||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
|
||||||
return result;
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getDemand: protectedProcedure
|
getDemand: controllerProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
startDate: z.string().datetime(),
|
startDate: z.string().datetime(),
|
||||||
@@ -80,16 +213,100 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const cacheKey = `demand:${input.startDate}:${input.endDate}:${input.groupBy}`;
|
return getDemandCached(ctx.db, input);
|
||||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardDemand>>>(cacheKey);
|
}),
|
||||||
if (cached) return cached;
|
|
||||||
|
getDetail: controllerProcedure
|
||||||
|
.input(z.object({ section: z.string().optional().default("all") }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const section = input.section;
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
const needsOverview =
|
||||||
|
section === "all"
|
||||||
|
|| section === "peak_times"
|
||||||
|
|| section === "demand_pipeline"
|
||||||
|
|| section === "chargeability_overview";
|
||||||
|
const overview = needsOverview ? await getOverviewCached(ctx.db) : null;
|
||||||
|
const now = new Date();
|
||||||
|
const rangeStart = overview?.budgetBasis.windowStart
|
||||||
|
? new Date(overview.budgetBasis.windowStart)
|
||||||
|
: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||||
|
const rangeEnd = overview?.budgetBasis.windowEnd
|
||||||
|
? new Date(overview.budgetBasis.windowEnd)
|
||||||
|
: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 5, 0));
|
||||||
|
const userRole =
|
||||||
|
(ctx.session.user as { role?: string } | undefined)?.role
|
||||||
|
?? ctx.dbUser?.systemRole
|
||||||
|
?? "USER";
|
||||||
|
|
||||||
|
if (section === "all" || section === "peak_times") {
|
||||||
|
const peakTimes = await getPeakTimesCached(ctx.db, {
|
||||||
|
startDate: rangeStart.toISOString(),
|
||||||
|
endDate: rangeEnd.toISOString(),
|
||||||
|
granularity: "month",
|
||||||
|
groupBy: "project",
|
||||||
|
});
|
||||||
|
|
||||||
|
result.peakTimes = [...peakTimes]
|
||||||
|
.sort((left, right) => right.totalHours - left.totalHours)
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((entry) => ({
|
||||||
|
month: entry.period,
|
||||||
|
totalHours: round1(entry.totalHours),
|
||||||
|
totalHoursPerDay: round1(entry.totalHours),
|
||||||
|
capacityHours: round1(entry.capacityHours),
|
||||||
|
utilizationPct: entry.utilizationPct ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === "all" || section === "top_resources") {
|
||||||
|
const resources = await getTopValueResourcesCached(ctx.db, { limit: 10, userRole });
|
||||||
|
result.topResources = resources.map((resource) => {
|
||||||
|
const topResource = resource as {
|
||||||
|
displayName: string;
|
||||||
|
eid: string;
|
||||||
|
chapter: string | null;
|
||||||
|
lcrCents: number;
|
||||||
|
valueScore: number | null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
name: topResource.displayName,
|
||||||
|
eid: topResource.eid,
|
||||||
|
chapter: topResource.chapter ?? null,
|
||||||
|
lcr: fmtEur(topResource.lcrCents),
|
||||||
|
valueScore: topResource.valueScore ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === "all" || section === "demand_pipeline") {
|
||||||
|
const demandRows = await getDemandCached(ctx.db, {
|
||||||
|
startDate: rangeStart.toISOString(),
|
||||||
|
endDate: rangeEnd.toISOString(),
|
||||||
|
groupBy: "project",
|
||||||
|
});
|
||||||
|
result.demandPipeline = demandRows
|
||||||
|
.map((row) => ({
|
||||||
|
project: `${row.name} (${row.shortCode})`,
|
||||||
|
needed: Math.max(0, round1(row.requiredFTEs - row.resourceCount)),
|
||||||
|
requiredFTEs: row.requiredFTEs,
|
||||||
|
allocatedResources: row.resourceCount,
|
||||||
|
allocatedHours: row.allocatedHours,
|
||||||
|
calendarLocations: row.derivation?.calendarLocations ?? [],
|
||||||
|
}))
|
||||||
|
.filter((row) => row.needed > 0)
|
||||||
|
.sort((left, right) => right.needed - left.needed)
|
||||||
|
.slice(0, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === "all" || section === "chargeability_overview") {
|
||||||
|
result.chargeabilityByChapter = (overview?.chapterUtilization ?? []).map((chapter) => ({
|
||||||
|
chapter: chapter.chapter ?? "Unassigned",
|
||||||
|
headcount: chapter.resourceCount,
|
||||||
|
avgTarget: `${Math.round(chapter.avgChargeabilityTarget)}%`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const result = await getDashboardDemand(ctx.db, {
|
|
||||||
startDate: new Date(input.startDate),
|
|
||||||
endDate: new Date(input.endDate),
|
|
||||||
groupBy: input.groupBy,
|
|
||||||
});
|
|
||||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -133,7 +350,7 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getBudgetForecast: protectedProcedure.query(async ({ ctx }) => {
|
getBudgetForecast: controllerProcedure.query(async ({ ctx }) => {
|
||||||
const cacheKey = "budgetForecast";
|
const cacheKey = "budgetForecast";
|
||||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardBudgetForecast>>>(cacheKey);
|
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardBudgetForecast>>>(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
@@ -143,7 +360,12 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getSkillGaps: protectedProcedure.query(async ({ ctx }) => {
|
getBudgetForecastDetail: controllerProcedure.query(async ({ ctx }) => {
|
||||||
|
const budgetForecast = await getDashboardBudgetForecast(ctx.db);
|
||||||
|
return mapBudgetForecastDetailRows(budgetForecast);
|
||||||
|
}),
|
||||||
|
|
||||||
|
getSkillGaps: controllerProcedure.query(async ({ ctx }) => {
|
||||||
const cacheKey = "skillGaps";
|
const cacheKey = "skillGaps";
|
||||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardSkillGaps>>>(cacheKey);
|
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardSkillGaps>>>(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
@@ -153,7 +375,17 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getProjectHealth: protectedProcedure.query(async ({ ctx }) => {
|
getSkillGapSummary: controllerProcedure.query(async ({ ctx }) => {
|
||||||
|
const cacheKey = "skillGapSummary";
|
||||||
|
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardSkillGapSummary>>>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const result = await getDashboardSkillGapSummary(ctx.db);
|
||||||
|
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getProjectHealth: controllerProcedure.query(async ({ ctx }) => {
|
||||||
const cacheKey = "projectHealth";
|
const cacheKey = "projectHealth";
|
||||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardProjectHealth>>>(cacheKey);
|
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardProjectHealth>>>(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
@@ -162,4 +394,9 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getProjectHealthDetail: controllerProcedure.query(async ({ ctx }) => {
|
||||||
|
const projectHealth = await getDashboardProjectHealth(ctx.db);
|
||||||
|
return mapProjectHealthDetailRows(projectHealth);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,167 @@ type EntitlementSnapshot = {
|
|||||||
pendingDays: number;
|
pendingDays: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function mapBalanceDetail(resource: {
|
||||||
|
displayName: string;
|
||||||
|
eid: string;
|
||||||
|
}, balance: {
|
||||||
|
year: number;
|
||||||
|
entitledDays: number;
|
||||||
|
carryoverDays: number;
|
||||||
|
usedDays: number;
|
||||||
|
pendingDays: number;
|
||||||
|
remainingDays: number;
|
||||||
|
sickDays: number;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
resource: resource.displayName,
|
||||||
|
eid: resource.eid,
|
||||||
|
year: balance.year,
|
||||||
|
entitlement: balance.entitledDays,
|
||||||
|
carryOver: balance.carryoverDays,
|
||||||
|
taken: balance.usedDays,
|
||||||
|
pending: balance.pendingDays,
|
||||||
|
remaining: balance.remainingDays,
|
||||||
|
sickDays: balance.sickDays,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapYearSummaryDetail(
|
||||||
|
year: number,
|
||||||
|
summaries: Array<{
|
||||||
|
displayName: string;
|
||||||
|
eid: string;
|
||||||
|
chapter: string | null;
|
||||||
|
entitledDays: number;
|
||||||
|
carryoverDays: number;
|
||||||
|
usedDays: number;
|
||||||
|
pendingDays: number;
|
||||||
|
remainingDays: number;
|
||||||
|
}>,
|
||||||
|
resourceName?: string,
|
||||||
|
) {
|
||||||
|
const needle = resourceName?.toLowerCase();
|
||||||
|
|
||||||
|
return summaries
|
||||||
|
.filter((summary) => {
|
||||||
|
if (!needle) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return summary.displayName.toLowerCase().includes(needle)
|
||||||
|
|| summary.eid.toLowerCase().includes(needle);
|
||||||
|
})
|
||||||
|
.slice(0, 50)
|
||||||
|
.map((summary) => ({
|
||||||
|
resource: summary.displayName,
|
||||||
|
eid: summary.eid,
|
||||||
|
chapter: summary.chapter ?? null,
|
||||||
|
year,
|
||||||
|
entitled: summary.entitledDays,
|
||||||
|
carryover: summary.carryoverDays,
|
||||||
|
used: summary.usedDays,
|
||||||
|
pending: summary.pendingDays,
|
||||||
|
remaining: summary.remainingDays,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntitlementReadContext = Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"];
|
||||||
|
|
||||||
|
async function readBalanceSnapshot(
|
||||||
|
ctx: Pick<EntitlementReadContext, "db" | "dbUser">,
|
||||||
|
input: { resourceId: string; year: number },
|
||||||
|
) {
|
||||||
|
if (ctx.dbUser) {
|
||||||
|
const allowedRoles = ["ADMIN", "MANAGER", "CONTROLLER"];
|
||||||
|
if (!allowedRoles.includes(ctx.dbUser.systemRole)) {
|
||||||
|
const resource = await ctx.db.resource.findUnique({
|
||||||
|
where: { id: input.resourceId },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
if (!resource || resource.userId !== ctx.dbUser.id) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You can only view your own vacation balance",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
||||||
|
const defaultDays = settings?.vacationDefaultDays ?? 28;
|
||||||
|
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
|
||||||
|
|
||||||
|
const sickVacationsResult = await ctx.db.vacation.findMany({
|
||||||
|
where: {
|
||||||
|
resourceId: input.resourceId,
|
||||||
|
type: VacationType.SICK,
|
||||||
|
status: VacationStatus.APPROVED,
|
||||||
|
startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) },
|
||||||
|
endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) },
|
||||||
|
},
|
||||||
|
select: { startDate: true, endDate: true, isHalfDay: true },
|
||||||
|
});
|
||||||
|
const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
|
||||||
|
const sickDays = sickVacations.reduce(
|
||||||
|
(sum, vacation) => sum + countCalendarDaysInPeriod(
|
||||||
|
vacation,
|
||||||
|
new Date(`${input.year}-01-01T00:00:00.000Z`),
|
||||||
|
new Date(`${input.year}-12-31T00:00:00.000Z`),
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
year: input.year,
|
||||||
|
resourceId: input.resourceId,
|
||||||
|
entitledDays: entitlement.entitledDays,
|
||||||
|
carryoverDays: entitlement.carryoverDays,
|
||||||
|
usedDays: entitlement.usedDays,
|
||||||
|
pendingDays: entitlement.pendingDays,
|
||||||
|
remainingDays: Math.max(
|
||||||
|
0,
|
||||||
|
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
|
||||||
|
),
|
||||||
|
sickDays,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readYearSummarySnapshot(
|
||||||
|
ctx: Pick<EntitlementReadContext, "db">,
|
||||||
|
input: { year: number; chapter?: string },
|
||||||
|
) {
|
||||||
|
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
||||||
|
const defaultDays = settings?.vacationDefaultDays ?? 28;
|
||||||
|
|
||||||
|
const resources = await ctx.db.resource.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
...(input.chapter ? { chapter: input.chapter } : {}),
|
||||||
|
},
|
||||||
|
select: { ...RESOURCE_BRIEF_SELECT, chapter: true },
|
||||||
|
orderBy: [{ chapter: "asc" }, { displayName: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
resources.map(async (resource) => {
|
||||||
|
const entitlement = await syncEntitlement(ctx.db, resource.id, input.year, defaultDays);
|
||||||
|
return {
|
||||||
|
resourceId: resource.id,
|
||||||
|
displayName: resource.displayName,
|
||||||
|
eid: resource.eid,
|
||||||
|
chapter: resource.chapter,
|
||||||
|
entitledDays: entitlement.entitledDays,
|
||||||
|
carryoverDays: entitlement.carryoverDays,
|
||||||
|
usedDays: entitlement.usedDays,
|
||||||
|
pendingDays: entitlement.pendingDays,
|
||||||
|
remainingDays: Math.max(
|
||||||
|
0,
|
||||||
|
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create an entitlement record, applying carryover from previous year if needed.
|
* Get or create an entitlement record, applying carryover from previous year if needed.
|
||||||
*/
|
*/
|
||||||
@@ -163,6 +324,15 @@ export const entitlementRouter = createTRPCRouter({
|
|||||||
* Creates the entitlement record if it doesn't exist (with carryover).
|
* Creates the entitlement record if it doesn't exist (with carryover).
|
||||||
*/
|
*/
|
||||||
getBalance: protectedProcedure
|
getBalance: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
resourceId: z.string(),
|
||||||
|
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => readBalanceSnapshot(ctx, input)),
|
||||||
|
|
||||||
|
getBalanceDetail: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
resourceId: z.string(),
|
resourceId: z.string(),
|
||||||
@@ -170,63 +340,20 @@ export const entitlementRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
// Ownership check: USER can only query their own balance
|
const balance = await readBalanceSnapshot(ctx, input);
|
||||||
if (ctx.dbUser) {
|
const resource = await ctx.db.resource.findUnique({
|
||||||
const allowedRoles = ["ADMIN", "MANAGER", "CONTROLLER"];
|
where: { id: input.resourceId },
|
||||||
if (!allowedRoles.includes(ctx.dbUser.systemRole)) {
|
select: { displayName: true, eid: true },
|
||||||
const resource = await ctx.db.resource.findUnique({
|
});
|
||||||
where: { id: input.resourceId },
|
|
||||||
select: { userId: true },
|
if (!resource) {
|
||||||
});
|
throw new TRPCError({
|
||||||
if (!resource || resource.userId !== ctx.dbUser.id) {
|
code: "NOT_FOUND",
|
||||||
throw new TRPCError({
|
message: "Resource not found",
|
||||||
code: "FORBIDDEN",
|
});
|
||||||
message: "You can only view your own vacation balance",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
return mapBalanceDetail(resource, balance);
|
||||||
const defaultDays = settings?.vacationDefaultDays ?? 28;
|
|
||||||
|
|
||||||
// Sync from real vacation records
|
|
||||||
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
|
|
||||||
|
|
||||||
// Also count sick days (informational)
|
|
||||||
const sickVacationsResult = await ctx.db.vacation.findMany({
|
|
||||||
where: {
|
|
||||||
resourceId: input.resourceId,
|
|
||||||
type: VacationType.SICK,
|
|
||||||
status: VacationStatus.APPROVED,
|
|
||||||
startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) },
|
|
||||||
endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) },
|
|
||||||
},
|
|
||||||
select: { startDate: true, endDate: true, isHalfDay: true },
|
|
||||||
});
|
|
||||||
const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
|
|
||||||
const sickDays = sickVacations.reduce(
|
|
||||||
(sum, v) => sum + countCalendarDaysInPeriod(
|
|
||||||
v,
|
|
||||||
new Date(`${input.year}-01-01T00:00:00.000Z`),
|
|
||||||
new Date(`${input.year}-12-31T00:00:00.000Z`),
|
|
||||||
),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
year: input.year,
|
|
||||||
resourceId: input.resourceId,
|
|
||||||
entitledDays: entitlement.entitledDays,
|
|
||||||
carryoverDays: entitlement.carryoverDays,
|
|
||||||
usedDays: entitlement.usedDays,
|
|
||||||
pendingDays: entitlement.pendingDays,
|
|
||||||
remainingDays: Math.max(
|
|
||||||
0,
|
|
||||||
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
|
|
||||||
),
|
|
||||||
sickDays,
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -366,39 +493,25 @@ export const entitlementRouter = createTRPCRouter({
|
|||||||
chapter: z.string().optional(),
|
chapter: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => readYearSummarySnapshot(ctx, {
|
||||||
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
year: input.year,
|
||||||
const defaultDays = settings?.vacationDefaultDays ?? 28;
|
...(input.chapter ? { chapter: input.chapter } : {}),
|
||||||
|
})),
|
||||||
|
|
||||||
const resources = await ctx.db.resource.findMany({
|
getYearSummaryDetail: managerProcedure
|
||||||
where: {
|
.input(
|
||||||
isActive: true,
|
z.object({
|
||||||
...(input.chapter ? { chapter: input.chapter } : {}),
|
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
|
||||||
},
|
chapter: z.string().optional(),
|
||||||
select: { ...RESOURCE_BRIEF_SELECT, chapter: true },
|
resourceName: z.string().optional(),
|
||||||
orderBy: [{ chapter: "asc" }, { displayName: "asc" }],
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const summaries = await readYearSummarySnapshot(ctx, {
|
||||||
|
year: input.year,
|
||||||
|
...(input.chapter ? { chapter: input.chapter } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await Promise.all(
|
return mapYearSummaryDetail(input.year, summaries, input.resourceName);
|
||||||
resources.map(async (r) => {
|
|
||||||
const entitlement = await syncEntitlement(ctx.db, r.id, input.year, defaultDays);
|
|
||||||
return {
|
|
||||||
resourceId: r.id,
|
|
||||||
displayName: r.displayName,
|
|
||||||
eid: r.eid,
|
|
||||||
chapter: r.chapter,
|
|
||||||
entitledDays: entitlement.entitledDays,
|
|
||||||
carryoverDays: entitlement.carryoverDays,
|
|
||||||
usedDays: entitlement.usedDays,
|
|
||||||
pendingDays: entitlement.pendingDays,
|
|
||||||
remainingDays: Math.max(
|
|
||||||
0,
|
|
||||||
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
+314
-109
@@ -47,6 +47,38 @@ import {
|
|||||||
} from "../trpc.js";
|
} from "../trpc.js";
|
||||||
import { emitAllocationCreated } from "../sse/event-bus.js";
|
import { emitAllocationCreated } from "../sse/event-bus.js";
|
||||||
|
|
||||||
|
type EstimateRouterErrorCode = "NOT_FOUND" | "PRECONDITION_FAILED";
|
||||||
|
|
||||||
|
type EstimateRouterErrorRule = {
|
||||||
|
code: EstimateRouterErrorCode;
|
||||||
|
messages?: readonly string[];
|
||||||
|
predicates?: readonly ((message: string) => boolean)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function rethrowEstimateRouterError(
|
||||||
|
error: unknown,
|
||||||
|
rules: readonly EstimateRouterErrorRule[],
|
||||||
|
): never {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingRule = rules.find(
|
||||||
|
(rule) =>
|
||||||
|
rule.messages?.includes(error.message) === true ||
|
||||||
|
rule.predicates?.some((predicate) => predicate(error.message)) === true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingRule) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: matchingRule.code,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
function buildComputedMetrics(
|
function buildComputedMetrics(
|
||||||
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"],
|
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"],
|
||||||
) {
|
) {
|
||||||
@@ -235,6 +267,199 @@ export const estimateRouter = createTRPCRouter({
|
|||||||
return estimate;
|
return estimate;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
listVersions: controllerProcedure
|
||||||
|
.input(z.object({ estimateId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const estimate = await findUniqueOrThrow(
|
||||||
|
ctx.db.estimate.findUnique({
|
||||||
|
where: { id: input.estimateId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
latestVersionNumber: true,
|
||||||
|
versions: {
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
versionNumber: true,
|
||||||
|
label: true,
|
||||||
|
status: true,
|
||||||
|
notes: true,
|
||||||
|
lockedAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
assumptions: true,
|
||||||
|
scopeItems: true,
|
||||||
|
demandLines: true,
|
||||||
|
resourceSnapshots: true,
|
||||||
|
exports: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"Estimate",
|
||||||
|
);
|
||||||
|
|
||||||
|
return estimate;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getVersionSnapshot: controllerProcedure
|
||||||
|
.input(z.object({ estimateId: z.string(), versionId: z.string().optional() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const estimate = await ctx.db.estimate.findUnique({
|
||||||
|
where: { id: input.estimateId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
baseCurrency: true,
|
||||||
|
versions: {
|
||||||
|
...(input.versionId
|
||||||
|
? { where: { id: input.versionId } }
|
||||||
|
: { orderBy: { versionNumber: "desc" as const }, take: 1 }),
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
versionNumber: true,
|
||||||
|
label: true,
|
||||||
|
status: true,
|
||||||
|
notes: true,
|
||||||
|
lockedAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
assumptions: {
|
||||||
|
select: { id: true, category: true, key: true, label: true },
|
||||||
|
},
|
||||||
|
scopeItems: {
|
||||||
|
select: { id: true, scopeType: true, sequenceNo: true, name: true },
|
||||||
|
orderBy: [{ sequenceNo: "asc" }, { name: "asc" }],
|
||||||
|
},
|
||||||
|
demandLines: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
chapter: true,
|
||||||
|
hours: true,
|
||||||
|
costTotalCents: true,
|
||||||
|
priceTotalCents: true,
|
||||||
|
currency: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resourceSnapshots: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
chapter: true,
|
||||||
|
currency: true,
|
||||||
|
lcrCents: true,
|
||||||
|
ucrCents: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exports: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
format: true,
|
||||||
|
fileName: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!estimate || estimate.versions.length === 0) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = estimate.versions[0]!;
|
||||||
|
const demandSummary = summarizeEstimateDemandLines(version.demandLines);
|
||||||
|
|
||||||
|
const chapterTotals = version.demandLines.reduce<Record<string, {
|
||||||
|
lineCount: number;
|
||||||
|
hours: number;
|
||||||
|
costTotalCents: number;
|
||||||
|
priceTotalCents: number;
|
||||||
|
currency: string;
|
||||||
|
}>>((acc, line) => {
|
||||||
|
const key = line.chapter ?? "Unassigned";
|
||||||
|
const current = acc[key] ?? {
|
||||||
|
lineCount: 0,
|
||||||
|
hours: 0,
|
||||||
|
costTotalCents: 0,
|
||||||
|
priceTotalCents: 0,
|
||||||
|
currency: line.currency,
|
||||||
|
};
|
||||||
|
current.lineCount += 1;
|
||||||
|
current.hours += line.hours;
|
||||||
|
current.costTotalCents += line.costTotalCents;
|
||||||
|
current.priceTotalCents += line.priceTotalCents;
|
||||||
|
acc[key] = current;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const scopeTypeTotals = version.scopeItems.reduce<Record<string, number>>((acc, item) => {
|
||||||
|
acc[item.scopeType] = (acc[item.scopeType] ?? 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const assumptionCategoryTotals = version.assumptions.reduce<Record<string, number>>((acc, assumption) => {
|
||||||
|
acc[assumption.category] = (acc[assumption.category] ?? 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
estimate: {
|
||||||
|
id: estimate.id,
|
||||||
|
name: estimate.name,
|
||||||
|
status: estimate.status,
|
||||||
|
baseCurrency: estimate.baseCurrency,
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
id: version.id,
|
||||||
|
versionNumber: version.versionNumber,
|
||||||
|
label: version.label,
|
||||||
|
status: version.status,
|
||||||
|
notes: version.notes,
|
||||||
|
lockedAt: version.lockedAt,
|
||||||
|
createdAt: version.createdAt,
|
||||||
|
updatedAt: version.updatedAt,
|
||||||
|
},
|
||||||
|
counts: {
|
||||||
|
assumptions: version.assumptions.length,
|
||||||
|
scopeItems: version.scopeItems.length,
|
||||||
|
demandLines: version.demandLines.length,
|
||||||
|
resourceSnapshots: version.resourceSnapshots.length,
|
||||||
|
exports: version.exports.length,
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
hours: demandSummary.totalHours,
|
||||||
|
costTotalCents: demandSummary.totalCostCents,
|
||||||
|
priceTotalCents: demandSummary.totalPriceCents,
|
||||||
|
marginCents: demandSummary.marginCents,
|
||||||
|
marginPercent: demandSummary.marginPercent,
|
||||||
|
},
|
||||||
|
chapterBreakdown: Object.entries(chapterTotals)
|
||||||
|
.sort((left, right) => right[1].hours - left[1].hours)
|
||||||
|
.map(([chapter, totals]) => ({
|
||||||
|
chapter,
|
||||||
|
...totals,
|
||||||
|
})),
|
||||||
|
scopeTypeBreakdown: Object.entries(scopeTypeTotals)
|
||||||
|
.sort((left, right) => right[1] - left[1])
|
||||||
|
.map(([scopeType, count]) => ({ scopeType, count })),
|
||||||
|
assumptionCategoryBreakdown: Object.entries(assumptionCategoryTotals)
|
||||||
|
.sort((left, right) => right[1] - left[1])
|
||||||
|
.map(([category, count]) => ({ category, count })),
|
||||||
|
exports: version.exports,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
create: managerProcedure
|
create: managerProcedure
|
||||||
.input(CreateEstimateSchema)
|
.input(CreateEstimateSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -294,15 +519,12 @@ export const estimateRouter = createTRPCRouter({
|
|||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
rethrowEstimateRouterError(error, [
|
||||||
if (
|
{
|
||||||
error.message === "Source estimate not found" ||
|
code: "NOT_FOUND",
|
||||||
error.message === "Source estimate has no versions"
|
messages: ["Source estimate not found", "Source estimate has no versions"],
|
||||||
) {
|
},
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
|
]);
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.auditLog.create({
|
await ctx.db.auditLog.create({
|
||||||
@@ -360,19 +582,16 @@ export const estimateRouter = createTRPCRouter({
|
|||||||
withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"),
|
withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "Estimate not found") {
|
rethrowEstimateRouterError(error, [
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
|
{
|
||||||
}
|
code: "NOT_FOUND",
|
||||||
if (
|
messages: ["Estimate not found"],
|
||||||
error instanceof Error &&
|
},
|
||||||
error.message === "Estimate has no working version"
|
{
|
||||||
) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "PRECONDITION_FAILED",
|
code: "PRECONDITION_FAILED",
|
||||||
message: error.message,
|
messages: ["Estimate has no working version"],
|
||||||
});
|
},
|
||||||
}
|
]);
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.auditLog.create({
|
await ctx.db.auditLog.create({
|
||||||
@@ -411,24 +630,19 @@ export const estimateRouter = createTRPCRouter({
|
|||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
rethrowEstimateRouterError(error, [
|
||||||
if (
|
{
|
||||||
error.message === "Estimate not found" ||
|
code: "NOT_FOUND",
|
||||||
error.message === "Estimate version not found"
|
messages: ["Estimate not found", "Estimate version not found"],
|
||||||
) {
|
},
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
|
{
|
||||||
}
|
code: "PRECONDITION_FAILED",
|
||||||
if (
|
messages: [
|
||||||
error.message === "Estimate has no working version" ||
|
"Estimate has no working version",
|
||||||
error.message === "Only working versions can be submitted"
|
"Only working versions can be submitted",
|
||||||
) {
|
],
|
||||||
throw new TRPCError({
|
},
|
||||||
code: "PRECONDITION_FAILED",
|
]);
|
||||||
message: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.auditLog.create({
|
await ctx.db.auditLog.create({
|
||||||
@@ -464,24 +678,19 @@ export const estimateRouter = createTRPCRouter({
|
|||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
rethrowEstimateRouterError(error, [
|
||||||
if (
|
{
|
||||||
error.message === "Estimate not found" ||
|
code: "NOT_FOUND",
|
||||||
error.message === "Estimate version not found"
|
messages: ["Estimate not found", "Estimate version not found"],
|
||||||
) {
|
},
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
|
{
|
||||||
}
|
code: "PRECONDITION_FAILED",
|
||||||
if (
|
messages: [
|
||||||
error.message === "Estimate has no submitted version" ||
|
"Estimate has no submitted version",
|
||||||
error.message === "Only submitted versions can be approved"
|
"Only submitted versions can be approved",
|
||||||
) {
|
],
|
||||||
throw new TRPCError({
|
},
|
||||||
code: "PRECONDITION_FAILED",
|
]);
|
||||||
message: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.auditLog.create({
|
await ctx.db.auditLog.create({
|
||||||
@@ -517,25 +726,20 @@ export const estimateRouter = createTRPCRouter({
|
|||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
rethrowEstimateRouterError(error, [
|
||||||
if (
|
{
|
||||||
error.message === "Estimate not found" ||
|
code: "NOT_FOUND",
|
||||||
error.message === "Estimate version not found"
|
messages: ["Estimate not found", "Estimate version not found"],
|
||||||
) {
|
},
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
|
{
|
||||||
}
|
code: "PRECONDITION_FAILED",
|
||||||
if (
|
messages: [
|
||||||
error.message === "Estimate already has a working version" ||
|
"Estimate already has a working version",
|
||||||
error.message === "Estimate has no locked version to revise" ||
|
"Estimate has no locked version to revise",
|
||||||
error.message === "Source version must be locked before creating a revision"
|
"Source version must be locked before creating a revision",
|
||||||
) {
|
],
|
||||||
throw new TRPCError({
|
},
|
||||||
code: "PRECONDITION_FAILED",
|
]);
|
||||||
message: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.auditLog.create({
|
await ctx.db.auditLog.create({
|
||||||
@@ -572,16 +776,16 @@ export const estimateRouter = createTRPCRouter({
|
|||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
rethrowEstimateRouterError(error, [
|
||||||
if (
|
{
|
||||||
error.message === "Estimate not found" ||
|
code: "NOT_FOUND",
|
||||||
error.message === "Estimate version not found" ||
|
messages: [
|
||||||
error.message === "Estimate has no version to export"
|
"Estimate not found",
|
||||||
) {
|
"Estimate version not found",
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
|
"Estimate has no version to export",
|
||||||
}
|
],
|
||||||
}
|
},
|
||||||
throw error;
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const exportedVersion = input.versionId
|
const exportedVersion = input.versionId
|
||||||
@@ -620,29 +824,30 @@ export const estimateRouter = createTRPCRouter({
|
|||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
rethrowEstimateRouterError(error, [
|
||||||
if (
|
{
|
||||||
error.message === "Estimate not found" ||
|
code: "NOT_FOUND",
|
||||||
error.message === "Estimate version not found" ||
|
messages: [
|
||||||
error.message === "Linked project not found"
|
"Estimate not found",
|
||||||
) {
|
"Estimate version not found",
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
|
"Linked project not found",
|
||||||
}
|
],
|
||||||
if (
|
},
|
||||||
error.message === "Estimate has no approved version" ||
|
{
|
||||||
error.message === "Only approved versions can be handed off to planning" ||
|
code: "PRECONDITION_FAILED",
|
||||||
error.message === "Estimate must be linked to a project before planning handoff" ||
|
messages: [
|
||||||
error.message === "Planning handoff already exists for this approved version" ||
|
"Estimate has no approved version",
|
||||||
error.message === "Linked project has an invalid date range" ||
|
"Only approved versions can be handed off to planning",
|
||||||
error.message.startsWith("Project window has no working days for demand line")
|
"Estimate must be linked to a project before planning handoff",
|
||||||
) {
|
"Planning handoff already exists for this approved version",
|
||||||
throw new TRPCError({
|
"Linked project has an invalid date range",
|
||||||
code: "PRECONDITION_FAILED",
|
],
|
||||||
message: error.message,
|
predicates: [
|
||||||
});
|
(message) =>
|
||||||
}
|
message.startsWith("Project window has no working days for demand line"),
|
||||||
}
|
],
|
||||||
throw error;
|
},
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.auditLog.create({
|
await ctx.db.auditLog.create({
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday
|
|||||||
import { createTRPCRouter, adminProcedure, protectedProcedure, type TRPCContext } from "../trpc.js";
|
import { createTRPCRouter, adminProcedure, protectedProcedure, type TRPCContext } from "../trpc.js";
|
||||||
|
|
||||||
type HolidayCalendarScope = HolidayCalendarScopeInput;
|
type HolidayCalendarScope = HolidayCalendarScopeInput;
|
||||||
|
type HolidayReadContext = Pick<TRPCContext, "db">;
|
||||||
|
|
||||||
const HOLIDAY_SCOPE = {
|
const HOLIDAY_SCOPE = {
|
||||||
COUNTRY: "COUNTRY",
|
COUNTRY: "COUNTRY",
|
||||||
@@ -49,6 +50,401 @@ function clampDate(date: Date): Date {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtDate(value: Date | null | undefined): string | null {
|
||||||
|
return value ? value.toISOString().slice(0, 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIsoDate(value: Date): string {
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHolidayCalendarEntryDetail(entry: {
|
||||||
|
id: string;
|
||||||
|
date: Date;
|
||||||
|
name: string;
|
||||||
|
isRecurringAnnual?: boolean | null;
|
||||||
|
source?: string | null;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
date: formatIsoDate(entry.date),
|
||||||
|
name: entry.name,
|
||||||
|
isRecurringAnnual: entry.isRecurringAnnual ?? false,
|
||||||
|
source: entry.source ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHolidayCalendarDetail(calendar: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
scopeType: string;
|
||||||
|
stateCode?: string | null;
|
||||||
|
isActive?: boolean | null;
|
||||||
|
priority?: number | null;
|
||||||
|
country?: { id: string; code: string; name: string } | null;
|
||||||
|
metroCity?: { id: string; name: string } | null;
|
||||||
|
_count?: { entries?: number | null } | null;
|
||||||
|
entries?: Array<{
|
||||||
|
id: string;
|
||||||
|
date: Date;
|
||||||
|
name: string;
|
||||||
|
isRecurringAnnual?: boolean | null;
|
||||||
|
source?: string | null;
|
||||||
|
}> | null;
|
||||||
|
}) {
|
||||||
|
const entries = calendar.entries?.map(formatHolidayCalendarEntryDetail) ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: calendar.id,
|
||||||
|
name: calendar.name,
|
||||||
|
scopeType: calendar.scopeType,
|
||||||
|
stateCode: calendar.stateCode ?? null,
|
||||||
|
isActive: calendar.isActive ?? true,
|
||||||
|
priority: calendar.priority ?? 0,
|
||||||
|
country: calendar.country
|
||||||
|
? {
|
||||||
|
id: calendar.country.id,
|
||||||
|
code: calendar.country.code,
|
||||||
|
name: calendar.country.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
metroCity: calendar.metroCity
|
||||||
|
? {
|
||||||
|
id: calendar.metroCity.id,
|
||||||
|
name: calendar.metroCity.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
entryCount: calendar._count?.entries ?? entries.length,
|
||||||
|
entries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResolvedHolidayDetail(holiday: {
|
||||||
|
date: string;
|
||||||
|
name: string;
|
||||||
|
scopeType: string;
|
||||||
|
calendarName: string;
|
||||||
|
sourceType: string;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
date: holiday.date,
|
||||||
|
name: holiday.name,
|
||||||
|
scope: holiday.scopeType,
|
||||||
|
calendarName: holiday.calendarName,
|
||||||
|
sourceType: holiday.sourceType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeResolvedHolidaysDetail(holidays: Array<{
|
||||||
|
date: string;
|
||||||
|
name: string;
|
||||||
|
scope: string;
|
||||||
|
calendarName: string;
|
||||||
|
sourceType: string;
|
||||||
|
}>) {
|
||||||
|
const byScope = new Map<string, number>();
|
||||||
|
const bySourceType = new Map<string, number>();
|
||||||
|
const byCalendar = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const holiday of holidays) {
|
||||||
|
byScope.set(holiday.scope, (byScope.get(holiday.scope) ?? 0) + 1);
|
||||||
|
bySourceType.set(holiday.sourceType, (bySourceType.get(holiday.sourceType) ?? 0) + 1);
|
||||||
|
byCalendar.set(holiday.calendarName, (byCalendar.get(holiday.calendarName) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
byScope: [...byScope.entries()]
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([scope, count]) => ({ scope, count })),
|
||||||
|
bySourceType: [...bySourceType.entries()]
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([sourceType, count]) => ({ sourceType, count })),
|
||||||
|
byCalendar: [...byCalendar.entries()]
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([calendarName, count]) => ({ calendarName, count })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResolveHolidaysInputSchema = z.object({
|
||||||
|
periodStart: z.coerce.date(),
|
||||||
|
periodEnd: z.coerce.date(),
|
||||||
|
countryId: z.string().optional(),
|
||||||
|
countryCode: z.string().trim().min(1).optional(),
|
||||||
|
stateCode: z.string().trim().min(1).optional(),
|
||||||
|
metroCityId: z.string().optional(),
|
||||||
|
metroCityName: z.string().trim().min(1).optional(),
|
||||||
|
}).superRefine((input, issueCtx) => {
|
||||||
|
if (!input.countryId && !input.countryCode) {
|
||||||
|
issueCtx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Either countryId or countryCode is required.",
|
||||||
|
path: ["countryId"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.periodEnd < input.periodStart) {
|
||||||
|
issueCtx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "periodEnd must be on or after periodStart.",
|
||||||
|
path: ["periodEnd"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ResolveResourceHolidaysInputSchema = z.object({
|
||||||
|
resourceId: z.string(),
|
||||||
|
periodStart: z.coerce.date(),
|
||||||
|
periodEnd: z.coerce.date(),
|
||||||
|
}).superRefine((input, issueCtx) => {
|
||||||
|
if (input.periodEnd < input.periodStart) {
|
||||||
|
issueCtx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "periodEnd must be on or after periodStart.",
|
||||||
|
path: ["periodEnd"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function readCalendarsSnapshot(
|
||||||
|
ctx: HolidayReadContext,
|
||||||
|
input?: {
|
||||||
|
includeInactive?: boolean | undefined;
|
||||||
|
countryCode?: string | undefined;
|
||||||
|
scopeType?: "COUNTRY" | "STATE" | "CITY" | undefined;
|
||||||
|
stateCode?: string | undefined;
|
||||||
|
metroCity?: string | undefined;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const db = asHolidayCalendarDb(ctx.db);
|
||||||
|
const where = {
|
||||||
|
...(input?.includeInactive ? {} : { isActive: true }),
|
||||||
|
...(input?.countryCode
|
||||||
|
? {
|
||||||
|
country: { code: { equals: input.countryCode.trim().toUpperCase(), mode: "insensitive" as const } },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(input?.scopeType ? { scopeType: input.scopeType } : {}),
|
||||||
|
...(input?.stateCode ? { stateCode: input.stateCode.trim().toUpperCase() } : {}),
|
||||||
|
...(input?.metroCity
|
||||||
|
? {
|
||||||
|
metroCity: { name: { contains: input.metroCity.trim(), mode: "insensitive" as const } },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return db.holidayCalendar.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
country: { select: { id: true, code: true, name: true } },
|
||||||
|
metroCity: { select: { id: true, name: true } },
|
||||||
|
_count: { select: { entries: true } },
|
||||||
|
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ country: { name: "asc" } },
|
||||||
|
{ scopeType: "asc" },
|
||||||
|
{ priority: "desc" },
|
||||||
|
{ name: "asc" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCalendarByIdentifierSnapshot(ctx: HolidayReadContext, identifier: string) {
|
||||||
|
const db = asHolidayCalendarDb(ctx.db);
|
||||||
|
const trimmedIdentifier = identifier.trim();
|
||||||
|
|
||||||
|
let calendar = await db.holidayCalendar.findUnique({
|
||||||
|
where: { id: trimmedIdentifier },
|
||||||
|
include: {
|
||||||
|
country: { select: { id: true, code: true, name: true } },
|
||||||
|
metroCity: { select: { id: true, name: true } },
|
||||||
|
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
calendar = await db.holidayCalendar.findFirst({
|
||||||
|
where: { name: { equals: trimmedIdentifier, mode: "insensitive" } },
|
||||||
|
include: {
|
||||||
|
country: { select: { id: true, code: true, name: true } },
|
||||||
|
metroCity: { select: { id: true, name: true } },
|
||||||
|
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
calendar = await db.holidayCalendar.findFirst({
|
||||||
|
where: { name: { contains: trimmedIdentifier, mode: "insensitive" } },
|
||||||
|
include: {
|
||||||
|
country: { select: { id: true, code: true, name: true } },
|
||||||
|
metroCity: { select: { id: true, name: true } },
|
||||||
|
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: `Holiday calendar not found: ${trimmedIdentifier}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return calendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPreviewResolvedHolidaysSnapshot(
|
||||||
|
ctx: HolidayReadContext,
|
||||||
|
input: z.infer<typeof PreviewResolvedHolidaysSchema>,
|
||||||
|
) {
|
||||||
|
const country = await findUniqueOrThrow(
|
||||||
|
ctx.db.country.findUnique({
|
||||||
|
where: { id: input.countryId },
|
||||||
|
select: { id: true, code: true, name: true },
|
||||||
|
}),
|
||||||
|
"Country",
|
||||||
|
);
|
||||||
|
|
||||||
|
const metroCity = input.metroCityId
|
||||||
|
? await ctx.db.metroCity.findUnique({
|
||||||
|
where: { id: input.metroCityId },
|
||||||
|
select: { id: true, name: true, countryId: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
|
||||||
|
periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`),
|
||||||
|
periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`),
|
||||||
|
countryId: input.countryId,
|
||||||
|
countryCode: country.code,
|
||||||
|
federalState: input.stateCode?.trim().toUpperCase() ?? null,
|
||||||
|
metroCityId: input.metroCityId ?? null,
|
||||||
|
metroCityName: metroCity?.name ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
locationContext: {
|
||||||
|
countryId: input.countryId,
|
||||||
|
countryCode: country.code,
|
||||||
|
stateCode: input.stateCode?.trim().toUpperCase() ?? null,
|
||||||
|
metroCityId: input.metroCityId ?? null,
|
||||||
|
metroCity: metroCity?.name ?? null,
|
||||||
|
year: input.year,
|
||||||
|
},
|
||||||
|
holidays: resolved.map((holiday) => ({
|
||||||
|
date: holiday.date,
|
||||||
|
name: holiday.name,
|
||||||
|
scopeType: holiday.scope,
|
||||||
|
calendarName: holiday.calendarName,
|
||||||
|
sourceType: holiday.sourceType,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readResolvedHolidaysSnapshot(
|
||||||
|
ctx: HolidayReadContext,
|
||||||
|
input: z.infer<typeof ResolveHolidaysInputSchema>,
|
||||||
|
) {
|
||||||
|
let resolvedCountryCode = input.countryCode?.trim().toUpperCase() ?? null;
|
||||||
|
|
||||||
|
if (!resolvedCountryCode && input.countryId) {
|
||||||
|
const country = await findUniqueOrThrow(
|
||||||
|
ctx.db.country.findUnique({
|
||||||
|
where: { id: input.countryId },
|
||||||
|
select: { code: true },
|
||||||
|
}),
|
||||||
|
"Country",
|
||||||
|
);
|
||||||
|
resolvedCountryCode = country.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metroCityName = input.metroCityId
|
||||||
|
? (await ctx.db.metroCity.findUnique({
|
||||||
|
where: { id: input.metroCityId },
|
||||||
|
select: { name: true },
|
||||||
|
}))?.name ?? null
|
||||||
|
: input.metroCityName?.trim() ?? null;
|
||||||
|
|
||||||
|
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
|
||||||
|
periodStart: input.periodStart,
|
||||||
|
periodEnd: input.periodEnd,
|
||||||
|
countryId: input.countryId ?? null,
|
||||||
|
countryCode: resolvedCountryCode,
|
||||||
|
federalState: input.stateCode?.trim().toUpperCase() ?? null,
|
||||||
|
metroCityId: input.metroCityId ?? null,
|
||||||
|
metroCityName,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
periodStart: input.periodStart.toISOString().slice(0, 10),
|
||||||
|
periodEnd: input.periodEnd.toISOString().slice(0, 10),
|
||||||
|
locationContext: {
|
||||||
|
countryId: input.countryId ?? null,
|
||||||
|
countryCode: resolvedCountryCode,
|
||||||
|
federalState: input.stateCode?.trim().toUpperCase() ?? null,
|
||||||
|
metroCityId: input.metroCityId ?? null,
|
||||||
|
metroCity: metroCityName,
|
||||||
|
},
|
||||||
|
holidays: resolved.map((holiday) => ({
|
||||||
|
date: holiday.date,
|
||||||
|
name: holiday.name,
|
||||||
|
scopeType: holiday.scope,
|
||||||
|
calendarName: holiday.calendarName,
|
||||||
|
sourceType: holiday.sourceType,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readResolvedResourceHolidaysSnapshot(
|
||||||
|
ctx: HolidayReadContext,
|
||||||
|
input: z.infer<typeof ResolveResourceHolidaysInputSchema>,
|
||||||
|
) {
|
||||||
|
const resource = await findUniqueOrThrow(
|
||||||
|
ctx.db.resource.findUnique({
|
||||||
|
where: { id: input.resourceId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
eid: true,
|
||||||
|
displayName: true,
|
||||||
|
federalState: true,
|
||||||
|
countryId: true,
|
||||||
|
metroCityId: true,
|
||||||
|
country: { select: { code: true, name: true } },
|
||||||
|
metroCity: { select: { name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"Resource",
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
|
||||||
|
periodStart: input.periodStart,
|
||||||
|
periodEnd: input.periodEnd,
|
||||||
|
countryId: resource.countryId ?? null,
|
||||||
|
countryCode: resource.country?.code ?? null,
|
||||||
|
federalState: resource.federalState ?? null,
|
||||||
|
metroCityId: resource.metroCityId ?? null,
|
||||||
|
metroCityName: resource.metroCity?.name ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
periodStart: input.periodStart.toISOString().slice(0, 10),
|
||||||
|
periodEnd: input.periodEnd.toISOString().slice(0, 10),
|
||||||
|
resource: {
|
||||||
|
id: resource.id,
|
||||||
|
eid: resource.eid,
|
||||||
|
name: resource.displayName,
|
||||||
|
country: resource.country?.name ?? resource.country?.code ?? null,
|
||||||
|
countryCode: resource.country?.code ?? null,
|
||||||
|
federalState: resource.federalState ?? null,
|
||||||
|
metroCity: resource.metroCity?.name ?? null,
|
||||||
|
},
|
||||||
|
holidays: resolved.map((holiday) => ({
|
||||||
|
date: holiday.date,
|
||||||
|
name: holiday.name,
|
||||||
|
scopeType: holiday.scope,
|
||||||
|
calendarName: holiday.calendarName,
|
||||||
|
sourceType: holiday.sourceType,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function assertEntryDateAvailable(
|
async function assertEntryDateAvailable(
|
||||||
db: HolidayCalendarDb,
|
db: HolidayCalendarDb,
|
||||||
input: {
|
input: {
|
||||||
@@ -153,26 +549,40 @@ async function assertScopeConsistency(
|
|||||||
|
|
||||||
export const holidayCalendarRouter = createTRPCRouter({
|
export const holidayCalendarRouter = createTRPCRouter({
|
||||||
listCalendars: protectedProcedure
|
listCalendars: protectedProcedure
|
||||||
.input(z.object({ includeInactive: z.boolean().optional() }).optional())
|
.input(z.object({
|
||||||
.query(async ({ ctx, input }) => {
|
includeInactive: z.boolean().optional(),
|
||||||
const db = asHolidayCalendarDb(ctx.db);
|
countryCode: z.string().trim().min(1).optional(),
|
||||||
const where = input?.includeInactive ? undefined : { isActive: true };
|
scopeType: z.enum(["COUNTRY", "STATE", "CITY"]).optional(),
|
||||||
|
stateCode: z.string().trim().min(1).optional(),
|
||||||
|
metroCity: z.string().trim().min(1).optional(),
|
||||||
|
}).optional())
|
||||||
|
.query(async ({ ctx, input }) => readCalendarsSnapshot(ctx, input)),
|
||||||
|
|
||||||
return db.holidayCalendar.findMany({
|
listCalendarsDetail: protectedProcedure
|
||||||
...(where ? { where } : {}),
|
.input(z.object({
|
||||||
include: {
|
includeInactive: z.boolean().optional(),
|
||||||
country: { select: { id: true, code: true, name: true } },
|
countryCode: z.string().trim().min(1).optional(),
|
||||||
metroCity: { select: { id: true, name: true } },
|
scopeType: z.enum(["COUNTRY", "STATE", "CITY"]).optional(),
|
||||||
_count: { select: { entries: true } },
|
stateCode: z.string().trim().min(1).optional(),
|
||||||
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
|
metroCity: z.string().trim().min(1).optional(),
|
||||||
},
|
}).optional())
|
||||||
orderBy: [
|
.query(async ({ ctx, input }) => {
|
||||||
{ country: { name: "asc" } },
|
const calendars = await readCalendarsSnapshot(ctx, input);
|
||||||
{ scopeType: "asc" },
|
return {
|
||||||
{ priority: "desc" },
|
count: calendars.length,
|
||||||
{ name: "asc" },
|
calendars: calendars.map(formatHolidayCalendarDetail),
|
||||||
],
|
};
|
||||||
});
|
}),
|
||||||
|
|
||||||
|
getCalendarByIdentifier: protectedProcedure
|
||||||
|
.input(z.object({ identifier: z.string().trim().min(1) }))
|
||||||
|
.query(async ({ ctx, input }) => readCalendarByIdentifierSnapshot(ctx, input.identifier)),
|
||||||
|
|
||||||
|
getCalendarByIdentifierDetail: protectedProcedure
|
||||||
|
.input(z.object({ identifier: z.string().trim().min(1) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const calendar = await readCalendarByIdentifierSnapshot(ctx, input.identifier);
|
||||||
|
return formatHolidayCalendarDetail(calendar);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getCalendarById: protectedProcedure
|
getCalendarById: protectedProcedure
|
||||||
@@ -323,7 +733,7 @@ export const holidayCalendarRouter = createTRPCRouter({
|
|||||||
source: "ui",
|
source: "ui",
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true, id: existing.id, name: existing.name };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
createEntry: adminProcedure
|
createEntry: adminProcedure
|
||||||
@@ -430,42 +840,61 @@ export const holidayCalendarRouter = createTRPCRouter({
|
|||||||
source: "ui",
|
source: "ui",
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true, id: existing.id, name: existing.name };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
previewResolvedHolidays: protectedProcedure
|
previewResolvedHolidays: protectedProcedure
|
||||||
|
.input(PreviewResolvedHolidaysSchema)
|
||||||
|
.query(async ({ ctx, input }) => (await readPreviewResolvedHolidaysSnapshot(ctx, input)).holidays),
|
||||||
|
|
||||||
|
previewResolvedHolidaysDetail: protectedProcedure
|
||||||
.input(PreviewResolvedHolidaysSchema)
|
.input(PreviewResolvedHolidaysSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const country = await findUniqueOrThrow(
|
const resolved = await readPreviewResolvedHolidaysSnapshot(ctx, input);
|
||||||
ctx.db.country.findUnique({
|
const holidays = resolved.holidays.map(formatResolvedHolidayDetail);
|
||||||
where: { id: input.countryId },
|
return {
|
||||||
select: { code: true },
|
count: holidays.length,
|
||||||
}),
|
locationContext: resolved.locationContext,
|
||||||
"Country",
|
summary: summarizeResolvedHolidaysDetail(holidays),
|
||||||
);
|
holidays,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
const metroCity = input.metroCityId
|
resolveHolidays: protectedProcedure
|
||||||
? await ctx.db.metroCity.findUnique({
|
.input(ResolveHolidaysInputSchema)
|
||||||
where: { id: input.metroCityId },
|
.query(async ({ ctx, input }) => readResolvedHolidaysSnapshot(ctx, input)),
|
||||||
select: { name: true },
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
|
resolveHolidaysDetail: protectedProcedure
|
||||||
periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`),
|
.input(ResolveHolidaysInputSchema)
|
||||||
periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`),
|
.query(async ({ ctx, input }) => {
|
||||||
countryId: input.countryId,
|
const resolved = await readResolvedHolidaysSnapshot(ctx, input);
|
||||||
countryCode: country.code,
|
const holidays = resolved.holidays.map(formatResolvedHolidayDetail);
|
||||||
federalState: input.stateCode?.trim().toUpperCase() ?? null,
|
return {
|
||||||
metroCityId: input.metroCityId ?? null,
|
periodStart: resolved.periodStart,
|
||||||
metroCityName: metroCity?.name ?? null,
|
periodEnd: resolved.periodEnd,
|
||||||
});
|
locationContext: resolved.locationContext,
|
||||||
|
count: holidays.length,
|
||||||
|
summary: summarizeResolvedHolidaysDetail(holidays),
|
||||||
|
holidays,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
return resolved.map((holiday) => ({
|
resolveResourceHolidays: protectedProcedure
|
||||||
date: holiday.date,
|
.input(ResolveResourceHolidaysInputSchema)
|
||||||
name: holiday.name,
|
.query(async ({ ctx, input }) => readResolvedResourceHolidaysSnapshot(ctx, input)),
|
||||||
scopeType: holiday.scope,
|
|
||||||
calendarName: holiday.calendarName,
|
resolveResourceHolidaysDetail: protectedProcedure
|
||||||
}));
|
.input(ResolveResourceHolidaysInputSchema)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const resolved = await readResolvedResourceHolidaysSnapshot(ctx, input);
|
||||||
|
const holidays = resolved.holidays.map(formatResolvedHolidayDetail);
|
||||||
|
return {
|
||||||
|
periodStart: resolved.periodStart,
|
||||||
|
periodEnd: resolved.periodEnd,
|
||||||
|
resource: resolved.resource,
|
||||||
|
count: holidays.length,
|
||||||
|
summary: summarizeResolvedHolidaysDetail(holidays),
|
||||||
|
holidays,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
+274
-288
@@ -13,6 +13,69 @@ export interface Anomaly {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InsightDemandRecord {
|
||||||
|
headcount: number;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
_count: {
|
||||||
|
assignments: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InsightProjectAssignmentRecord {
|
||||||
|
resourceId: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
hoursPerDay: number;
|
||||||
|
dailyCostCents: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InsightProjectRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
budgetCents: number;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
demandRequirements: InsightDemandRecord[];
|
||||||
|
assignments: InsightProjectAssignmentRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InsightResourceRecord {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
availability: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InsightAssignmentLoadRecord {
|
||||||
|
resourceId: string;
|
||||||
|
hoursPerDay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InsightSnapshot {
|
||||||
|
anomalies: Anomaly[];
|
||||||
|
summary: {
|
||||||
|
total: number;
|
||||||
|
criticalCount: number;
|
||||||
|
budget: number;
|
||||||
|
staffing: number;
|
||||||
|
timeline: number;
|
||||||
|
utilization: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InsightsDbAccess {
|
||||||
|
project: {
|
||||||
|
findMany(args: Record<string, unknown>): Promise<InsightProjectRecord[]>;
|
||||||
|
};
|
||||||
|
resource: {
|
||||||
|
findMany(args: Record<string, unknown>): Promise<InsightResourceRecord[]>;
|
||||||
|
};
|
||||||
|
assignment: {
|
||||||
|
findMany(args: Record<string, unknown>): Promise<InsightAssignmentLoadRecord[]>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,9 +92,216 @@ function countBusinessDays(start: Date, end: Date): number {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadInsightProjects(db: InsightsDbAccess["project"]) {
|
||||||
|
return db.findMany({
|
||||||
|
where: { status: { in: ["ACTIVE", "DRAFT"] } },
|
||||||
|
include: {
|
||||||
|
demandRequirements: {
|
||||||
|
select: {
|
||||||
|
headcount: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
_count: { select: { assignments: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
assignments: {
|
||||||
|
select: {
|
||||||
|
resourceId: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
hoursPerDay: true,
|
||||||
|
dailyCostCents: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as Promise<InsightProjectRecord[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInsightResources(db: InsightsDbAccess["resource"]) {
|
||||||
|
return db.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
availability: true,
|
||||||
|
},
|
||||||
|
}) as Promise<InsightResourceRecord[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInsightAssignmentLoads(db: InsightsDbAccess["assignment"], now: Date) {
|
||||||
|
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||||
|
|
||||||
|
return db.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ["ACTIVE", "CONFIRMED"] },
|
||||||
|
startDate: { lte: periodEnd },
|
||||||
|
endDate: { gte: periodStart },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
resourceId: true,
|
||||||
|
hoursPerDay: true,
|
||||||
|
},
|
||||||
|
}) as Promise<InsightAssignmentLoadRecord[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeAnomalies(anomalies: Anomaly[]): InsightSnapshot["summary"] {
|
||||||
|
return anomalies.reduce<InsightSnapshot["summary"]>((summary, anomaly) => {
|
||||||
|
summary.total += 1;
|
||||||
|
summary[anomaly.type] += 1;
|
||||||
|
if (anomaly.severity === "critical") {
|
||||||
|
summary.criticalCount += 1;
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}, {
|
||||||
|
total: 0,
|
||||||
|
criticalCount: 0,
|
||||||
|
budget: 0,
|
||||||
|
staffing: 0,
|
||||||
|
timeline: 0,
|
||||||
|
utilization: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildInsightSnapshot(db: InsightsDbAccess, now = new Date()): Promise<InsightSnapshot> {
|
||||||
|
const [projects, resources, activeAssignments] = await Promise.all([
|
||||||
|
loadInsightProjects(db.project),
|
||||||
|
loadInsightResources(db.resource),
|
||||||
|
loadInsightAssignmentLoads(db.assignment, now),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
|
||||||
|
const anomalies: Anomaly[] = [];
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
if (project.budgetCents > 0) {
|
||||||
|
const totalDays = countBusinessDays(project.startDate, project.endDate);
|
||||||
|
const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate);
|
||||||
|
|
||||||
|
if (totalDays > 0 && elapsedDays > 0) {
|
||||||
|
const expectedBurnRate = elapsedDays / totalDays;
|
||||||
|
const totalCostCents = project.assignments.reduce((sum, assignment) => {
|
||||||
|
const assignmentStart = assignment.startDate < project.startDate
|
||||||
|
? project.startDate
|
||||||
|
: assignment.startDate;
|
||||||
|
const assignmentEnd = assignment.endDate > now ? now : assignment.endDate;
|
||||||
|
if (assignmentEnd < assignmentStart) {
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sum + assignment.dailyCostCents * countBusinessDays(assignmentStart, assignmentEnd);
|
||||||
|
}, 0);
|
||||||
|
const actualBurnRate = totalCostCents / project.budgetCents;
|
||||||
|
|
||||||
|
if (actualBurnRate > expectedBurnRate * 1.2) {
|
||||||
|
const overSpendPercent = Math.round(((actualBurnRate - expectedBurnRate) / expectedBurnRate) * 100);
|
||||||
|
anomalies.push({
|
||||||
|
type: "budget",
|
||||||
|
severity: actualBurnRate > expectedBurnRate * 1.5 ? "critical" : "warning",
|
||||||
|
entityId: project.id,
|
||||||
|
entityName: project.name,
|
||||||
|
message: `Burning budget ${overSpendPercent}% faster than expected. ${Math.round(actualBurnRate * 100)}% spent at ${Math.round(expectedBurnRate * 100)}% timeline.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const upcomingDemands = project.demandRequirements.filter(
|
||||||
|
(demand) => demand.startDate <= twoWeeksFromNow && demand.endDate >= now,
|
||||||
|
);
|
||||||
|
for (const demand of upcomingDemands) {
|
||||||
|
const unfilledCount = demand.headcount - demand._count.assignments;
|
||||||
|
const unfillPct = demand.headcount > 0 ? unfilledCount / demand.headcount : 0;
|
||||||
|
if (unfillPct > 0.3) {
|
||||||
|
anomalies.push({
|
||||||
|
type: "staffing",
|
||||||
|
severity: unfillPct > 0.6 ? "critical" : "warning",
|
||||||
|
entityId: project.id,
|
||||||
|
entityName: project.name,
|
||||||
|
message: `${unfilledCount} of ${demand.headcount} positions unfilled, starting ${demand.startDate.toISOString().slice(0, 10)}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrunAssignments = project.assignments.filter(
|
||||||
|
(assignment) => assignment.endDate > project.endDate
|
||||||
|
&& (assignment.status === "ACTIVE" || assignment.status === "CONFIRMED"),
|
||||||
|
);
|
||||||
|
if (overrunAssignments.length > 0) {
|
||||||
|
anomalies.push({
|
||||||
|
type: "timeline",
|
||||||
|
severity: "warning",
|
||||||
|
entityId: project.id,
|
||||||
|
entityName: project.name,
|
||||||
|
message: `${overrunAssignments.length} assignment(s) extend beyond the project end date (${project.endDate.toISOString().slice(0, 10)}).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceHoursMap = new Map<string, number>();
|
||||||
|
for (const assignment of activeAssignments) {
|
||||||
|
const currentHours = resourceHoursMap.get(assignment.resourceId) ?? 0;
|
||||||
|
resourceHoursMap.set(assignment.resourceId, currentHours + assignment.hoursPerDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const resource of resources) {
|
||||||
|
const availability = resource.availability as Record<string, number> | null;
|
||||||
|
if (!availability) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyAvailableHours = Object.values(availability).reduce((sum, hours) => sum + (hours ?? 0), 0) / 5;
|
||||||
|
if (dailyAvailableHours <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookedHours = resourceHoursMap.get(resource.id) ?? 0;
|
||||||
|
const utilizationPercent = Math.round((bookedHours / dailyAvailableHours) * 100);
|
||||||
|
|
||||||
|
if (utilizationPercent > 110) {
|
||||||
|
anomalies.push({
|
||||||
|
type: "utilization",
|
||||||
|
severity: utilizationPercent > 130 ? "critical" : "warning",
|
||||||
|
entityId: resource.id,
|
||||||
|
entityName: resource.displayName,
|
||||||
|
message: `Resource at ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailableHours.toFixed(1)}h per day).`,
|
||||||
|
});
|
||||||
|
} else if (utilizationPercent < 40 && bookedHours > 0) {
|
||||||
|
anomalies.push({
|
||||||
|
type: "utilization",
|
||||||
|
severity: "warning",
|
||||||
|
entityId: resource.id,
|
||||||
|
entityName: resource.displayName,
|
||||||
|
message: `Resource at only ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailableHours.toFixed(1)}h per day).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
anomalies.sort((left, right) => {
|
||||||
|
if (left.severity !== right.severity) {
|
||||||
|
return left.severity === "critical" ? -1 : 1;
|
||||||
|
}
|
||||||
|
return left.type.localeCompare(right.type);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
anomalies,
|
||||||
|
summary: summarizeAnomalies(anomalies),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Router ──────────────────────────────────────────────────────────────────
|
// ─── Router ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const insightsRouter = createTRPCRouter({
|
export const insightsRouter = createTRPCRouter({
|
||||||
|
getAnomalyDetail: controllerProcedure.query(async ({ ctx }) => {
|
||||||
|
const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess);
|
||||||
|
return {
|
||||||
|
anomalies: snapshot.anomalies,
|
||||||
|
count: snapshot.anomalies.length,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate an AI-powered executive narrative for a project.
|
* Generate an AI-powered executive narrative for a project.
|
||||||
* Caches the result in the project's dynamicFields.aiNarrative to avoid
|
* Caches the result in the project's dynamicFields.aiNarrative to avoid
|
||||||
@@ -185,300 +455,16 @@ ${dataContext}`;
|
|||||||
* No AI involved — pure data analysis.
|
* No AI involved — pure data analysis.
|
||||||
*/
|
*/
|
||||||
detectAnomalies: controllerProcedure.query(async ({ ctx }) => {
|
detectAnomalies: controllerProcedure.query(async ({ ctx }) => {
|
||||||
const now = new Date();
|
const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess);
|
||||||
const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
|
return snapshot.anomalies;
|
||||||
const anomalies: Anomaly[] = [];
|
|
||||||
|
|
||||||
// Fetch all active projects with their demands and assignments
|
|
||||||
const projects = await ctx.db.project.findMany({
|
|
||||||
where: { status: { in: ["ACTIVE", "DRAFT"] } },
|
|
||||||
include: {
|
|
||||||
demandRequirements: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
headcount: true,
|
|
||||||
startDate: true,
|
|
||||||
endDate: true,
|
|
||||||
status: true,
|
|
||||||
_count: { select: { assignments: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
assignments: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
resourceId: true,
|
|
||||||
startDate: true,
|
|
||||||
endDate: true,
|
|
||||||
hoursPerDay: true,
|
|
||||||
dailyCostCents: true,
|
|
||||||
status: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const project of projects) {
|
|
||||||
// ── Budget anomaly: spending faster than expected burn rate ──
|
|
||||||
if (project.budgetCents > 0) {
|
|
||||||
const totalDays = countBusinessDays(project.startDate, project.endDate);
|
|
||||||
const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate);
|
|
||||||
|
|
||||||
if (totalDays > 0 && elapsedDays > 0) {
|
|
||||||
const expectedBurnRate = elapsedDays / totalDays; // fraction of timeline elapsed
|
|
||||||
const totalCostCents = project.assignments.reduce((s, a) => {
|
|
||||||
const aStart = a.startDate < project.startDate ? project.startDate : a.startDate;
|
|
||||||
const aEnd = a.endDate > now ? now : a.endDate;
|
|
||||||
if (aEnd < aStart) return s;
|
|
||||||
const days = countBusinessDays(aStart, aEnd);
|
|
||||||
return s + a.dailyCostCents * days;
|
|
||||||
}, 0);
|
|
||||||
const actualBurnRate = totalCostCents / project.budgetCents;
|
|
||||||
|
|
||||||
if (actualBurnRate > expectedBurnRate * 1.2) {
|
|
||||||
const overSpendPercent = Math.round(((actualBurnRate - expectedBurnRate) / expectedBurnRate) * 100);
|
|
||||||
anomalies.push({
|
|
||||||
type: "budget",
|
|
||||||
severity: actualBurnRate > expectedBurnRate * 1.5 ? "critical" : "warning",
|
|
||||||
entityId: project.id,
|
|
||||||
entityName: project.name,
|
|
||||||
message: `Burning budget ${overSpendPercent}% faster than expected. ${Math.round(actualBurnRate * 100)}% spent at ${Math.round(expectedBurnRate * 100)}% timeline.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Staffing anomaly: unfilled demands close to start ──
|
|
||||||
const upcomingDemands = project.demandRequirements.filter(
|
|
||||||
(d) => d.startDate <= twoWeeksFromNow && d.endDate >= now,
|
|
||||||
);
|
|
||||||
for (const demand of upcomingDemands) {
|
|
||||||
const unfilledCount = demand.headcount - demand._count.assignments;
|
|
||||||
const unfillPct = demand.headcount > 0 ? unfilledCount / demand.headcount : 0;
|
|
||||||
if (unfillPct > 0.3) {
|
|
||||||
anomalies.push({
|
|
||||||
type: "staffing",
|
|
||||||
severity: unfillPct > 0.6 ? "critical" : "warning",
|
|
||||||
entityId: project.id,
|
|
||||||
entityName: project.name,
|
|
||||||
message: `${unfilledCount} of ${demand.headcount} positions unfilled, starting ${demand.startDate.toISOString().slice(0, 10)}.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Timeline anomaly: assignments extending beyond project end ──
|
|
||||||
const overrunAssignments = project.assignments.filter(
|
|
||||||
(a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"),
|
|
||||||
);
|
|
||||||
if (overrunAssignments.length > 0) {
|
|
||||||
anomalies.push({
|
|
||||||
type: "timeline",
|
|
||||||
severity: "warning",
|
|
||||||
entityId: project.id,
|
|
||||||
entityName: project.name,
|
|
||||||
message: `${overrunAssignments.length} assignment(s) extend beyond the project end date (${project.endDate.toISOString().slice(0, 10)}).`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Utilization anomaly: resources at extreme utilization ──
|
|
||||||
const resources = await ctx.db.resource.findMany({
|
|
||||||
where: { isActive: true },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
displayName: true,
|
|
||||||
availability: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get all active assignments for current period
|
|
||||||
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
||||||
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
|
||||||
|
|
||||||
const activeAssignments = await ctx.db.assignment.findMany({
|
|
||||||
where: {
|
|
||||||
status: { in: ["ACTIVE", "CONFIRMED"] },
|
|
||||||
startDate: { lte: periodEnd },
|
|
||||||
endDate: { gte: periodStart },
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
resourceId: true,
|
|
||||||
hoursPerDay: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build resource utilization map
|
|
||||||
const resourceHoursMap = new Map<string, number>();
|
|
||||||
for (const assignment of activeAssignments) {
|
|
||||||
const current = resourceHoursMap.get(assignment.resourceId) ?? 0;
|
|
||||||
resourceHoursMap.set(assignment.resourceId, current + assignment.hoursPerDay);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const resource of resources) {
|
|
||||||
const avail = resource.availability as Record<string, number> | null;
|
|
||||||
if (!avail) continue;
|
|
||||||
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
|
|
||||||
if (dailyAvailHours <= 0) continue;
|
|
||||||
|
|
||||||
const bookedHours = resourceHoursMap.get(resource.id) ?? 0;
|
|
||||||
const utilizationPercent = Math.round((bookedHours / dailyAvailHours) * 100);
|
|
||||||
|
|
||||||
if (utilizationPercent > 110) {
|
|
||||||
anomalies.push({
|
|
||||||
type: "utilization",
|
|
||||||
severity: utilizationPercent > 130 ? "critical" : "warning",
|
|
||||||
entityId: resource.id,
|
|
||||||
entityName: resource.displayName,
|
|
||||||
message: `Resource at ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailHours.toFixed(1)}h per day).`,
|
|
||||||
});
|
|
||||||
} else if (utilizationPercent < 40 && utilizationPercent > 0) {
|
|
||||||
// Only flag under-utilization if resource has at least some bookings
|
|
||||||
// to avoid flagging bench resources
|
|
||||||
if (bookedHours > 0) {
|
|
||||||
anomalies.push({
|
|
||||||
type: "utilization",
|
|
||||||
severity: "warning",
|
|
||||||
entityId: resource.id,
|
|
||||||
entityName: resource.displayName,
|
|
||||||
message: `Resource at only ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailHours.toFixed(1)}h per day).`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort: critical first, then by type
|
|
||||||
anomalies.sort((a, b) => {
|
|
||||||
if (a.severity !== b.severity) return a.severity === "critical" ? -1 : 1;
|
|
||||||
return a.type.localeCompare(b.type);
|
|
||||||
});
|
|
||||||
|
|
||||||
return anomalies;
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard-friendly summary: anomaly counts by category + total.
|
* Dashboard-friendly summary: anomaly counts by category + total.
|
||||||
*/
|
*/
|
||||||
getInsightsSummary: controllerProcedure.query(async ({ ctx }) => {
|
getInsightsSummary: controllerProcedure.query(async ({ ctx }) => {
|
||||||
// Re-use the detectAnomalies logic inline (calling it directly would
|
const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess);
|
||||||
// require the full context to be passed through — simpler to share code
|
return snapshot.summary;
|
||||||
// via the router caller pattern, but for now we duplicate the call).
|
|
||||||
const now = new Date();
|
|
||||||
const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
const projects = await ctx.db.project.findMany({
|
|
||||||
where: { status: { in: ["ACTIVE", "DRAFT"] } },
|
|
||||||
include: {
|
|
||||||
demandRequirements: {
|
|
||||||
select: {
|
|
||||||
headcount: true,
|
|
||||||
startDate: true,
|
|
||||||
endDate: true,
|
|
||||||
_count: { select: { assignments: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
assignments: {
|
|
||||||
select: {
|
|
||||||
resourceId: true,
|
|
||||||
startDate: true,
|
|
||||||
endDate: true,
|
|
||||||
hoursPerDay: true,
|
|
||||||
dailyCostCents: true,
|
|
||||||
status: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let budgetCount = 0;
|
|
||||||
let staffingCount = 0;
|
|
||||||
let timelineCount = 0;
|
|
||||||
let criticalCount = 0;
|
|
||||||
|
|
||||||
for (const project of projects) {
|
|
||||||
// Budget check
|
|
||||||
if (project.budgetCents > 0) {
|
|
||||||
const totalDays = countBusinessDays(project.startDate, project.endDate);
|
|
||||||
const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate);
|
|
||||||
if (totalDays > 0 && elapsedDays > 0) {
|
|
||||||
const expectedBurnRate = elapsedDays / totalDays;
|
|
||||||
const totalCostCents = project.assignments.reduce((s, a) => {
|
|
||||||
const aStart = a.startDate < project.startDate ? project.startDate : a.startDate;
|
|
||||||
const aEnd = a.endDate > now ? now : a.endDate;
|
|
||||||
if (aEnd < aStart) return s;
|
|
||||||
return s + a.dailyCostCents * countBusinessDays(aStart, aEnd);
|
|
||||||
}, 0);
|
|
||||||
const actualBurnRate = totalCostCents / project.budgetCents;
|
|
||||||
if (actualBurnRate > expectedBurnRate * 1.2) {
|
|
||||||
budgetCount++;
|
|
||||||
if (actualBurnRate > expectedBurnRate * 1.5) criticalCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Staffing check
|
|
||||||
const upcomingDemands = project.demandRequirements.filter(
|
|
||||||
(d) => d.startDate <= twoWeeksFromNow && d.endDate >= now,
|
|
||||||
);
|
|
||||||
for (const demand of upcomingDemands) {
|
|
||||||
const unfillPct = demand.headcount > 0 ? (demand.headcount - demand._count.assignments) / demand.headcount : 0;
|
|
||||||
if (unfillPct > 0.3) {
|
|
||||||
staffingCount++;
|
|
||||||
if (unfillPct > 0.6) criticalCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeline check
|
|
||||||
const overruns = project.assignments.filter(
|
|
||||||
(a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"),
|
|
||||||
);
|
|
||||||
if (overruns.length > 0) timelineCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utilization check
|
|
||||||
const resources = await ctx.db.resource.findMany({
|
|
||||||
where: { isActive: true },
|
|
||||||
select: { id: true, availability: true },
|
|
||||||
});
|
|
||||||
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
||||||
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
|
||||||
const activeAssignments = await ctx.db.assignment.findMany({
|
|
||||||
where: {
|
|
||||||
status: { in: ["ACTIVE", "CONFIRMED"] },
|
|
||||||
startDate: { lte: periodEnd },
|
|
||||||
endDate: { gte: periodStart },
|
|
||||||
},
|
|
||||||
select: { resourceId: true, hoursPerDay: true },
|
|
||||||
});
|
|
||||||
const resourceHoursMap = new Map<string, number>();
|
|
||||||
for (const a of activeAssignments) {
|
|
||||||
resourceHoursMap.set(a.resourceId, (resourceHoursMap.get(a.resourceId) ?? 0) + a.hoursPerDay);
|
|
||||||
}
|
|
||||||
|
|
||||||
let utilizationCount = 0;
|
|
||||||
for (const resource of resources) {
|
|
||||||
const avail = resource.availability as Record<string, number> | null;
|
|
||||||
if (!avail) continue;
|
|
||||||
const dailyAvail = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
|
|
||||||
if (dailyAvail <= 0) continue;
|
|
||||||
const booked = resourceHoursMap.get(resource.id) ?? 0;
|
|
||||||
const pct = Math.round((booked / dailyAvail) * 100);
|
|
||||||
if (pct > 110) {
|
|
||||||
utilizationCount++;
|
|
||||||
if (pct > 130) criticalCount++;
|
|
||||||
} else if (pct < 40 && booked > 0) {
|
|
||||||
utilizationCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = budgetCount + staffingCount + timelineCount + utilizationCount;
|
|
||||||
|
|
||||||
return {
|
|
||||||
total,
|
|
||||||
criticalCount,
|
|
||||||
budget: budgetCount,
|
|
||||||
staffing: staffingCount,
|
|
||||||
timeline: timelineCount,
|
|
||||||
utilization: utilizationCount,
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { PermissionKey, parseTaskAction, resolvePermissions } from "@capakraken/shared";
|
||||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
|
import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
import { createNotification } from "../lib/create-notification.js";
|
import { createNotification } from "../lib/create-notification.js";
|
||||||
import { resolveRecipients } from "../lib/notification-targeting.js";
|
import { resolveRecipients } from "../lib/notification-targeting.js";
|
||||||
import { sendEmail } from "../lib/email.js";
|
import { sendEmail } from "../lib/email.js";
|
||||||
|
import { getTaskAction } from "../lib/task-actions.js";
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -260,6 +262,49 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Get one task/approval visible to the current user */
|
||||||
|
getTaskDetail: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const userId = await resolveUserId(ctx);
|
||||||
|
|
||||||
|
const task = await ctx.db.notification.findFirst({
|
||||||
|
where: {
|
||||||
|
id: input.id,
|
||||||
|
OR: [{ userId }, { assigneeId: userId }],
|
||||||
|
category: { in: ["TASK", "APPROVAL"] },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
body: true,
|
||||||
|
type: true,
|
||||||
|
priority: true,
|
||||||
|
category: true,
|
||||||
|
taskStatus: true,
|
||||||
|
taskAction: true,
|
||||||
|
dueDate: true,
|
||||||
|
entityId: true,
|
||||||
|
entityType: true,
|
||||||
|
completedAt: true,
|
||||||
|
completedBy: true,
|
||||||
|
createdAt: true,
|
||||||
|
userId: true,
|
||||||
|
assigneeId: true,
|
||||||
|
sender: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Task not found or you do not have permission",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}),
|
||||||
|
|
||||||
/** Update task status */
|
/** Update task status */
|
||||||
updateTaskStatus: protectedProcedure
|
updateTaskStatus: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -312,6 +357,101 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
return updated;
|
return updated;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Execute the machine-readable action associated with a task */
|
||||||
|
executeTaskAction: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const userId = await resolveUserId(ctx);
|
||||||
|
const task = await ctx.db.notification.findFirst({
|
||||||
|
where: {
|
||||||
|
id: input.id,
|
||||||
|
OR: [{ userId }, { assigneeId: userId }],
|
||||||
|
category: { in: ["TASK", "APPROVAL"] },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
assigneeId: true,
|
||||||
|
taskAction: true,
|
||||||
|
taskStatus: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Task not found or you do not have permission",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!task.taskAction) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "PRECONDITION_FAILED",
|
||||||
|
message: "This task has no executable action",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (task.taskStatus === "DONE") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "PRECONDITION_FAILED",
|
||||||
|
message: "This task is already completed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseTaskAction(task.taskAction);
|
||||||
|
if (!parsed) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Invalid taskAction format: ${task.taskAction}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = getTaskAction(parsed.action);
|
||||||
|
if (!handler) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Unknown action: ${parsed.action}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = resolvePermissions(
|
||||||
|
ctx.dbUser.systemRole as import("@capakraken/shared").SystemRole,
|
||||||
|
ctx.dbUser.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null,
|
||||||
|
ctx.roleDefaults ?? undefined,
|
||||||
|
);
|
||||||
|
if (handler.permission && !permissions.has(handler.permission as PermissionKey)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: `Permission denied: you need "${handler.permission}" to perform this action`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionResult = await handler.execute(parsed.entityId, ctx.db, userId);
|
||||||
|
if (!actionResult.success) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: actionResult.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedTask = await ctx.db.notification.update({
|
||||||
|
where: { id: input.id },
|
||||||
|
data: {
|
||||||
|
taskStatus: "DONE",
|
||||||
|
completedAt: new Date(),
|
||||||
|
completedBy: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
emitTaskCompleted(task.userId, task.id);
|
||||||
|
if (task.assigneeId && task.assigneeId !== task.userId) {
|
||||||
|
emitTaskCompleted(task.assigneeId, task.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
task: completedTask,
|
||||||
|
actionResult,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// REMINDERS
|
// REMINDERS
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -542,6 +682,21 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Get one broadcast with sender context */
|
||||||
|
getBroadcastById: managerProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return findUniqueOrThrow(
|
||||||
|
ctx.db.notificationBroadcast.findUnique({
|
||||||
|
where: { id: input.id },
|
||||||
|
include: {
|
||||||
|
sender: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"Broadcast",
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// TASK CREATION (Manager+)
|
// TASK CREATION (Manager+)
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -78,6 +78,98 @@ export const orgUnitRouter = createTRPCRouter({
|
|||||||
return unit;
|
return unit;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
resolveByIdentifier: protectedProcedure
|
||||||
|
.input(z.object({ identifier: z.string().trim().min(1) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const identifier = input.identifier.trim();
|
||||||
|
const select = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
shortName: true,
|
||||||
|
level: true,
|
||||||
|
isActive: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let unit = await ctx.db.orgUnit.findUnique({
|
||||||
|
where: { id: identifier },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
unit = await ctx.db.orgUnit.findFirst({
|
||||||
|
where: { name: { equals: identifier, mode: "insensitive" } },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
unit = await ctx.db.orgUnit.findFirst({
|
||||||
|
where: { shortName: { equals: identifier, mode: "insensitive" } },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
unit = await ctx.db.orgUnit.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: identifier, mode: "insensitive" } },
|
||||||
|
{ shortName: { contains: identifier, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: `Org unit not found: ${identifier}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return unit;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getByIdentifier: protectedProcedure
|
||||||
|
.input(z.object({ identifier: z.string().trim().min(1) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const identifier = input.identifier.trim();
|
||||||
|
let unit = await ctx.db.orgUnit.findUnique({
|
||||||
|
where: { id: identifier },
|
||||||
|
include: { _count: { select: { resources: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
unit = await ctx.db.orgUnit.findFirst({
|
||||||
|
where: { name: { equals: identifier, mode: "insensitive" } },
|
||||||
|
include: { _count: { select: { resources: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
unit = await ctx.db.orgUnit.findFirst({
|
||||||
|
where: { shortName: { equals: identifier, mode: "insensitive" } },
|
||||||
|
include: { _count: { select: { resources: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
unit = await ctx.db.orgUnit.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: identifier, mode: "insensitive" } },
|
||||||
|
{ shortName: { contains: identifier, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: { _count: { select: { resources: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: `Org unit not found: ${identifier}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return unit;
|
||||||
|
}),
|
||||||
|
|
||||||
create: adminProcedure
|
create: adminProcedure
|
||||||
.input(CreateOrgUnitSchema)
|
.input(CreateOrgUnitSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
|||||||
@@ -16,17 +16,481 @@ import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure
|
|||||||
import { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
|
import { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
|
||||||
import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js";
|
import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js";
|
||||||
import { invalidateDashboardCache } from "../lib/cache.js";
|
import { invalidateDashboardCache } from "../lib/cache.js";
|
||||||
|
import { logger } from "../lib/logger.js";
|
||||||
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
||||||
import { validateImageDataUrl } from "../lib/image-validation.js";
|
import { validateImageDataUrl } from "../lib/image-validation.js";
|
||||||
|
import type { TRPCContext } from "../trpc.js";
|
||||||
import {
|
import {
|
||||||
calculateEffectiveBookedHours,
|
calculateEffectiveBookedHours,
|
||||||
loadResourceDailyAvailabilityContexts,
|
loadResourceDailyAvailabilityContexts,
|
||||||
} from "../lib/resource-capacity.js";
|
} from "../lib/resource-capacity.js";
|
||||||
|
import { fmtEur } from "../lib/format-utils.js";
|
||||||
|
|
||||||
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
|
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
|
||||||
|
|
||||||
|
const PROJECT_SUMMARY_SELECT = {
|
||||||
|
id: true,
|
||||||
|
shortCode: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
client: { select: { name: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const PROJECT_SUMMARY_DETAIL_SELECT = {
|
||||||
|
...PROJECT_SUMMARY_SELECT,
|
||||||
|
budgetCents: true,
|
||||||
|
winProbability: true,
|
||||||
|
_count: { select: { assignments: true, estimates: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const PROJECT_IDENTIFIER_SELECT = {
|
||||||
|
id: true,
|
||||||
|
shortCode: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const PROJECT_DETAIL_SELECT = {
|
||||||
|
...PROJECT_IDENTIFIER_SELECT,
|
||||||
|
id: true,
|
||||||
|
shortCode: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
orderType: true,
|
||||||
|
allocationType: true,
|
||||||
|
budgetCents: true,
|
||||||
|
winProbability: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
responsiblePerson: true,
|
||||||
|
client: { select: { name: true } },
|
||||||
|
utilizationCategory: { select: { code: true, name: true } },
|
||||||
|
_count: { select: { assignments: true, estimates: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function runProjectBackgroundEffect(
|
||||||
|
effectName: string,
|
||||||
|
execute: () => unknown,
|
||||||
|
metadata: Record<string, unknown> = {},
|
||||||
|
): void {
|
||||||
|
void Promise.resolve()
|
||||||
|
.then(execute)
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
{ err: error, effectName, ...metadata },
|
||||||
|
"Project background side effect failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateDashboardCacheInBackground(): void {
|
||||||
|
runProjectBackgroundEffect("invalidateDashboardCache", () => invalidateDashboardCache());
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchProjectWebhookInBackground(
|
||||||
|
db: TRPCContext["db"],
|
||||||
|
event: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
runProjectBackgroundEffect(
|
||||||
|
"dispatchWebhooks",
|
||||||
|
() => dispatchWebhooks(db, event, payload),
|
||||||
|
{ event },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: Date | null): string | null {
|
||||||
|
return value ? value.toISOString().slice(0, 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProjectSummary(project: {
|
||||||
|
id: string;
|
||||||
|
shortCode: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
startDate: Date | null;
|
||||||
|
endDate: Date | null;
|
||||||
|
client: { name: string } | null;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: project.id,
|
||||||
|
code: project.shortCode,
|
||||||
|
name: project.name,
|
||||||
|
status: project.status,
|
||||||
|
start: formatDate(project.startDate),
|
||||||
|
end: formatDate(project.endDate),
|
||||||
|
client: project.client?.name ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProjectSummaryDetail(project: {
|
||||||
|
id: string;
|
||||||
|
shortCode: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
budgetCents: number | null;
|
||||||
|
winProbability: number;
|
||||||
|
startDate: Date | null;
|
||||||
|
endDate: Date | null;
|
||||||
|
client: { name: string } | null;
|
||||||
|
_count: { assignments: number; estimates: number };
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: project.id,
|
||||||
|
code: project.shortCode,
|
||||||
|
name: project.name,
|
||||||
|
status: project.status,
|
||||||
|
budget: project.budgetCents && project.budgetCents > 0 ? fmtEur(project.budgetCents) : "Not set",
|
||||||
|
winProbability: `${project.winProbability}%`,
|
||||||
|
start: formatDate(project.startDate),
|
||||||
|
end: formatDate(project.endDate),
|
||||||
|
client: project.client?.name ?? null,
|
||||||
|
assignmentCount: project._count.assignments,
|
||||||
|
estimateCount: project._count.estimates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProjectDetail(
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
shortCode: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
orderType: string;
|
||||||
|
allocationType: string;
|
||||||
|
budgetCents: number | null;
|
||||||
|
winProbability: number;
|
||||||
|
startDate: Date | null;
|
||||||
|
endDate: Date | null;
|
||||||
|
responsiblePerson: string | null;
|
||||||
|
client: { name: string } | null;
|
||||||
|
utilizationCategory: { code: string; name: string } | null;
|
||||||
|
_count: { assignments: number; estimates: number };
|
||||||
|
},
|
||||||
|
topAssignments: Array<{
|
||||||
|
resource: { displayName: string; eid: string };
|
||||||
|
role: string | null;
|
||||||
|
status: string;
|
||||||
|
hoursPerDay: number;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id: project.id,
|
||||||
|
code: project.shortCode,
|
||||||
|
name: project.name,
|
||||||
|
status: project.status,
|
||||||
|
orderType: project.orderType,
|
||||||
|
allocationType: project.allocationType,
|
||||||
|
budget: project.budgetCents && project.budgetCents > 0 ? fmtEur(project.budgetCents) : "Not set",
|
||||||
|
budgetCents: project.budgetCents,
|
||||||
|
winProbability: `${project.winProbability}%`,
|
||||||
|
start: formatDate(project.startDate),
|
||||||
|
end: formatDate(project.endDate),
|
||||||
|
responsible: project.responsiblePerson,
|
||||||
|
client: project.client?.name ?? null,
|
||||||
|
category: project.utilizationCategory?.name ?? null,
|
||||||
|
assignmentCount: project._count.assignments,
|
||||||
|
estimateCount: project._count.estimates,
|
||||||
|
topAllocations: topAssignments.map((assignment) => ({
|
||||||
|
resource: assignment.resource.displayName,
|
||||||
|
eid: assignment.resource.eid,
|
||||||
|
role: assignment.role ?? null,
|
||||||
|
status: assignment.status,
|
||||||
|
hoursPerDay: assignment.hoursPerDay,
|
||||||
|
start: formatDate(assignment.startDate),
|
||||||
|
end: formatDate(assignment.endDate),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readProjectSummariesSnapshot(
|
||||||
|
ctx: Pick<TRPCContext, "db">,
|
||||||
|
input: {
|
||||||
|
search?: string | undefined;
|
||||||
|
status?: ProjectStatus | undefined;
|
||||||
|
limit: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const buildWhere = (search: string | undefined) => ({
|
||||||
|
...(input.status ? { status: input.status } : {}),
|
||||||
|
...(search
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: search, mode: "insensitive" as const } },
|
||||||
|
{ shortCode: { contains: search, mode: "insensitive" as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let projects = await ctx.db.project.findMany({
|
||||||
|
where: buildWhere(input.search),
|
||||||
|
select: PROJECT_SUMMARY_SELECT,
|
||||||
|
take: input.limit,
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (projects.length === 0 && input.search) {
|
||||||
|
const words = input.search.split(/[\s,._\-/]+/).filter((word) => word.length >= 2);
|
||||||
|
if (words.length > 1) {
|
||||||
|
const candidates = await ctx.db.project.findMany({
|
||||||
|
where: {
|
||||||
|
...(input.status ? { status: input.status } : {}),
|
||||||
|
OR: words.flatMap((word) => ([
|
||||||
|
{ name: { contains: word, mode: "insensitive" as const } },
|
||||||
|
{ shortCode: { contains: word, mode: "insensitive" as const } },
|
||||||
|
])),
|
||||||
|
},
|
||||||
|
select: PROJECT_SUMMARY_SELECT,
|
||||||
|
take: input.limit * 2,
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
projects = candidates
|
||||||
|
.map((project) => {
|
||||||
|
const haystack = `${project.name} ${project.shortCode}`.toLowerCase();
|
||||||
|
const matchCount = words.filter((word) => haystack.includes(word.toLowerCase())).length;
|
||||||
|
return { project, matchCount };
|
||||||
|
})
|
||||||
|
.sort((left, right) => right.matchCount - left.matchCount)
|
||||||
|
.slice(0, input.limit)
|
||||||
|
.map((entry) => entry.project);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: projects,
|
||||||
|
exactMatch: input.search
|
||||||
|
? projects.some((project) =>
|
||||||
|
project.name.toLowerCase().includes(input.search!.toLowerCase())
|
||||||
|
|| project.shortCode.toLowerCase().includes(input.search!.toLowerCase()))
|
||||||
|
: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readProjectSummaryDetailsSnapshot(
|
||||||
|
ctx: Pick<TRPCContext, "db">,
|
||||||
|
input: {
|
||||||
|
search?: string | undefined;
|
||||||
|
status?: ProjectStatus | undefined;
|
||||||
|
limit: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const buildWhere = (search: string | undefined) => ({
|
||||||
|
...(input.status ? { status: input.status } : {}),
|
||||||
|
...(search
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: search, mode: "insensitive" as const } },
|
||||||
|
{ shortCode: { contains: search, mode: "insensitive" as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let projects = await ctx.db.project.findMany({
|
||||||
|
where: buildWhere(input.search),
|
||||||
|
select: PROJECT_SUMMARY_DETAIL_SELECT,
|
||||||
|
take: input.limit,
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (projects.length === 0 && input.search) {
|
||||||
|
const words = input.search.split(/[\s,._\-/]+/).filter((word) => word.length >= 2);
|
||||||
|
if (words.length > 1) {
|
||||||
|
const candidates = await ctx.db.project.findMany({
|
||||||
|
where: {
|
||||||
|
...(input.status ? { status: input.status } : {}),
|
||||||
|
OR: words.flatMap((word) => ([
|
||||||
|
{ name: { contains: word, mode: "insensitive" as const } },
|
||||||
|
{ shortCode: { contains: word, mode: "insensitive" as const } },
|
||||||
|
])),
|
||||||
|
},
|
||||||
|
select: PROJECT_SUMMARY_DETAIL_SELECT,
|
||||||
|
take: input.limit * 2,
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
projects = candidates
|
||||||
|
.map((project) => {
|
||||||
|
const haystack = `${project.name} ${project.shortCode}`.toLowerCase();
|
||||||
|
const matchCount = words.filter((word) => haystack.includes(word.toLowerCase())).length;
|
||||||
|
return { project, matchCount };
|
||||||
|
})
|
||||||
|
.sort((left, right) => right.matchCount - left.matchCount)
|
||||||
|
.slice(0, input.limit)
|
||||||
|
.map((entry) => entry.project);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: projects,
|
||||||
|
exactMatch: input.search
|
||||||
|
? projects.some((project) =>
|
||||||
|
project.name.toLowerCase().includes(input.search!.toLowerCase())
|
||||||
|
|| project.shortCode.toLowerCase().includes(input.search!.toLowerCase()))
|
||||||
|
: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveProjectIdentifierSnapshot(
|
||||||
|
ctx: Pick<TRPCContext, "db">,
|
||||||
|
identifier: string,
|
||||||
|
) {
|
||||||
|
let project = await ctx.db.project.findUnique({
|
||||||
|
where: { id: identifier },
|
||||||
|
select: PROJECT_IDENTIFIER_SELECT,
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
project = await ctx.db.project.findUnique({
|
||||||
|
where: { shortCode: identifier },
|
||||||
|
select: PROJECT_IDENTIFIER_SELECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!project) {
|
||||||
|
project = await ctx.db.project.findFirst({
|
||||||
|
where: { name: { equals: identifier, mode: "insensitive" } },
|
||||||
|
select: PROJECT_IDENTIFIER_SELECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!project) {
|
||||||
|
project = await ctx.db.project.findFirst({
|
||||||
|
where: { name: { contains: identifier, mode: "insensitive" } },
|
||||||
|
select: PROJECT_IDENTIFIER_SELECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readProjectByIdentifierDetailSnapshot(
|
||||||
|
ctx: Pick<TRPCContext, "db">,
|
||||||
|
identifier: string,
|
||||||
|
) {
|
||||||
|
const projectIdentity = await resolveProjectIdentifierSnapshot(ctx, identifier);
|
||||||
|
const project = await ctx.db.project.findUnique({
|
||||||
|
where: { id: projectIdentity.id },
|
||||||
|
select: PROJECT_DETAIL_SELECT,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const topAssignments = await ctx.db.assignment.findMany({
|
||||||
|
where: {
|
||||||
|
projectId: project.id,
|
||||||
|
status: { not: "CANCELLED" },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
resource: { select: { displayName: true, eid: true } },
|
||||||
|
role: true,
|
||||||
|
status: true,
|
||||||
|
hoursPerDay: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
},
|
||||||
|
take: 10,
|
||||||
|
orderBy: { startDate: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
topAssignments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const projectRouter = createTRPCRouter({
|
export const projectRouter = createTRPCRouter({
|
||||||
list: protectedProcedure
|
resolveByIdentifier: protectedProcedure
|
||||||
|
.input(z.object({ identifier: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const select = {
|
||||||
|
id: true,
|
||||||
|
shortCode: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
responsiblePerson: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let project = await ctx.db.project.findUnique({
|
||||||
|
where: { id: input.identifier },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
project = await ctx.db.project.findUnique({
|
||||||
|
where: { shortCode: input.identifier },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!project) {
|
||||||
|
project = await ctx.db.project.findFirst({
|
||||||
|
where: { name: { equals: input.identifier, mode: "insensitive" } },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!project) {
|
||||||
|
project = await ctx.db.project.findFirst({
|
||||||
|
where: { name: { contains: input.identifier, mode: "insensitive" } },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
}),
|
||||||
|
|
||||||
|
searchSummaries: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
status: z.nativeEnum(ProjectStatus).optional(),
|
||||||
|
limit: z.number().int().min(1).max(50).default(20),
|
||||||
|
}))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const { items, exactMatch } = await readProjectSummariesSnapshot(ctx, input);
|
||||||
|
const formatted = items.map(mapProjectSummary);
|
||||||
|
if (items.length > 0 && input.search && !exactMatch) {
|
||||||
|
return {
|
||||||
|
suggestions: formatted,
|
||||||
|
note: `No exact match for "${input.search}". These projects match some of the search terms:`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return formatted;
|
||||||
|
}),
|
||||||
|
|
||||||
|
searchSummariesDetail: controllerProcedure
|
||||||
|
.input(z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
status: z.nativeEnum(ProjectStatus).optional(),
|
||||||
|
limit: z.number().int().min(1).max(50).default(20),
|
||||||
|
}))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const { items, exactMatch } = await readProjectSummaryDetailsSnapshot(ctx, input);
|
||||||
|
const formatted = items.map(mapProjectSummaryDetail);
|
||||||
|
if (items.length > 0 && input.search && !exactMatch) {
|
||||||
|
return {
|
||||||
|
suggestions: formatted,
|
||||||
|
note: `No exact match for "${input.search}". These projects match some of the search terms:`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return formatted;
|
||||||
|
}),
|
||||||
|
|
||||||
|
list: controllerProcedure
|
||||||
.input(
|
.input(
|
||||||
PaginationInputSchema.extend({
|
PaginationInputSchema.extend({
|
||||||
status: z.nativeEnum(ProjectStatus).optional(),
|
status: z.nativeEnum(ProjectStatus).optional(),
|
||||||
@@ -90,7 +554,7 @@ export const projectRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getById: protectedProcedure
|
getById: controllerProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const [project, planningRead] = await Promise.all([
|
const [project, planningRead] = await Promise.all([
|
||||||
@@ -113,7 +577,18 @@ export const projectRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getShoringRatio: protectedProcedure
|
getByIdentifier: protectedProcedure
|
||||||
|
.input(z.object({ identifier: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => resolveProjectIdentifierSnapshot(ctx, input.identifier)),
|
||||||
|
|
||||||
|
getByIdentifierDetail: controllerProcedure
|
||||||
|
.input(z.object({ identifier: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const project = await readProjectByIdentifierDetailSnapshot(ctx, input.identifier);
|
||||||
|
return mapProjectDetail(project, project.topAssignments);
|
||||||
|
}),
|
||||||
|
|
||||||
|
getShoringRatio: controllerProcedure
|
||||||
.input(z.object({ projectId: z.string() }))
|
.input(z.object({ projectId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const project = await ctx.db.project.findUnique({
|
const project = await ctx.db.project.findUnique({
|
||||||
@@ -241,8 +716,8 @@ export const projectRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
void invalidateDashboardCache();
|
invalidateDashboardCacheInBackground();
|
||||||
void dispatchWebhooks(ctx.db, "project.created", {
|
dispatchProjectWebhookInBackground(ctx.db, "project.created", {
|
||||||
id: project.id,
|
id: project.id,
|
||||||
shortCode: project.shortCode,
|
shortCode: project.shortCode,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
@@ -302,7 +777,7 @@ export const projectRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
void invalidateDashboardCache();
|
invalidateDashboardCacheInBackground();
|
||||||
return updated;
|
return updated;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -314,8 +789,8 @@ export const projectRouter = createTRPCRouter({
|
|||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
data: { status: input.status },
|
data: { status: input.status },
|
||||||
});
|
});
|
||||||
void invalidateDashboardCache();
|
invalidateDashboardCacheInBackground();
|
||||||
void dispatchWebhooks(ctx.db, "project.status_changed", {
|
dispatchProjectWebhookInBackground(ctx.db, "project.status_changed", {
|
||||||
id: result.id,
|
id: result.id,
|
||||||
shortCode: result.shortCode,
|
shortCode: result.shortCode,
|
||||||
name: result.name,
|
name: result.name,
|
||||||
@@ -348,7 +823,7 @@ export const projectRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
void invalidateDashboardCache();
|
invalidateDashboardCacheInBackground();
|
||||||
return { count: updated.length };
|
return { count: updated.length };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -454,7 +929,7 @@ export const projectRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
void invalidateDashboardCache();
|
invalidateDashboardCacheInBackground();
|
||||||
return { id: input.id, name: project.name };
|
return { id: input.id, name: project.name };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -494,7 +969,7 @@ export const projectRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
void invalidateDashboardCache();
|
invalidateDashboardCacheInBackground();
|
||||||
return { count: projects.length };
|
return { count: projects.length };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { findUniqueOrThrow } from "../db/helpers.js";
|
|||||||
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
|
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
|
||||||
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
|
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
|
||||||
import { createAuditEntry } from "../lib/audit.js";
|
import { createAuditEntry } from "../lib/audit.js";
|
||||||
|
import { fmtEur } from "../lib/format-utils.js";
|
||||||
|
|
||||||
const lineSelect = {
|
const lineSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -30,6 +31,118 @@ const lineSelect = {
|
|||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
async function lookupBestRateMatch(
|
||||||
|
db: Pick<import("@capakraken/db").PrismaClient, "rateCard" | "role">,
|
||||||
|
input: {
|
||||||
|
clientId?: string | undefined;
|
||||||
|
chapter?: string | undefined;
|
||||||
|
managementLevelId?: string | undefined;
|
||||||
|
roleName?: string | undefined;
|
||||||
|
seniority?: string | undefined;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const rateCardWhere: Prisma.RateCardWhereInput = { isActive: true };
|
||||||
|
if (input.clientId) {
|
||||||
|
rateCardWhere.OR = [
|
||||||
|
{ clientId: input.clientId },
|
||||||
|
{ clientId: null },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateCards = await db.rateCard.findMany({
|
||||||
|
where: rateCardWhere,
|
||||||
|
include: {
|
||||||
|
lines: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
chapter: true,
|
||||||
|
seniority: true,
|
||||||
|
costRateCents: true,
|
||||||
|
billRateCents: true,
|
||||||
|
role: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
client: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ effectiveFrom: "desc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rateCards.length === 0) {
|
||||||
|
return {
|
||||||
|
bestMatch: null,
|
||||||
|
alternatives: [],
|
||||||
|
totalCandidates: 0,
|
||||||
|
message: "No active rate cards found.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let roleId: string | undefined;
|
||||||
|
if (input.roleName) {
|
||||||
|
const role = await db.role.findFirst({
|
||||||
|
where: { name: { contains: input.roleName, mode: "insensitive" } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (role) roleId = role.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoredLines: Array<{
|
||||||
|
rateCardName: string;
|
||||||
|
clientId: string | null;
|
||||||
|
clientName: string | null;
|
||||||
|
lineId: string;
|
||||||
|
chapter: string | null;
|
||||||
|
seniority: string | null;
|
||||||
|
roleName: string | null;
|
||||||
|
costRateCents: number;
|
||||||
|
billRateCents: number | null;
|
||||||
|
score: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const card of rateCards) {
|
||||||
|
for (const line of card.lines) {
|
||||||
|
let score = 0;
|
||||||
|
let mismatch = false;
|
||||||
|
|
||||||
|
if (roleId && line.role) {
|
||||||
|
if (line.role.id === roleId) score += 4;
|
||||||
|
else mismatch = true;
|
||||||
|
}
|
||||||
|
if (input.chapter && line.chapter) {
|
||||||
|
if (line.chapter.toLowerCase() === input.chapter.toLowerCase()) score += 2;
|
||||||
|
else mismatch = true;
|
||||||
|
}
|
||||||
|
if (input.seniority && line.seniority) {
|
||||||
|
if (line.seniority.toLowerCase() === input.seniority.toLowerCase()) score += 1;
|
||||||
|
else mismatch = true;
|
||||||
|
}
|
||||||
|
if (input.clientId && card.client?.id === input.clientId) score += 3;
|
||||||
|
|
||||||
|
if (!mismatch) {
|
||||||
|
scoredLines.push({
|
||||||
|
rateCardName: card.name,
|
||||||
|
clientId: card.client?.id ?? null,
|
||||||
|
clientName: card.client?.name ?? null,
|
||||||
|
lineId: line.id,
|
||||||
|
chapter: line.chapter,
|
||||||
|
seniority: line.seniority,
|
||||||
|
roleName: line.role?.name ?? null,
|
||||||
|
costRateCents: line.costRateCents,
|
||||||
|
billRateCents: line.billRateCents ?? null,
|
||||||
|
score,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scoredLines.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bestMatch: scoredLines[0] ?? null,
|
||||||
|
alternatives: scoredLines.slice(1, 4),
|
||||||
|
totalCandidates: scoredLines.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const rateCardRouter = createTRPCRouter({
|
export const rateCardRouter = createTRPCRouter({
|
||||||
list: controllerProcedure
|
list: controllerProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -92,6 +205,131 @@ export const rateCardRouter = createTRPCRouter({
|
|||||||
return rateCard;
|
return rateCard;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
lookupBestMatch: controllerProcedure
|
||||||
|
.input(z.object({
|
||||||
|
clientId: z.string().optional(),
|
||||||
|
chapter: z.string().optional(),
|
||||||
|
managementLevelId: z.string().optional(),
|
||||||
|
roleName: z.string().optional(),
|
||||||
|
seniority: z.string().optional(),
|
||||||
|
}))
|
||||||
|
.query(async ({ ctx, input }) => lookupBestRateMatch(ctx.db, input)),
|
||||||
|
|
||||||
|
resolveBestRate: controllerProcedure
|
||||||
|
.input(z.object({
|
||||||
|
resourceId: z.string().optional(),
|
||||||
|
roleName: z.string().optional(),
|
||||||
|
date: z.coerce.date().optional(),
|
||||||
|
}))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const effectiveAt = input.date ?? new Date();
|
||||||
|
|
||||||
|
if (input.resourceId) {
|
||||||
|
const resource = await findUniqueOrThrow(
|
||||||
|
ctx.db.resource.findUnique({
|
||||||
|
where: { id: input.resourceId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
chapter: true,
|
||||||
|
areaRole: { select: { name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"Resource",
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolved = await lookupBestRateMatch(ctx.db, {
|
||||||
|
...(resource.chapter ? { chapter: resource.chapter } : {}),
|
||||||
|
...(resource.areaRole?.name ? { roleName: resource.areaRole.name } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resolved.bestMatch) {
|
||||||
|
return {
|
||||||
|
rateCard: resolved.bestMatch.rateCardName,
|
||||||
|
resource: resource.displayName,
|
||||||
|
rate: fmtEur(resolved.bestMatch.costRateCents),
|
||||||
|
rateCents: resolved.bestMatch.costRateCents,
|
||||||
|
matchedBy: resolved.bestMatch.roleName ? `role: ${resolved.bestMatch.roleName}` : "best_match",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.roleName) {
|
||||||
|
const match = await lookupBestRateMatch(ctx.db, { roleName: input.roleName });
|
||||||
|
if (match.bestMatch) {
|
||||||
|
return {
|
||||||
|
rateCard: match.bestMatch.rateCardName,
|
||||||
|
rate: fmtEur(match.bestMatch.costRateCents),
|
||||||
|
rateCents: match.bestMatch.costRateCents,
|
||||||
|
matchedBy: match.bestMatch.roleName ? `role: ${match.bestMatch.roleName}` : "best_match",
|
||||||
|
alternatives: match.alternatives.map((alternative) => ({
|
||||||
|
rateCard: alternative.rateCardName,
|
||||||
|
role: alternative.roleName,
|
||||||
|
chapter: alternative.chapter,
|
||||||
|
seniority: alternative.seniority,
|
||||||
|
costRate: fmtEur(alternative.costRateCents),
|
||||||
|
billRate: alternative.billRateCents != null ? fmtEur(alternative.billRateCents) : null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (match.totalCandidates === 0) {
|
||||||
|
return { error: "No matching rate card line found." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = await ctx.db.rateCard.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
OR: [
|
||||||
|
{ effectiveFrom: null },
|
||||||
|
{ effectiveFrom: { lte: effectiveAt } },
|
||||||
|
],
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ effectiveTo: null },
|
||||||
|
{ effectiveTo: { gte: effectiveAt } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: { select: { lines: true } },
|
||||||
|
client: { select: { id: true, name: true, code: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ isActive: "desc" }, { effectiveFrom: "desc" }, { name: "asc" }],
|
||||||
|
});
|
||||||
|
const card = cards[0];
|
||||||
|
if (!card) {
|
||||||
|
return { error: "No active rate card found for the given date." };
|
||||||
|
}
|
||||||
|
const detail = await findUniqueOrThrow(
|
||||||
|
ctx.db.rateCard.findUnique({
|
||||||
|
where: { id: card.id },
|
||||||
|
include: {
|
||||||
|
client: { select: { id: true, name: true, code: true } },
|
||||||
|
lines: {
|
||||||
|
select: lineSelect,
|
||||||
|
orderBy: [{ chapter: "asc" }, { seniority: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"Rate card",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rateCard: detail.name,
|
||||||
|
lines: detail.lines.map((line) => ({
|
||||||
|
role: line.role?.name ?? null,
|
||||||
|
seniority: line.seniority,
|
||||||
|
chapter: line.chapter,
|
||||||
|
location: line.location,
|
||||||
|
costRate: fmtEur(line.costRateCents),
|
||||||
|
billRate: line.billRateCents != null ? fmtEur(line.billRateCents) : null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
create: managerProcedure
|
create: managerProcedure
|
||||||
.input(CreateRateCardSchema)
|
.input(CreateRateCardSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -362,7 +600,7 @@ export const rateCardRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// ─── Rate resolution ───────────────────────────────────────────────────────
|
// ─── Rate resolution ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
resolveRate: controllerProcedure
|
resolveRateLine: controllerProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
rateCardId: z.string(),
|
rateCardId: z.string(),
|
||||||
roleId: z.string().optional(),
|
roleId: z.string().optional(),
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ const ENTITY_MAP = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type EntityKey = keyof typeof ENTITY_MAP;
|
type EntityKey = keyof typeof ENTITY_MAP;
|
||||||
|
const PERIOD_MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/;
|
||||||
|
|
||||||
/** Allowlist of top-level scalar fields per entity that can be filtered/sorted on. */
|
/** Allowlist of top-level scalar fields per entity that can be filtered/sorted on. */
|
||||||
const ALLOWED_SCALAR_FIELDS: Record<EntityKey, Set<string>> = {
|
const ALLOWED_SCALAR_FIELDS: Record<EntityKey, Set<string>> = {
|
||||||
@@ -190,6 +191,158 @@ function getValidScalarField(entity: EntityKey, field: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getColumnDef(entity: EntityKey, columnKey: string): ColumnDef | undefined {
|
||||||
|
return COLUMN_MAP[entity].find((column) => column.key === columnKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertKnownColumns(entity: EntityKey, columns: string[]): void {
|
||||||
|
const invalidColumns = columns.filter((column) => !getColumnDef(entity, column));
|
||||||
|
if (invalidColumns.length > 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Unknown columns for ${entity}: ${invalidColumns.join(", ")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidFilterField(entity: EntityKey, field: string): string {
|
||||||
|
if (entity === "resource_month") {
|
||||||
|
if (!getColumnDef(entity, field)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Unknown filter field for ${entity}: ${field}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validField = getValidScalarField(entity, field);
|
||||||
|
if (!validField) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Unsupported filter field for ${entity}: ${field}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return validField;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidSortField(entity: EntityKey, field: string): void {
|
||||||
|
if (entity === "resource_month") {
|
||||||
|
if (!getColumnDef(entity, field)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Unknown sort field for ${entity}: ${field}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getValidScalarField(entity, field)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Unsupported sort field for ${entity}: ${field}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidGroupField(entity: EntityKey, field: string): void {
|
||||||
|
const knownField =
|
||||||
|
entity === "resource_month"
|
||||||
|
? getColumnDef(entity, field)?.key
|
||||||
|
: getValidScalarField(entity, field);
|
||||||
|
if (!knownField) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Unsupported group field for ${entity}: ${field}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFilterValueOrThrow(def: ColumnDef, value: string): unknown {
|
||||||
|
if (def.dataType === "number") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Invalid numeric filter value for ${def.key}: ${value}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (def.dataType === "boolean") {
|
||||||
|
if (value !== "true" && value !== "false") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Invalid boolean filter value for ${def.key}: ${value}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return value === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (def.dataType === "date") {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Invalid date filter value for ${def.key}: ${value}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateReportInput(input: ReportInput | z.infer<typeof ReportTemplateConfigSchema>): void {
|
||||||
|
assertKnownColumns(input.entity, input.columns);
|
||||||
|
|
||||||
|
if (input.periodMonth && !PERIOD_MONTH_PATTERN.test(input.periodMonth)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Invalid periodMonth: ${input.periodMonth}. Expected YYYY-MM with a month between 01 and 12.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.entity !== "resource_month" && input.periodMonth) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "periodMonth is only supported for resource_month reports",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.sortBy) {
|
||||||
|
assertValidSortField(input.entity, input.sortBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.groupBy) {
|
||||||
|
assertValidGroupField(input.entity, input.groupBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const filter of input.filters) {
|
||||||
|
const field = assertValidFilterField(input.entity, filter.field);
|
||||||
|
const def = getColumnDef(input.entity, field);
|
||||||
|
if (!def) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Unknown filter field for ${input.entity}: ${filter.field}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.op === "contains" || filter.op === "in") {
|
||||||
|
if (def.dataType !== "string") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Filter operator ${filter.op} is only supported for string fields like ${def.key}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
void parseFilterValueOrThrow(def, filter.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a Prisma `select` object from the requested columns.
|
* Build a Prisma `select` object from the requested columns.
|
||||||
* Always includes `id`. For relation columns like "country.name",
|
* Always includes `id`. For relation columns like "country.name",
|
||||||
@@ -254,24 +407,15 @@ function buildWhere(
|
|||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
|
|
||||||
for (const filter of filters) {
|
for (const filter of filters) {
|
||||||
const field = getValidScalarField(entity, filter.field);
|
const field = assertValidFilterField(entity, filter.field);
|
||||||
if (!field) continue;
|
const colDef = getColumnDef(entity, field);
|
||||||
|
if (!colDef) {
|
||||||
const entityColumns = COLUMN_MAP[entity];
|
throw new TRPCError({
|
||||||
const colDef = entityColumns.find((c) => c.key === field);
|
code: "BAD_REQUEST",
|
||||||
const dataType = colDef?.dataType ?? "string";
|
message: `Unknown filter field for ${entity}: ${filter.field}`,
|
||||||
|
});
|
||||||
// Parse value based on data type
|
|
||||||
let parsedValue: unknown = filter.value;
|
|
||||||
if (dataType === "number") {
|
|
||||||
parsedValue = Number(filter.value);
|
|
||||||
if (Number.isNaN(parsedValue as number)) continue;
|
|
||||||
} else if (dataType === "boolean") {
|
|
||||||
parsedValue = filter.value === "true";
|
|
||||||
} else if (dataType === "date") {
|
|
||||||
parsedValue = new Date(filter.value);
|
|
||||||
if (Number.isNaN((parsedValue as Date).getTime())) continue;
|
|
||||||
}
|
}
|
||||||
|
const parsedValue = parseFilterValueOrThrow(colDef, filter.value);
|
||||||
|
|
||||||
switch (filter.op) {
|
switch (filter.op) {
|
||||||
case "eq":
|
case "eq":
|
||||||
@@ -293,14 +437,28 @@ function buildWhere(
|
|||||||
where[field] = { lte: parsedValue };
|
where[field] = { lte: parsedValue };
|
||||||
break;
|
break;
|
||||||
case "contains":
|
case "contains":
|
||||||
if (dataType === "string") {
|
if (colDef.dataType !== "string") {
|
||||||
where[field] = { contains: filter.value, mode: "insensitive" };
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Filter operator contains is only supported for string fields like ${field}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
where[field] = { contains: filter.value, mode: "insensitive" };
|
||||||
break;
|
break;
|
||||||
case "in":
|
case "in":
|
||||||
if (dataType === "string") {
|
if (colDef.dataType !== "string") {
|
||||||
where[field] = { in: filter.value.split(",").map((v) => v.trim()) };
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Filter operator in is only supported for string fields like ${field}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
where[field] = { in: filter.value.split(",").map((v) => v.trim()) };
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Unsupported filter operator: ${filter.op}`,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,7 +513,7 @@ const ReportInputSchema = z.object({
|
|||||||
groupBy: z.string().optional(),
|
groupBy: z.string().optional(),
|
||||||
sortBy: z.string().optional(),
|
sortBy: z.string().optional(),
|
||||||
sortDir: z.enum(["asc", "desc"]).default("asc"),
|
sortDir: z.enum(["asc", "desc"]).default("asc"),
|
||||||
periodMonth: z.string().regex(/^\d{4}-\d{2}$/).optional(),
|
periodMonth: z.string().regex(PERIOD_MONTH_PATTERN).optional(),
|
||||||
limit: z.number().int().min(1).max(5000).default(50),
|
limit: z.number().int().min(1).max(5000).default(50),
|
||||||
offset: z.number().int().min(0).default(0),
|
offset: z.number().int().min(0).default(0),
|
||||||
});
|
});
|
||||||
@@ -440,6 +598,7 @@ export const reportRouter = createTRPCRouter({
|
|||||||
config: ReportTemplateConfigSchema,
|
config: ReportTemplateConfigSchema,
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
validateReportInput(input.config);
|
||||||
const reportTemplate = getReportTemplateDelegate(ctx.db);
|
const reportTemplate = getReportTemplateDelegate(ctx.db);
|
||||||
const payload = input.config as unknown as Prisma.InputJsonValue;
|
const payload = input.config as unknown as Prisma.InputJsonValue;
|
||||||
const entity = toTemplateEntity(input.config.entity);
|
const entity = toTemplateEntity(input.config.entity);
|
||||||
@@ -568,6 +727,8 @@ async function executeReportQuery(
|
|||||||
db: any,
|
db: any,
|
||||||
input: ReportInput,
|
input: ReportInput,
|
||||||
): Promise<{ rows: Record<string, unknown>[]; columns: string[]; totalCount: number }> {
|
): Promise<{ rows: Record<string, unknown>[]; columns: string[]; totalCount: number }> {
|
||||||
|
validateReportInput(input);
|
||||||
|
|
||||||
if (input.entity === "resource_month") {
|
if (input.entity === "resource_month") {
|
||||||
return executeResourceMonthReport(db, input);
|
return executeResourceMonthReport(db, input);
|
||||||
}
|
}
|
||||||
@@ -579,9 +740,13 @@ async function executeReportQuery(
|
|||||||
let orderBy: Record<string, string> | undefined;
|
let orderBy: Record<string, string> | undefined;
|
||||||
if (sortBy) {
|
if (sortBy) {
|
||||||
const validField = getValidScalarField(entity, sortBy);
|
const validField = getValidScalarField(entity, sortBy);
|
||||||
if (validField) {
|
if (!validField) {
|
||||||
orderBy = { [validField]: sortDir };
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Unsupported sort field for ${entity}: ${sortBy}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
orderBy = { [validField]: sortDir };
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelDelegate = getModelDelegate(db, entity);
|
const modelDelegate = getModelDelegate(db, entity);
|
||||||
|
|||||||
+1207
-75
File diff suppressed because it is too large
Load Diff
@@ -80,6 +80,87 @@ export const roleRouter = createTRPCRouter({
|
|||||||
return attachPlanningEntryCounts(ctx.db, roles);
|
return attachPlanningEntryCounts(ctx.db, roles);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
resolveByIdentifier: protectedProcedure
|
||||||
|
.input(z.object({ identifier: z.string().trim().min(1) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const identifier = input.identifier.trim();
|
||||||
|
const select = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
color: true,
|
||||||
|
isActive: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let role = await ctx.db.role.findUnique({
|
||||||
|
where: { id: identifier },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
if (!role) {
|
||||||
|
role = await ctx.db.role.findUnique({
|
||||||
|
where: { name: identifier },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!role) {
|
||||||
|
role = await ctx.db.role.findFirst({
|
||||||
|
where: { name: { equals: identifier, mode: "insensitive" } },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!role) {
|
||||||
|
role = await ctx.db.role.findFirst({
|
||||||
|
where: { name: { contains: identifier, mode: "insensitive" } },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!role) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return role;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getByIdentifier: protectedProcedure
|
||||||
|
.input(z.object({ identifier: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const select = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
color: true,
|
||||||
|
isActive: true,
|
||||||
|
_count: { select: { resourceRoles: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let role = await ctx.db.role.findUnique({
|
||||||
|
where: { id: input.identifier },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
if (!role) {
|
||||||
|
role = await ctx.db.role.findUnique({
|
||||||
|
where: { name: input.identifier },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!role) {
|
||||||
|
role = await ctx.db.role.findFirst({
|
||||||
|
where: { name: { equals: input.identifier, mode: "insensitive" } },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!role) {
|
||||||
|
role = await ctx.db.role.findFirst({
|
||||||
|
where: { name: { contains: input.identifier, mode: "insensitive" } },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!role) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachSinglePlanningEntryCount(ctx.db, role);
|
||||||
|
}),
|
||||||
|
|
||||||
getById: protectedProcedure
|
getById: protectedProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
|||||||
+971
-290
File diff suppressed because it is too large
Load Diff
@@ -8,10 +8,10 @@ import {
|
|||||||
updateDemandRequirement,
|
updateDemandRequirement,
|
||||||
updateAllocationEntry,
|
updateAllocationEntry,
|
||||||
} from "@capakraken/application";
|
} from "@capakraken/application";
|
||||||
|
import { Prisma, VacationType } from "@capakraken/db";
|
||||||
import type { PrismaClient } from "@capakraken/db";
|
import type { PrismaClient } from "@capakraken/db";
|
||||||
import { calculateAllocation, computeBudgetStatus, validateShift, DEFAULT_CALCULATION_RULES } from "@capakraken/engine";
|
import { calculateAllocation, computeBudgetStatus, validateShift, DEFAULT_CALCULATION_RULES } from "@capakraken/engine";
|
||||||
import type { CalculationRule, AbsenceDay } from "@capakraken/shared";
|
import type { CalculationRule, AbsenceDay } from "@capakraken/shared";
|
||||||
import { VacationType } from "@capakraken/db";
|
|
||||||
import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@capakraken/shared";
|
import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@capakraken/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -28,8 +28,10 @@ import {
|
|||||||
} from "../sse/event-bus.js";
|
} from "../sse/event-bus.js";
|
||||||
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
|
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
|
||||||
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
|
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
|
||||||
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
import { controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||||
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
|
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||||
|
import { logger } from "../lib/logger.js";
|
||||||
|
import type { TRPCContext } from "../trpc.js";
|
||||||
|
|
||||||
type ShiftDbClient = Pick<
|
type ShiftDbClient = Pick<
|
||||||
PrismaClient,
|
PrismaClient,
|
||||||
@@ -52,6 +54,20 @@ export type TimelineEntriesFilters = {
|
|||||||
countryCodes?: string[] | undefined;
|
countryCodes?: string[] | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TimelineWindowFiltersSchema = z.object({
|
||||||
|
startDate: z.coerce.date(),
|
||||||
|
endDate: z.coerce.date(),
|
||||||
|
resourceIds: z.array(z.string()).optional(),
|
||||||
|
projectIds: z.array(z.string()).optional(),
|
||||||
|
clientIds: z.array(z.string()).optional(),
|
||||||
|
chapters: z.array(z.string()).optional(),
|
||||||
|
eids: z.array(z.string()).optional(),
|
||||||
|
countryCodes: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TimelineWindowFiltersInput = z.infer<typeof TimelineWindowFiltersSchema>;
|
||||||
|
type TimelineSelfServiceContext = Pick<TRPCContext, "db" | "dbUser">;
|
||||||
|
|
||||||
export function getAssignmentResourceIds(
|
export function getAssignmentResourceIds(
|
||||||
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
|
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
|
||||||
): string[] {
|
): string[] {
|
||||||
@@ -64,6 +80,215 @@ export function getAssignmentResourceIds(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtDate(value: Date | null | undefined): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUtcDate(year: number, month: number, day: number): Date {
|
||||||
|
return new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTimelineDateRange(input: {
|
||||||
|
startDate?: string | undefined;
|
||||||
|
endDate?: string | undefined;
|
||||||
|
durationDays?: number | undefined;
|
||||||
|
}): { startDate: Date; endDate: Date } {
|
||||||
|
const now = new Date();
|
||||||
|
const startDate = input.startDate
|
||||||
|
? new Date(`${input.startDate}T00:00:00.000Z`)
|
||||||
|
: createUtcDate(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||||
|
|
||||||
|
if (Number.isNaN(startDate.getTime())) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Invalid startDate: ${input.startDate}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDate = input.endDate
|
||||||
|
? new Date(`${input.endDate}T00:00:00.000Z`)
|
||||||
|
: createUtcDate(
|
||||||
|
startDate.getUTCFullYear(),
|
||||||
|
startDate.getUTCMonth(),
|
||||||
|
startDate.getUTCDate() + Math.max((input.durationDays ?? 21) - 1, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Number.isNaN(endDate.getTime())) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Invalid endDate: ${input.endDate}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (endDate < startDate) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "endDate must be on or after startDate.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { startDate, endDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStringList(values?: string[] | undefined): string[] | undefined {
|
||||||
|
const normalized = values
|
||||||
|
?.map((value) => value.trim())
|
||||||
|
.filter((value) => value.length > 0);
|
||||||
|
|
||||||
|
return normalized && normalized.length > 0 ? normalized : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTimelineFilters(input: {
|
||||||
|
resourceIds?: string[] | undefined;
|
||||||
|
projectIds?: string[] | undefined;
|
||||||
|
clientIds?: string[] | undefined;
|
||||||
|
chapters?: string[] | undefined;
|
||||||
|
eids?: string[] | undefined;
|
||||||
|
countryCodes?: string[] | undefined;
|
||||||
|
}): Omit<TimelineEntriesFilters, "startDate" | "endDate"> {
|
||||||
|
return {
|
||||||
|
resourceIds: normalizeStringList(input.resourceIds),
|
||||||
|
projectIds: normalizeStringList(input.projectIds),
|
||||||
|
clientIds: normalizeStringList(input.clientIds),
|
||||||
|
chapters: normalizeStringList(input.chapters),
|
||||||
|
eids: normalizeStringList(input.eids),
|
||||||
|
countryCodes: normalizeStringList(input.countryCodes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyTimelineEntriesView() {
|
||||||
|
return buildSplitAllocationReadModel({
|
||||||
|
demandRequirements: [],
|
||||||
|
assignments: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findOwnedTimelineResourceId(
|
||||||
|
ctx: TimelineSelfServiceContext,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!ctx.dbUser?.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = await ctx.db.resource.findFirst({
|
||||||
|
where: { userId: ctx.dbUser.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return resource?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSelfServiceTimelineInput(
|
||||||
|
ctx: TimelineSelfServiceContext,
|
||||||
|
input: TimelineWindowFiltersInput,
|
||||||
|
): Promise<TimelineEntriesFilters | null> {
|
||||||
|
const ownedResourceId = await findOwnedTimelineResourceId(ctx);
|
||||||
|
if (!ownedResourceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
resourceIds: [ownedResourceId],
|
||||||
|
projectIds: normalizeStringList(input.projectIds),
|
||||||
|
clientIds: normalizeStringList(input.clientIds),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeTimelineEntries(readModel: {
|
||||||
|
allocations: Array<{ projectId: string | null; resourceId: string | null }>;
|
||||||
|
demands: Array<{ projectId: string | null }>;
|
||||||
|
assignments: Array<{ projectId: string | null; resourceId: string | null }>;
|
||||||
|
}) {
|
||||||
|
const projectIds = new Set<string>();
|
||||||
|
const resourceIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const entry of [...readModel.allocations, ...readModel.demands, ...readModel.assignments]) {
|
||||||
|
if (entry.projectId) {
|
||||||
|
projectIds.add(entry.projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const assignment of [...readModel.allocations, ...readModel.assignments]) {
|
||||||
|
if (assignment.resourceId) {
|
||||||
|
resourceIds.add(assignment.resourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allocationCount: readModel.allocations.length,
|
||||||
|
demandCount: readModel.demands.length,
|
||||||
|
assignmentCount: readModel.assignments.length,
|
||||||
|
projectCount: projectIds.size,
|
||||||
|
resourceCount: resourceIds.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHolidayOverlays(
|
||||||
|
overlays: Array<{
|
||||||
|
id: string;
|
||||||
|
resourceId: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
note?: string | null;
|
||||||
|
scope?: string | null;
|
||||||
|
calendarName?: string | null;
|
||||||
|
sourceType?: string | null;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return overlays.map((overlay) => ({
|
||||||
|
id: overlay.id,
|
||||||
|
resourceId: overlay.resourceId,
|
||||||
|
startDate: fmtDate(overlay.startDate),
|
||||||
|
endDate: fmtDate(overlay.endDate),
|
||||||
|
note: overlay.note ?? null,
|
||||||
|
scope: overlay.scope ?? null,
|
||||||
|
calendarName: overlay.calendarName ?? null,
|
||||||
|
sourceType: overlay.sourceType ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeHolidayOverlays(
|
||||||
|
overlays: ReturnType<typeof formatHolidayOverlays>,
|
||||||
|
) {
|
||||||
|
const resourceIds = new Set<string>();
|
||||||
|
const byScope = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const overlay of overlays) {
|
||||||
|
resourceIds.add(overlay.resourceId);
|
||||||
|
const scope = overlay.scope ?? "UNKNOWN";
|
||||||
|
byScope.set(scope, (byScope.get(scope) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
overlayCount: overlays.length,
|
||||||
|
holidayResourceCount: resourceIds.size,
|
||||||
|
byScope: [...byScope.entries()]
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([scope, count]) => ({ scope, count })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangesOverlap(
|
||||||
|
leftStart: Date,
|
||||||
|
leftEnd: Date,
|
||||||
|
rightStart: Date,
|
||||||
|
rightEnd: Date,
|
||||||
|
): boolean {
|
||||||
|
return leftStart <= rightEnd && rightStart <= leftEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDate(value: Date | string): Date {
|
||||||
|
return value instanceof Date ? value : new Date(value);
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadTimelineEntriesReadModel(
|
export async function loadTimelineEntriesReadModel(
|
||||||
db: TimelineEntriesDbClient,
|
db: TimelineEntriesDbClient,
|
||||||
input: TimelineEntriesFilters,
|
input: TimelineEntriesFilters,
|
||||||
@@ -147,6 +372,14 @@ export async function loadTimelineHolidayOverlays(
|
|||||||
input: TimelineEntriesFilters,
|
input: TimelineEntriesFilters,
|
||||||
) {
|
) {
|
||||||
const readModel = await loadTimelineEntriesReadModel(db, input);
|
const readModel = await loadTimelineEntriesReadModel(db, input);
|
||||||
|
return loadTimelineHolidayOverlaysForReadModel(db, input, readModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTimelineHolidayOverlaysForReadModel(
|
||||||
|
db: TimelineEntriesDbClient,
|
||||||
|
input: TimelineEntriesFilters,
|
||||||
|
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
|
||||||
|
) {
|
||||||
const resourceIds = [...new Set(
|
const resourceIds = [...new Set(
|
||||||
readModel.assignments
|
readModel.assignments
|
||||||
.map((assignment) => assignment.resourceId)
|
.map((assignment) => assignment.resourceId)
|
||||||
@@ -380,17 +613,56 @@ function anonymizeResourceOnEntry<T extends { resource?: { id: string } | null }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Load active calculation rules from DB, falling back to defaults if none configured. */
|
/** Load active calculation rules from DB, falling back to defaults if none configured. */
|
||||||
|
function isMissingOptionalTableError(error: unknown, tableHints: string[]): boolean {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
if (error.code !== "P2021") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const table = typeof error.meta?.table === "string" ? error.meta.table.toLowerCase() : "";
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
return tableHints.some((hint) => table.includes(hint) || message.includes(hint));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error !== "object" || error === null || !("code" in error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = error as {
|
||||||
|
code?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
meta?: { table?: unknown };
|
||||||
|
};
|
||||||
|
const code = typeof candidate.code === "string" ? candidate.code : "";
|
||||||
|
if (code !== "P2021") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const table = typeof candidate.meta?.table === "string" ? candidate.meta.table.toLowerCase() : "";
|
||||||
|
const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : "";
|
||||||
|
return tableHints.some((hint) => table.includes(hint) || message.includes(hint));
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCalculationRules(db: PrismaClient): Promise<CalculationRule[]> {
|
async function loadCalculationRules(db: PrismaClient): Promise<CalculationRule[]> {
|
||||||
|
const calculationRuleModel = (db as PrismaClient & {
|
||||||
|
calculationRule?: { findMany?: (args: unknown) => Promise<unknown[]> };
|
||||||
|
}).calculationRule;
|
||||||
|
|
||||||
|
if (!calculationRuleModel || typeof calculationRuleModel.findMany !== "function") {
|
||||||
|
return DEFAULT_CALCULATION_RULES;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rules = await db.calculationRule.findMany({
|
const rules = await calculationRuleModel.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
orderBy: [{ priority: "desc" }],
|
orderBy: [{ priority: "desc" }],
|
||||||
});
|
});
|
||||||
if (rules.length > 0) {
|
if (rules.length > 0) {
|
||||||
return rules as unknown as CalculationRule[];
|
return rules as unknown as CalculationRule[];
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
// table may not exist yet
|
if (!isMissingOptionalTableError(error, ["calculationrule", "calculation_rule", "calculation_rules"])) {
|
||||||
|
logger.error({ err: error }, "Failed to load active calculation rules for timeline");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return DEFAULT_CALCULATION_RULES;
|
return DEFAULT_CALCULATION_RULES;
|
||||||
}
|
}
|
||||||
@@ -440,8 +712,14 @@ async function buildAbsenceDays(
|
|||||||
cur.setDate(cur.getDate() + 1);
|
cur.setDate(cur.getDate() + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
// vacation table may not exist yet
|
if (!isMissingOptionalTableError(error, ["vacation", "vacations"])) {
|
||||||
|
logger.error(
|
||||||
|
{ err: error, resourceId, startDate, endDate },
|
||||||
|
"Failed to load timeline absence days",
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { absenceDays, legacyVacationDates };
|
return { absenceDays, legacyVacationDates };
|
||||||
@@ -452,38 +730,16 @@ export const timelineRouter = createTRPCRouter({
|
|||||||
* Get all timeline entries (projects + allocations) for a date range.
|
* Get all timeline entries (projects + allocations) for a date range.
|
||||||
* Includes project startDate, endDate, staffingReqs for demand overlay.
|
* Includes project startDate, endDate, staffingReqs for demand overlay.
|
||||||
*/
|
*/
|
||||||
getEntries: protectedProcedure
|
getEntries: controllerProcedure
|
||||||
.input(
|
.input(TimelineWindowFiltersSchema)
|
||||||
z.object({
|
|
||||||
startDate: z.coerce.date(),
|
|
||||||
endDate: z.coerce.date(),
|
|
||||||
resourceIds: z.array(z.string()).optional(),
|
|
||||||
projectIds: z.array(z.string()).optional(),
|
|
||||||
clientIds: z.array(z.string()).optional(),
|
|
||||||
chapters: z.array(z.string()).optional(),
|
|
||||||
eids: z.array(z.string()).optional(),
|
|
||||||
countryCodes: z.array(z.string()).optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
|
const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
|
||||||
const directory = await getAnonymizationDirectory(ctx.db);
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
return readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory));
|
return readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getEntriesView: protectedProcedure
|
getEntriesView: controllerProcedure
|
||||||
.input(
|
.input(TimelineWindowFiltersSchema)
|
||||||
z.object({
|
|
||||||
startDate: z.coerce.date(),
|
|
||||||
endDate: z.coerce.date(),
|
|
||||||
resourceIds: z.array(z.string()).optional(),
|
|
||||||
projectIds: z.array(z.string()).optional(),
|
|
||||||
clientIds: z.array(z.string()).optional(),
|
|
||||||
chapters: z.array(z.string()).optional(),
|
|
||||||
eids: z.array(z.string()).optional(),
|
|
||||||
countryCodes: z.array(z.string()).optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const [readModel, directory] = await Promise.all([
|
const [readModel, directory] = await Promise.all([
|
||||||
loadTimelineEntriesReadModel(ctx.db, input),
|
loadTimelineEntriesReadModel(ctx.db, input),
|
||||||
@@ -497,11 +753,47 @@ export const timelineRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getHolidayOverlays: protectedProcedure
|
getMyEntriesView: protectedProcedure
|
||||||
|
.input(TimelineWindowFiltersSchema)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input);
|
||||||
|
if (!selfServiceInput) {
|
||||||
|
return createEmptyTimelineEntriesView();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [readModel, directory] = await Promise.all([
|
||||||
|
loadTimelineEntriesReadModel(ctx.db, selfServiceInput),
|
||||||
|
getAnonymizationDirectory(ctx.db),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...readModel,
|
||||||
|
allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
|
||||||
|
assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
getHolidayOverlays: controllerProcedure
|
||||||
|
.input(TimelineWindowFiltersSchema)
|
||||||
|
.query(async ({ ctx, input }) => loadTimelineHolidayOverlays(ctx.db, input)),
|
||||||
|
|
||||||
|
getMyHolidayOverlays: protectedProcedure
|
||||||
|
.input(TimelineWindowFiltersSchema)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input);
|
||||||
|
if (!selfServiceInput) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadTimelineHolidayOverlays(ctx.db, selfServiceInput);
|
||||||
|
}),
|
||||||
|
|
||||||
|
getEntriesDetail: controllerProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
startDate: z.coerce.date(),
|
startDate: z.string().optional(),
|
||||||
endDate: z.coerce.date(),
|
endDate: z.string().optional(),
|
||||||
|
durationDays: z.number().int().min(1).max(366).optional(),
|
||||||
resourceIds: z.array(z.string()).optional(),
|
resourceIds: z.array(z.string()).optional(),
|
||||||
projectIds: z.array(z.string()).optional(),
|
projectIds: z.array(z.string()).optional(),
|
||||||
clientIds: z.array(z.string()).optional(),
|
clientIds: z.array(z.string()).optional(),
|
||||||
@@ -510,7 +802,73 @@ export const timelineRouter = createTRPCRouter({
|
|||||||
countryCodes: z.array(z.string()).optional(),
|
countryCodes: z.array(z.string()).optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => loadTimelineHolidayOverlays(ctx.db, input)),
|
.query(async ({ ctx, input }) => {
|
||||||
|
const { startDate, endDate } = createTimelineDateRange(input);
|
||||||
|
const filters = createTimelineFilters(input);
|
||||||
|
const timelineInput = { ...filters, startDate, endDate };
|
||||||
|
|
||||||
|
const [readModel, directory] = await Promise.all([
|
||||||
|
loadTimelineEntriesReadModel(ctx.db, timelineInput),
|
||||||
|
getAnonymizationDirectory(ctx.db),
|
||||||
|
]);
|
||||||
|
const holidayOverlays = await loadTimelineHolidayOverlaysForReadModel(
|
||||||
|
ctx.db,
|
||||||
|
timelineInput,
|
||||||
|
readModel,
|
||||||
|
);
|
||||||
|
const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays);
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: {
|
||||||
|
startDate: fmtDate(startDate),
|
||||||
|
endDate: fmtDate(endDate),
|
||||||
|
},
|
||||||
|
filters,
|
||||||
|
summary: {
|
||||||
|
...summarizeTimelineEntries(readModel),
|
||||||
|
...summarizeHolidayOverlays(formattedHolidayOverlays),
|
||||||
|
},
|
||||||
|
allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
|
||||||
|
demands: readModel.demands,
|
||||||
|
assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
|
||||||
|
holidayOverlays: formattedHolidayOverlays,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
getHolidayOverlayDetail: controllerProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
startDate: z.string().optional(),
|
||||||
|
endDate: z.string().optional(),
|
||||||
|
durationDays: z.number().int().min(1).max(366).optional(),
|
||||||
|
resourceIds: z.array(z.string()).optional(),
|
||||||
|
projectIds: z.array(z.string()).optional(),
|
||||||
|
clientIds: z.array(z.string()).optional(),
|
||||||
|
chapters: z.array(z.string()).optional(),
|
||||||
|
eids: z.array(z.string()).optional(),
|
||||||
|
countryCodes: z.array(z.string()).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const { startDate, endDate } = createTimelineDateRange(input);
|
||||||
|
const filters = createTimelineFilters(input);
|
||||||
|
const holidayOverlays = await loadTimelineHolidayOverlays(ctx.db, {
|
||||||
|
...filters,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
const formattedOverlays = formatHolidayOverlays(holidayOverlays);
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: {
|
||||||
|
startDate: fmtDate(startDate),
|
||||||
|
endDate: fmtDate(endDate),
|
||||||
|
},
|
||||||
|
filters,
|
||||||
|
summary: summarizeHolidayOverlays(formattedOverlays),
|
||||||
|
overlays: formattedOverlays,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get full project context for a project:
|
* Get full project context for a project:
|
||||||
@@ -519,7 +877,7 @@ export const timelineRouter = createTRPCRouter({
|
|||||||
* - all assignment bookings for the same resources (for cross-project overlap display)
|
* - all assignment bookings for the same resources (for cross-project overlap display)
|
||||||
* Used when: drag starts or project panel opens.
|
* Used when: drag starts or project panel opens.
|
||||||
*/
|
*/
|
||||||
getProjectContext: protectedProcedure
|
getProjectContext: controllerProcedure
|
||||||
.input(z.object({ projectId: z.string() }))
|
.input(z.object({ projectId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const {
|
const {
|
||||||
@@ -548,6 +906,122 @@ export const timelineRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getProjectContextDetail: controllerProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
startDate: z.string().optional(),
|
||||||
|
endDate: z.string().optional(),
|
||||||
|
durationDays: z.number().int().min(1).max(366).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const projectContext = await loadTimelineProjectContext(ctx.db, input.projectId);
|
||||||
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
|
|
||||||
|
const derivedStartDate = input.startDate
|
||||||
|
? createTimelineDateRange({ startDate: input.startDate, durationDays: 1 }).startDate
|
||||||
|
: projectContext.project.startDate
|
||||||
|
?? projectContext.assignments[0]?.startDate
|
||||||
|
?? projectContext.demands[0]?.startDate
|
||||||
|
?? createTimelineDateRange({ durationDays: 1 }).startDate;
|
||||||
|
const derivedEndDate = input.endDate
|
||||||
|
? createTimelineDateRange({ startDate: fmtDate(derivedStartDate) ?? undefined, endDate: input.endDate }).endDate
|
||||||
|
: projectContext.project.endDate
|
||||||
|
?? createTimelineDateRange({
|
||||||
|
startDate: fmtDate(derivedStartDate) ?? undefined,
|
||||||
|
durationDays: input.durationDays ?? 21,
|
||||||
|
}).endDate;
|
||||||
|
|
||||||
|
if (derivedEndDate < derivedStartDate) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "endDate must be on or after startDate.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const holidayOverlays = projectContext.resourceIds.length > 0
|
||||||
|
? await loadTimelineHolidayOverlays(ctx.db, {
|
||||||
|
startDate: derivedStartDate,
|
||||||
|
endDate: derivedEndDate,
|
||||||
|
resourceIds: projectContext.resourceIds,
|
||||||
|
projectIds: [input.projectId],
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays);
|
||||||
|
|
||||||
|
const assignmentConflicts = projectContext.assignments
|
||||||
|
.filter((assignment) => assignment.resourceId && assignment.resource)
|
||||||
|
.map((assignment) => {
|
||||||
|
const overlaps = projectContext.allResourceAllocations
|
||||||
|
.filter((booking) => (
|
||||||
|
booking.resourceId === assignment.resourceId
|
||||||
|
&& booking.id !== assignment.id
|
||||||
|
&& rangesOverlap(
|
||||||
|
toDate(booking.startDate),
|
||||||
|
toDate(booking.endDate),
|
||||||
|
toDate(assignment.startDate),
|
||||||
|
toDate(assignment.endDate),
|
||||||
|
)
|
||||||
|
))
|
||||||
|
.map((booking) => ({
|
||||||
|
id: booking.id,
|
||||||
|
projectId: booking.projectId,
|
||||||
|
projectName: booking.project?.name ?? null,
|
||||||
|
projectShortCode: booking.project?.shortCode ?? null,
|
||||||
|
startDate: fmtDate(toDate(booking.startDate)),
|
||||||
|
endDate: fmtDate(toDate(booking.endDate)),
|
||||||
|
hoursPerDay: booking.hoursPerDay,
|
||||||
|
status: booking.status,
|
||||||
|
sameProject: booking.projectId === input.projectId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
assignmentId: assignment.id,
|
||||||
|
resourceId: assignment.resourceId!,
|
||||||
|
resourceName: assignment.resource?.displayName ?? null,
|
||||||
|
startDate: fmtDate(toDate(assignment.startDate)),
|
||||||
|
endDate: fmtDate(toDate(assignment.endDate)),
|
||||||
|
hoursPerDay: assignment.hoursPerDay,
|
||||||
|
overlapCount: overlaps.length,
|
||||||
|
crossProjectOverlapCount: overlaps.filter((booking) => !booking.sameProject).length,
|
||||||
|
overlaps,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: projectContext.project,
|
||||||
|
period: {
|
||||||
|
startDate: fmtDate(derivedStartDate),
|
||||||
|
endDate: fmtDate(derivedEndDate),
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
...summarizeTimelineEntries({
|
||||||
|
allocations: projectContext.allocations,
|
||||||
|
demands: projectContext.demands,
|
||||||
|
assignments: projectContext.assignments,
|
||||||
|
}),
|
||||||
|
resourceIds: projectContext.resourceIds.length,
|
||||||
|
allResourceAllocationCount: projectContext.allResourceAllocations.length,
|
||||||
|
conflictedAssignmentCount: assignmentConflicts.filter((item) => item.crossProjectOverlapCount > 0).length,
|
||||||
|
...summarizeHolidayOverlays(formattedHolidayOverlays),
|
||||||
|
},
|
||||||
|
allocations: projectContext.allocations.map((allocation) =>
|
||||||
|
anonymizeResourceOnEntry(allocation, directory),
|
||||||
|
),
|
||||||
|
demands: projectContext.demands,
|
||||||
|
assignments: projectContext.assignments.map((assignment) =>
|
||||||
|
anonymizeResourceOnEntry(assignment, directory),
|
||||||
|
),
|
||||||
|
allResourceAllocations: projectContext.allResourceAllocations.map((allocation) =>
|
||||||
|
anonymizeResourceOnEntry(allocation, directory),
|
||||||
|
),
|
||||||
|
assignmentConflicts,
|
||||||
|
holidayOverlays: formattedHolidayOverlays,
|
||||||
|
resourceIds: projectContext.resourceIds,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline update of an allocation's hours, dates, includeSaturday, or role.
|
* Inline update of an allocation's hours, dates, includeSaturday, or role.
|
||||||
* Recalculates dailyCostCents and emits SSE.
|
* Recalculates dailyCostCents and emits SSE.
|
||||||
@@ -682,10 +1156,50 @@ export const timelineRouter = createTRPCRouter({
|
|||||||
* Preview a project shift — validate without committing.
|
* Preview a project shift — validate without committing.
|
||||||
* Returns cost impact, conflicts, warnings.
|
* Returns cost impact, conflicts, warnings.
|
||||||
*/
|
*/
|
||||||
previewShift: protectedProcedure
|
previewShift: controllerProcedure
|
||||||
.input(ShiftProjectSchema)
|
.input(ShiftProjectSchema)
|
||||||
.query(async ({ ctx, input }) => previewTimelineProjectShift(ctx.db, input)),
|
.query(async ({ ctx, input }) => previewTimelineProjectShift(ctx.db, input)),
|
||||||
|
|
||||||
|
getShiftPreviewDetail: controllerProcedure
|
||||||
|
.input(ShiftProjectSchema)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const [project, preview] = await Promise.all([
|
||||||
|
findUniqueOrThrow(
|
||||||
|
ctx.db.project.findUnique({
|
||||||
|
where: { id: input.projectId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
shortCode: true,
|
||||||
|
status: true,
|
||||||
|
responsiblePerson: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"Project",
|
||||||
|
),
|
||||||
|
previewTimelineProjectShift(ctx.db, input),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
shortCode: project.shortCode,
|
||||||
|
status: project.status,
|
||||||
|
responsiblePerson: project.responsiblePerson,
|
||||||
|
startDate: fmtDate(project.startDate),
|
||||||
|
endDate: fmtDate(project.endDate),
|
||||||
|
},
|
||||||
|
requestedShift: {
|
||||||
|
newStartDate: fmtDate(input.newStartDate),
|
||||||
|
newEndDate: fmtDate(input.newEndDate),
|
||||||
|
},
|
||||||
|
preview,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a project shift — validate, then commit all allocation date changes.
|
* Apply a project shift — validate, then commit all allocation date changes.
|
||||||
* Reads includeSaturday from each allocation's metadata.
|
* Reads includeSaturday from each allocation's metadata.
|
||||||
@@ -1044,7 +1558,7 @@ export const timelineRouter = createTRPCRouter({
|
|||||||
/**
|
/**
|
||||||
* Get budget status for a project.
|
* Get budget status for a project.
|
||||||
*/
|
*/
|
||||||
getBudgetStatus: protectedProcedure
|
getBudgetStatus: controllerProcedure
|
||||||
.input(z.object({ projectId: z.string() }))
|
.input(z.object({ projectId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const project = await findUniqueOrThrow(
|
const project = await findUniqueOrThrow(
|
||||||
@@ -1052,6 +1566,8 @@ export const timelineRouter = createTRPCRouter({
|
|||||||
where: { id: input.projectId },
|
where: { id: input.projectId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
name: true,
|
||||||
|
shortCode: true,
|
||||||
budgetCents: true,
|
budgetCents: true,
|
||||||
winProbability: true,
|
winProbability: true,
|
||||||
startDate: true,
|
startDate: true,
|
||||||
@@ -1066,7 +1582,7 @@ export const timelineRouter = createTRPCRouter({
|
|||||||
projectIds: [project.id],
|
projectIds: [project.id],
|
||||||
});
|
});
|
||||||
|
|
||||||
return computeBudgetStatus(
|
const budgetStatus = computeBudgetStatus(
|
||||||
project.budgetCents,
|
project.budgetCents,
|
||||||
project.winProbability,
|
project.winProbability,
|
||||||
bookings.map((booking) => ({
|
bookings.map((booking) => ({
|
||||||
@@ -1079,5 +1595,13 @@ export const timelineRouter = createTRPCRouter({
|
|||||||
project.startDate,
|
project.startDate,
|
||||||
project.endDate,
|
project.endDate,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...budgetStatus,
|
||||||
|
projectName: project.name,
|
||||||
|
projectCode: project.shortCode,
|
||||||
|
totalAllocations: bookings.length,
|
||||||
|
budgetCents: project.budgetCents,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,14 +15,126 @@ import { createAuditEntry } from "../lib/audit.js";
|
|||||||
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
|
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
|
||||||
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
|
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
|
||||||
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
|
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
|
||||||
|
import { logger } from "../lib/logger.js";
|
||||||
|
import type { TRPCContext } from "../trpc.js";
|
||||||
|
|
||||||
/** Types that consume from annual leave balance */
|
/** Types that consume from annual leave balance */
|
||||||
const BALANCE_TYPES = new Set<VacationType>([VacationType.ANNUAL, VacationType.OTHER]);
|
const BALANCE_TYPES = new Set<VacationType>([VacationType.ANNUAL, VacationType.OTHER]);
|
||||||
|
type VacationReadContext = Pick<TRPCContext, "db" | "dbUser">;
|
||||||
|
|
||||||
|
function canManageVacationReads(ctx: { dbUser: { systemRole: string } | null }): boolean {
|
||||||
|
const role = ctx.dbUser?.systemRole;
|
||||||
|
return role === "ADMIN" || role === "MANAGER";
|
||||||
|
}
|
||||||
|
|
||||||
|
function runVacationBackgroundEffect(
|
||||||
|
effectName: string,
|
||||||
|
execute: () => unknown,
|
||||||
|
metadata: Record<string, unknown> = {},
|
||||||
|
): void {
|
||||||
|
void Promise.resolve()
|
||||||
|
.then(execute)
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
{ err: error, effectName, ...metadata },
|
||||||
|
"Vacation background side effect failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyVacationStatusInBackground(
|
||||||
|
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
|
||||||
|
vacationId: string,
|
||||||
|
resourceId: string,
|
||||||
|
newStatus: VacationStatus,
|
||||||
|
rejectionReason?: string | null,
|
||||||
|
): void {
|
||||||
|
runVacationBackgroundEffect(
|
||||||
|
"notifyVacationStatus",
|
||||||
|
() => notifyVacationStatus(db, vacationId, resourceId, newStatus, rejectionReason),
|
||||||
|
{ vacationId, resourceId, newStatus },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchVacationWebhookInBackground(
|
||||||
|
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
|
||||||
|
event: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
runVacationBackgroundEffect(
|
||||||
|
"dispatchWebhooks",
|
||||||
|
() => dispatchWebhooks(db, event, payload),
|
||||||
|
{ event },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findOwnedResourceId(
|
||||||
|
ctx: VacationReadContext,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!ctx.dbUser?.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = await ctx.db.resource.findFirst({
|
||||||
|
where: { userId: ctx.dbUser.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return resource?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertCanReadVacationResource(
|
||||||
|
ctx: VacationReadContext,
|
||||||
|
resourceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (canManageVacationReads(ctx)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownedResourceId = await findOwnedResourceId(ctx);
|
||||||
|
if (!ownedResourceId || ownedResourceId !== resourceId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You can only view vacation data for your own resource",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isSameUtcDay(left: Date, right: Date): boolean {
|
function isSameUtcDay(left: Date, right: Date): boolean {
|
||||||
return left.toISOString().slice(0, 10) === right.toISOString().slice(0, 10);
|
return left.toISOString().slice(0, 10) === right.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapTeamOverlapDetail(params: {
|
||||||
|
resource: { displayName: string; chapter: string | null };
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
overlaps: Array<{
|
||||||
|
type: VacationType;
|
||||||
|
status: VacationStatus;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
resource: { displayName: string };
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
resource: params.resource.displayName,
|
||||||
|
chapter: params.resource.chapter,
|
||||||
|
period: `${params.startDate.toISOString().slice(0, 10)} to ${params.endDate.toISOString().slice(0, 10)}`,
|
||||||
|
overlappingVacations: params.overlaps.map((vacation) => ({
|
||||||
|
resource: vacation.resource.displayName,
|
||||||
|
type: vacation.type,
|
||||||
|
status: vacation.status,
|
||||||
|
start: vacation.startDate.toISOString().slice(0, 10),
|
||||||
|
end: vacation.endDate.toISOString().slice(0, 10),
|
||||||
|
})),
|
||||||
|
overlapCount: params.overlaps.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const PreviewVacationRequestSchema = z.object({
|
const PreviewVacationRequestSchema = z.object({
|
||||||
resourceId: z.string(),
|
resourceId: z.string(),
|
||||||
type: z.nativeEnum(VacationType),
|
type: z.nativeEnum(VacationType),
|
||||||
@@ -224,9 +336,25 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
let resourceIdFilter = input.resourceId;
|
||||||
|
|
||||||
|
if (!canManageVacationReads(ctx)) {
|
||||||
|
const ownedResourceId = await findOwnedResourceId(ctx);
|
||||||
|
if (input.resourceId && input.resourceId !== ownedResourceId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You can only view vacation data for your own resource",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!ownedResourceId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
resourceIdFilter = ownedResourceId;
|
||||||
|
}
|
||||||
|
|
||||||
const vacations = await ctx.db.vacation.findMany({
|
const vacations = await ctx.db.vacation.findMany({
|
||||||
where: {
|
where: {
|
||||||
...(input.resourceId ? { resourceId: input.resourceId } : {}),
|
...(resourceIdFilter ? { resourceId: resourceIdFilter } : {}),
|
||||||
...(input.status ? { status: Array.isArray(input.status) ? { in: input.status } : input.status } : {}),
|
...(input.status ? { status: Array.isArray(input.status) ? { in: input.status } : input.status } : {}),
|
||||||
...(input.type ? { type: input.type } : {}),
|
...(input.type ? { type: input.type } : {}),
|
||||||
...(input.startDate ? { endDate: { gte: input.startDate } } : {}),
|
...(input.startDate ? { endDate: { gte: input.startDate } } : {}),
|
||||||
@@ -254,15 +382,38 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
ctx.db.vacation.findUnique({
|
ctx.db.vacation.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: {
|
include: {
|
||||||
resource: { select: RESOURCE_BRIEF_SELECT },
|
resource: { select: { ...RESOURCE_BRIEF_SELECT, userId: true } },
|
||||||
requestedBy: { select: { id: true, name: true, email: true } },
|
requestedBy: { select: { id: true, name: true, email: true } },
|
||||||
approvedBy: { select: { id: true, name: true, email: true } },
|
approvedBy: { select: { id: true, name: true, email: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
"Vacation",
|
"Vacation",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!canManageVacationReads(ctx)) {
|
||||||
|
const isOwnVacation = vacation.resource?.userId === ctx.dbUser?.id || vacation.requestedById === ctx.dbUser?.id;
|
||||||
|
if (!isOwnVacation) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You can only view your own vacation data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const directory = await getAnonymizationDirectory(ctx.db);
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
return anonymizeVacationRecord(vacation, directory);
|
const anonymized = anonymizeVacationRecord(vacation, directory);
|
||||||
|
return {
|
||||||
|
...anonymized,
|
||||||
|
resource: anonymized.resource
|
||||||
|
? {
|
||||||
|
id: anonymized.resource.id,
|
||||||
|
displayName: anonymized.resource.displayName,
|
||||||
|
eid: anonymized.resource.eid,
|
||||||
|
lcrCents: anonymized.resource.lcrCents,
|
||||||
|
chapter: anonymized.resource.chapter,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -475,7 +626,7 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
summary: `Approved vacation (was ${existing.status})`,
|
summary: `Approved vacation (was ${existing.status})`,
|
||||||
});
|
});
|
||||||
|
|
||||||
void dispatchWebhooks(ctx.db, "vacation.approved", {
|
dispatchVacationWebhookInBackground(ctx.db, "vacation.approved", {
|
||||||
id: updated.id,
|
id: updated.id,
|
||||||
resourceId: updated.resourceId,
|
resourceId: updated.resourceId,
|
||||||
startDate: updated.startDate.toISOString(),
|
startDate: updated.startDate.toISOString(),
|
||||||
@@ -497,7 +648,7 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (existing.status === VacationStatus.PENDING) {
|
if (existing.status === VacationStatus.PENDING) {
|
||||||
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
|
notifyVacationStatusInBackground(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...updated, warnings: conflictResult.warnings };
|
return { ...updated, warnings: conflictResult.warnings };
|
||||||
@@ -558,7 +709,13 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
summary: `Rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`,
|
summary: `Rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.REJECTED, input.rejectionReason);
|
notifyVacationStatusInBackground(
|
||||||
|
ctx.db,
|
||||||
|
updated.id,
|
||||||
|
updated.resourceId,
|
||||||
|
VacationStatus.REJECTED,
|
||||||
|
input.rejectionReason,
|
||||||
|
);
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}),
|
}),
|
||||||
@@ -599,7 +756,7 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
|
|
||||||
for (const v of vacations) {
|
for (const v of vacations) {
|
||||||
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.APPROVED });
|
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.APPROVED });
|
||||||
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.APPROVED);
|
notifyVacationStatusInBackground(ctx.db, v.id, v.resourceId, VacationStatus.APPROVED);
|
||||||
|
|
||||||
void createAuditEntry({
|
void createAuditEntry({
|
||||||
db: ctx.db,
|
db: ctx.db,
|
||||||
@@ -668,7 +825,13 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
|
|
||||||
for (const v of vacations) {
|
for (const v of vacations) {
|
||||||
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.REJECTED });
|
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.REJECTED });
|
||||||
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.REJECTED, input.rejectionReason);
|
notifyVacationStatusInBackground(
|
||||||
|
ctx.db,
|
||||||
|
v.id,
|
||||||
|
v.resourceId,
|
||||||
|
VacationStatus.REJECTED,
|
||||||
|
input.rejectionReason,
|
||||||
|
);
|
||||||
|
|
||||||
void createAuditEntry({
|
void createAuditEntry({
|
||||||
db: ctx.db,
|
db: ctx.db,
|
||||||
@@ -773,6 +936,8 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
await assertCanReadVacationResource(ctx, input.resourceId);
|
||||||
|
|
||||||
return ctx.db.vacation.findMany({
|
return ctx.db.vacation.findMany({
|
||||||
where: {
|
where: {
|
||||||
resourceId: input.resourceId,
|
resourceId: input.resourceId,
|
||||||
@@ -798,7 +963,7 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
return ctx.db.vacation.findMany({
|
return ctx.db.vacation.findMany({
|
||||||
where: { status: VacationStatus.PENDING },
|
where: { status: VacationStatus.PENDING },
|
||||||
include: {
|
include: {
|
||||||
resource: { select: RESOURCE_BRIEF_SELECT },
|
resource: { select: { ...RESOURCE_BRIEF_SELECT, chapter: true } },
|
||||||
requestedBy: { select: { id: true, name: true, email: true } },
|
requestedBy: { select: { id: true, name: true, email: true } },
|
||||||
},
|
},
|
||||||
orderBy: { startDate: "asc" },
|
orderBy: { startDate: "asc" },
|
||||||
@@ -818,6 +983,8 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
await assertCanReadVacationResource(ctx, input.resourceId);
|
||||||
|
|
||||||
// Find the chapter of the requesting resource
|
// Find the chapter of the requesting resource
|
||||||
const resource = await ctx.db.resource.findUnique({
|
const resource = await ctx.db.resource.findUnique({
|
||||||
where: { id: input.resourceId },
|
where: { id: input.resourceId },
|
||||||
@@ -842,6 +1009,61 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getTeamOverlapDetail: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
resourceId: z.string(),
|
||||||
|
startDate: z.coerce.date(),
|
||||||
|
endDate: z.coerce.date(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
await assertCanReadVacationResource(ctx, input.resourceId);
|
||||||
|
|
||||||
|
const resource = await ctx.db.resource.findUnique({
|
||||||
|
where: { id: input.resourceId },
|
||||||
|
select: { displayName: true, chapter: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Resource not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resource.chapter) {
|
||||||
|
return mapTeamOverlapDetail({
|
||||||
|
resource,
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
overlaps: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlaps = await ctx.db.vacation.findMany({
|
||||||
|
where: {
|
||||||
|
resource: { chapter: resource.chapter },
|
||||||
|
resourceId: { not: input.resourceId },
|
||||||
|
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
|
||||||
|
startDate: { lte: input.endDate },
|
||||||
|
endDate: { gte: input.startDate },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
resource: { select: RESOURCE_BRIEF_SELECT },
|
||||||
|
},
|
||||||
|
orderBy: { startDate: "asc" },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapTeamOverlapDetail({
|
||||||
|
resource,
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
overlaps,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch-create public holidays for all resources (or a chapter) for a given year+state.
|
* Batch-create public holidays for all resources (or a chapter) for a given year+state.
|
||||||
* Admin-only. Creates as APPROVED automatically.
|
* Admin-only. Creates as APPROVED automatically.
|
||||||
|
|||||||
@@ -1,20 +1,34 @@
|
|||||||
import { Redis } from "ioredis";
|
import { Redis } from "ioredis";
|
||||||
import { SSE_EVENT_TYPES, type SseEventType } from "@capakraken/shared";
|
import { PermissionKey, SSE_EVENT_TYPES, SystemRole, type SseEventType } from "@capakraken/shared";
|
||||||
|
|
||||||
|
export type SseAudience = string;
|
||||||
|
|
||||||
export interface SseEvent {
|
export interface SseEvent {
|
||||||
type: SseEventType;
|
type: SseEventType;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
audience: SseAudience[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type Subscriber = (event: SseEvent) => void;
|
type Subscriber = (event: SseEvent) => void;
|
||||||
|
|
||||||
|
interface Subscription {
|
||||||
|
fn: Subscriber;
|
||||||
|
audiences: Set<SseAudience>;
|
||||||
|
includeUnscoped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SseSubscriptionOptions {
|
||||||
|
audiences?: Iterable<SseAudience>;
|
||||||
|
includeUnscoped?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// Module-level subscriber registry (shared between EventBus and publishLocal)
|
// Module-level subscriber registry (shared between EventBus and publishLocal)
|
||||||
const subscribers = new Set<Subscriber>();
|
const subscribers = new Set<Subscription>();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Debounce buffer: aggregates rapid events of the same type within a 50ms
|
// Debounce buffer: aggregates rapid events of the same type and audience within
|
||||||
// window and delivers a single event per type to subscribers.
|
// a 50ms window and delivers a single event per scope to subscribers.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const DEBOUNCE_MS = 50;
|
const DEBOUNCE_MS = 50;
|
||||||
@@ -23,48 +37,76 @@ interface BufferEntry {
|
|||||||
payloads: Record<string, unknown>[];
|
payloads: Record<string, unknown>[];
|
||||||
timer: ReturnType<typeof setTimeout>;
|
timer: ReturnType<typeof setTimeout>;
|
||||||
firstTimestamp: string;
|
firstTimestamp: string;
|
||||||
|
audience: SseAudience[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const debounceBuffer = new Map<SseEventType, BufferEntry>();
|
const debounceBuffer = new Map<string, BufferEntry>();
|
||||||
|
|
||||||
|
function normalizeAudiences(audiences?: Iterable<SseAudience>): SseAudience[] {
|
||||||
|
return [...new Set(Array.from(audiences ?? [], (audience) => audience.trim()).filter(Boolean))].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBufferKey(type: SseEventType, audience: readonly SseAudience[]): string {
|
||||||
|
return `${type}::${audience.length > 0 ? audience.join("|") : "__unscoped__"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesSubscription(event: SseEvent, subscription: Subscription): boolean {
|
||||||
|
if (event.audience.length === 0) {
|
||||||
|
return subscription.includeUnscoped;
|
||||||
|
}
|
||||||
|
return event.audience.some((audience) => subscription.audiences.has(audience));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deliverEvent(event: SseEvent): void {
|
||||||
|
for (const subscription of subscribers) {
|
||||||
|
if (matchesSubscription(event, subscription)) {
|
||||||
|
subscription.fn(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userAudience = (userId: string): SseAudience => `user:${userId}`;
|
||||||
|
export const roleAudience = (role: string): SseAudience => `role:${role}`;
|
||||||
|
export const permissionAudience = (permission: string): SseAudience => `permission:${permission}`;
|
||||||
|
|
||||||
/** Flush a single event type from the buffer and deliver to subscribers. */
|
/** Flush a single event type from the buffer and deliver to subscribers. */
|
||||||
function flushEventType(type: SseEventType): void {
|
function flushEventType(type: SseEventType, audience: readonly SseAudience[]): void {
|
||||||
const entry = debounceBuffer.get(type);
|
const key = getBufferKey(type, audience);
|
||||||
|
const entry = debounceBuffer.get(key);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
debounceBuffer.delete(type);
|
debounceBuffer.delete(key);
|
||||||
|
|
||||||
const event: SseEvent =
|
const event: SseEvent =
|
||||||
entry.payloads.length === 1
|
entry.payloads.length === 1
|
||||||
? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp }
|
? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp, audience: entry.audience }
|
||||||
: {
|
: {
|
||||||
type,
|
type,
|
||||||
payload: { _batch: entry.payloads },
|
payload: { _batch: entry.payloads },
|
||||||
timestamp: entry.firstTimestamp,
|
timestamp: entry.firstTimestamp,
|
||||||
|
audience: entry.audience,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const fn of subscribers) {
|
deliverEvent(event);
|
||||||
fn(event);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flush all pending debounce timers immediately (for cleanup / tests). */
|
/** Flush all pending debounce timers immediately (for cleanup / tests). */
|
||||||
export function flushPendingEvents(): void {
|
export function flushPendingEvents(): void {
|
||||||
for (const [type, entry] of debounceBuffer) {
|
for (const [key, entry] of debounceBuffer) {
|
||||||
clearTimeout(entry.timer);
|
clearTimeout(entry.timer);
|
||||||
debounceBuffer.delete(type);
|
debounceBuffer.delete(key);
|
||||||
|
|
||||||
|
const [type] = key.split("::") as [SseEventType];
|
||||||
const event: SseEvent =
|
const event: SseEvent =
|
||||||
entry.payloads.length === 1
|
entry.payloads.length === 1
|
||||||
? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp }
|
? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp, audience: entry.audience }
|
||||||
: {
|
: {
|
||||||
type,
|
type,
|
||||||
payload: { _batch: entry.payloads },
|
payload: { _batch: entry.payloads },
|
||||||
timestamp: entry.firstTimestamp,
|
timestamp: entry.firstTimestamp,
|
||||||
|
audience: entry.audience,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const fn of subscribers) {
|
deliverEvent(event);
|
||||||
fn(event);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,9 +143,21 @@ function setupSubscriber(): void {
|
|||||||
});
|
});
|
||||||
subscriber.on("message", (_channel: string, message: string) => {
|
subscriber.on("message", (_channel: string, message: string) => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(message) as { type: SseEventType; payload: Record<string, unknown>; timestamp: string };
|
const parsed = JSON.parse(message) as {
|
||||||
publishLocal({ type: parsed.type, payload: parsed.payload, timestamp: parsed.timestamp });
|
type: SseEventType;
|
||||||
} catch { /* ignore parse errors */ }
|
payload: Record<string, unknown>;
|
||||||
|
timestamp: string;
|
||||||
|
audience?: SseAudience[];
|
||||||
|
};
|
||||||
|
publishLocal({
|
||||||
|
type: parsed.type,
|
||||||
|
payload: parsed.payload,
|
||||||
|
timestamp: parsed.timestamp,
|
||||||
|
audience: normalizeAudiences(parsed.audience),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[Redis setupSubscriber] Redis unavailable, SSE will be local-only:", e);
|
console.warn("[Redis setupSubscriber] Redis unavailable, SSE will be local-only:", e);
|
||||||
@@ -115,28 +169,47 @@ function setupSubscriber(): void {
|
|||||||
* Gracefully degrades to in-memory delivery when Redis is unavailable.
|
* Gracefully degrades to in-memory delivery when Redis is unavailable.
|
||||||
*/
|
*/
|
||||||
class EventBus {
|
class EventBus {
|
||||||
subscribe(fn: Subscriber): () => void {
|
subscribe(fn: Subscriber, options: SseSubscriptionOptions = {}): () => void {
|
||||||
subscribers.add(fn);
|
const subscription: Subscription = {
|
||||||
return () => subscribers.delete(fn);
|
fn,
|
||||||
|
audiences: new Set(normalizeAudiences(options.audiences)),
|
||||||
|
includeUnscoped: options.includeUnscoped ?? true,
|
||||||
|
};
|
||||||
|
subscribers.add(subscription);
|
||||||
|
return () => subscribers.delete(subscription);
|
||||||
}
|
}
|
||||||
|
|
||||||
publish(event: SseEvent): void {
|
publish(event: SseEvent): void {
|
||||||
|
const normalizedEvent: SseEvent = {
|
||||||
|
...event,
|
||||||
|
audience: normalizeAudiences(event.audience),
|
||||||
|
};
|
||||||
|
|
||||||
// Broadcast via Redis (all instances receive via subscriber.on("message"))
|
// Broadcast via Redis (all instances receive via subscriber.on("message"))
|
||||||
try {
|
try {
|
||||||
const pub = getPublisher();
|
const pub = getPublisher();
|
||||||
void pub.publish(CHANNEL, JSON.stringify({ type: event.type, payload: event.payload, timestamp: event.timestamp }));
|
void pub.publish(
|
||||||
|
CHANNEL,
|
||||||
|
JSON.stringify({
|
||||||
|
type: normalizedEvent.type,
|
||||||
|
payload: normalizedEvent.payload,
|
||||||
|
timestamp: normalizedEvent.timestamp,
|
||||||
|
audience: normalizedEvent.audience,
|
||||||
|
}),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[Redis emit] fallback to local-only:", e);
|
console.warn("[Redis emit] fallback to local-only:", e);
|
||||||
// Deliver locally when Redis is unavailable
|
// Deliver locally when Redis is unavailable
|
||||||
publishLocal(event);
|
publishLocal(normalizedEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(type: SseEventType, payload: Record<string, unknown>): void {
|
emit(type: SseEventType, payload: Record<string, unknown>, audience: Iterable<SseAudience> = []): void {
|
||||||
this.publish({
|
this.publish({
|
||||||
type,
|
type,
|
||||||
payload,
|
payload,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
audience: normalizeAudiences(audience),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,23 +218,26 @@ class EventBus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local delivery with debounce: buffer events of the same type within a 50ms
|
// Local delivery with debounce: buffer events of the same type and audience
|
||||||
// window and then deliver a single (possibly aggregated) event to subscribers.
|
// within a 50ms window and then deliver a single (possibly aggregated) event.
|
||||||
function publishLocal(event: SseEvent): void {
|
function publishLocal(event: SseEvent): void {
|
||||||
const existing = debounceBuffer.get(event.type);
|
const audience = normalizeAudiences(event.audience);
|
||||||
|
const key = getBufferKey(event.type, audience);
|
||||||
|
const existing = debounceBuffer.get(key);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Another event of the same type is already buffered — append payload and
|
// Another event of the same type is already buffered — append payload and
|
||||||
// reset the timer so the window starts fresh from the latest arrival.
|
// reset the timer so the window starts fresh from the latest arrival.
|
||||||
existing.payloads.push(event.payload);
|
existing.payloads.push(event.payload);
|
||||||
clearTimeout(existing.timer);
|
clearTimeout(existing.timer);
|
||||||
existing.timer = setTimeout(() => flushEventType(event.type), DEBOUNCE_MS);
|
existing.timer = setTimeout(() => flushEventType(event.type, audience), DEBOUNCE_MS);
|
||||||
} else {
|
} else {
|
||||||
// First event of this type — start a new debounce window.
|
// First event of this type and audience — start a new debounce window.
|
||||||
debounceBuffer.set(event.type, {
|
debounceBuffer.set(key, {
|
||||||
payloads: [event.payload],
|
payloads: [event.payload],
|
||||||
timer: setTimeout(() => flushEventType(event.type), DEBOUNCE_MS),
|
timer: setTimeout(() => flushEventType(event.type, audience), DEBOUNCE_MS),
|
||||||
firstTimestamp: event.timestamp,
|
firstTimestamp: event.timestamp,
|
||||||
|
audience,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,58 +250,73 @@ setupSubscriber();
|
|||||||
|
|
||||||
// Helper emitters
|
// Helper emitters
|
||||||
export const emitAllocationCreated = (allocation: Record<string, unknown>) =>
|
export const emitAllocationCreated = (allocation: Record<string, unknown>) =>
|
||||||
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, allocation);
|
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, allocation, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
|
||||||
|
|
||||||
export const emitAllocationUpdated = (allocation: Record<string, unknown>) =>
|
export const emitAllocationUpdated = (allocation: Record<string, unknown>) =>
|
||||||
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, allocation);
|
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, allocation, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
|
||||||
|
|
||||||
export const emitAllocationDeleted = (allocationId: string, projectId: string) =>
|
export const emitAllocationDeleted = (allocationId: string, projectId: string) =>
|
||||||
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_DELETED, { allocationId, projectId });
|
eventBus.emit(
|
||||||
|
SSE_EVENT_TYPES.ALLOCATION_DELETED,
|
||||||
|
{ allocationId, projectId },
|
||||||
|
[permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)],
|
||||||
|
);
|
||||||
|
|
||||||
export const emitProjectShifted = (project: Record<string, unknown>) =>
|
export const emitProjectShifted = (project: Record<string, unknown>) =>
|
||||||
eventBus.emit(SSE_EVENT_TYPES.PROJECT_SHIFTED, project);
|
eventBus.emit(SSE_EVENT_TYPES.PROJECT_SHIFTED, project, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
|
||||||
|
|
||||||
export const emitBudgetWarning = (projectId: string, payload: Record<string, unknown>) =>
|
export const emitBudgetWarning = (projectId: string, payload: Record<string, unknown>) =>
|
||||||
eventBus.emit(SSE_EVENT_TYPES.BUDGET_WARNING, { projectId, ...payload });
|
eventBus.emit(
|
||||||
|
SSE_EVENT_TYPES.BUDGET_WARNING,
|
||||||
|
{ projectId, ...payload },
|
||||||
|
[permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)],
|
||||||
|
);
|
||||||
|
|
||||||
export const emitVacationCreated = (vacation: Record<string, unknown>) =>
|
export const emitVacationCreated = (vacation: Record<string, unknown>) =>
|
||||||
eventBus.emit(SSE_EVENT_TYPES.VACATION_CREATED, vacation);
|
eventBus.emit(SSE_EVENT_TYPES.VACATION_CREATED, vacation, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
|
||||||
|
|
||||||
export const emitVacationUpdated = (vacation: Record<string, unknown>) =>
|
export const emitVacationUpdated = (vacation: Record<string, unknown>) =>
|
||||||
eventBus.emit(SSE_EVENT_TYPES.VACATION_UPDATED, vacation);
|
eventBus.emit(SSE_EVENT_TYPES.VACATION_UPDATED, vacation, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
|
||||||
|
|
||||||
export const emitVacationDeleted = (vacationId: string, resourceId: string) =>
|
export const emitVacationDeleted = (vacationId: string, resourceId: string) =>
|
||||||
eventBus.emit(SSE_EVENT_TYPES.VACATION_DELETED, { vacationId, resourceId });
|
eventBus.emit(
|
||||||
|
SSE_EVENT_TYPES.VACATION_DELETED,
|
||||||
|
{ vacationId, resourceId },
|
||||||
|
[permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)],
|
||||||
|
);
|
||||||
|
|
||||||
export const emitRoleCreated = (role: Record<string, unknown>) =>
|
export const emitRoleCreated = (role: Record<string, unknown>) =>
|
||||||
eventBus.emit(SSE_EVENT_TYPES.ROLE_CREATED, role);
|
eventBus.emit(SSE_EVENT_TYPES.ROLE_CREATED, role, [permissionAudience(PermissionKey.MANAGE_ROLES)]);
|
||||||
|
|
||||||
export const emitRoleUpdated = (role: Record<string, unknown>) =>
|
export const emitRoleUpdated = (role: Record<string, unknown>) =>
|
||||||
eventBus.emit(SSE_EVENT_TYPES.ROLE_UPDATED, role);
|
eventBus.emit(SSE_EVENT_TYPES.ROLE_UPDATED, role, [permissionAudience(PermissionKey.MANAGE_ROLES)]);
|
||||||
|
|
||||||
export const emitRoleDeleted = (roleId: string) =>
|
export const emitRoleDeleted = (roleId: string) =>
|
||||||
eventBus.emit(SSE_EVENT_TYPES.ROLE_DELETED, { roleId });
|
eventBus.emit(SSE_EVENT_TYPES.ROLE_DELETED, { roleId }, [permissionAudience(PermissionKey.MANAGE_ROLES)]);
|
||||||
|
|
||||||
export function emitNotificationCreated(userId: string, notificationId: string): void {
|
export function emitNotificationCreated(userId: string, notificationId: string): void {
|
||||||
eventBus.emit(SSE_EVENT_TYPES.NOTIFICATION_CREATED, { userId, notificationId });
|
eventBus.emit(SSE_EVENT_TYPES.NOTIFICATION_CREATED, { userId, notificationId }, [userAudience(userId)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emitTaskAssigned(userId: string, notificationId: string): void {
|
export function emitTaskAssigned(userId: string, notificationId: string): void {
|
||||||
eventBus.emit(SSE_EVENT_TYPES.TASK_ASSIGNED, { userId, notificationId });
|
eventBus.emit(SSE_EVENT_TYPES.TASK_ASSIGNED, { userId, notificationId }, [userAudience(userId)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emitTaskCompleted(userId: string, notificationId: string): void {
|
export function emitTaskCompleted(userId: string, notificationId: string): void {
|
||||||
eventBus.emit(SSE_EVENT_TYPES.TASK_COMPLETED, { userId, notificationId });
|
eventBus.emit(SSE_EVENT_TYPES.TASK_COMPLETED, { userId, notificationId }, [userAudience(userId)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emitTaskStatusChanged(userId: string, notificationId: string): void {
|
export function emitTaskStatusChanged(userId: string, notificationId: string): void {
|
||||||
eventBus.emit(SSE_EVENT_TYPES.TASK_STATUS_CHANGED, { userId, notificationId });
|
eventBus.emit(SSE_EVENT_TYPES.TASK_STATUS_CHANGED, { userId, notificationId }, [userAudience(userId)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emitReminderDue(userId: string, notificationId: string): void {
|
export function emitReminderDue(userId: string, notificationId: string): void {
|
||||||
eventBus.emit(SSE_EVENT_TYPES.REMINDER_DUE, { userId, notificationId });
|
eventBus.emit(SSE_EVENT_TYPES.REMINDER_DUE, { userId, notificationId }, [userAudience(userId)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emitBroadcastSent(broadcastId: string, recipientCount: number): void {
|
export function emitBroadcastSent(broadcastId: string, recipientCount: number): void {
|
||||||
eventBus.emit(SSE_EVENT_TYPES.BROADCAST_SENT, { broadcastId, recipientCount });
|
eventBus.emit(SSE_EVENT_TYPES.BROADCAST_SENT, { broadcastId, recipientCount }, [
|
||||||
|
roleAudience(SystemRole.ADMIN),
|
||||||
|
roleAudience(SystemRole.MANAGER),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ describe("dashboard use-cases", () => {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
|
vacation: {
|
||||||
|
count: vi.fn().mockResolvedValue(2),
|
||||||
|
},
|
||||||
|
estimate: {
|
||||||
|
count: vi.fn().mockResolvedValue(5),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await getDashboardOverview(db as never);
|
const result = await getDashboardOverview(db as never);
|
||||||
@@ -83,6 +89,8 @@ describe("dashboard use-cases", () => {
|
|||||||
{ status: "ACTIVE", count: 1 },
|
{ status: "ACTIVE", count: 1 },
|
||||||
{ status: "DRAFT", count: 1 },
|
{ status: "DRAFT", count: 1 },
|
||||||
]);
|
]);
|
||||||
|
expect(result.approvedVacations).toBe(2);
|
||||||
|
expect(result.totalEstimates).toBe(5);
|
||||||
expect(result.chapterUtilization).toEqual([
|
expect(result.chapterUtilization).toEqual([
|
||||||
{ chapter: "CGI", resourceCount: 2, avgChargeabilityTarget: 70 },
|
{ chapter: "CGI", resourceCount: 2, avgChargeabilityTarget: 70 },
|
||||||
{ chapter: "Unassigned", resourceCount: 1, avgChargeabilityTarget: 0 },
|
{ chapter: "Unassigned", resourceCount: 1, avgChargeabilityTarget: 0 },
|
||||||
@@ -134,6 +142,12 @@ describe("dashboard use-cases", () => {
|
|||||||
auditLog: {
|
auditLog: {
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
|
vacation: {
|
||||||
|
count: vi.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
estimate: {
|
||||||
|
count: vi.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await getDashboardOverview(db as never);
|
const result = await getDashboardOverview(db as never);
|
||||||
@@ -242,12 +256,20 @@ describe("dashboard use-cases", () => {
|
|||||||
auditLog: {
|
auditLog: {
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
|
vacation: {
|
||||||
|
count: vi.fn().mockResolvedValue(1),
|
||||||
|
},
|
||||||
|
estimate: {
|
||||||
|
count: vi.fn().mockResolvedValue(2),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await getDashboardOverview(db as never);
|
const result = await getDashboardOverview(db as never);
|
||||||
|
|
||||||
expect(result.totalAllocations).toBe(3);
|
expect(result.totalAllocations).toBe(3);
|
||||||
expect(result.activeAllocations).toBe(2);
|
expect(result.activeAllocations).toBe(2);
|
||||||
|
expect(result.approvedVacations).toBe(1);
|
||||||
|
expect(result.totalEstimates).toBe(2);
|
||||||
expect(result.budgetSummary).toEqual({
|
expect(result.budgetSummary).toEqual({
|
||||||
totalBudgetCents: 100_000,
|
totalBudgetCents: 100_000,
|
||||||
totalCostCents: 4_000,
|
totalCostCents: 4_000,
|
||||||
@@ -954,6 +976,12 @@ describe("dashboard use-cases", () => {
|
|||||||
auditLog: {
|
auditLog: {
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
|
vacation: {
|
||||||
|
count: vi.fn().mockResolvedValue(1),
|
||||||
|
},
|
||||||
|
estimate: {
|
||||||
|
count: vi.fn().mockResolvedValue(4),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await getDashboardOverview(db as never);
|
const result = await getDashboardOverview(db as never);
|
||||||
@@ -963,6 +991,8 @@ describe("dashboard use-cases", () => {
|
|||||||
totalCostCents: 1_000,
|
totalCostCents: 1_000,
|
||||||
avgUtilizationPercent: 10,
|
avgUtilizationPercent: 10,
|
||||||
});
|
});
|
||||||
|
expect(result.approvedVacations).toBe(1);
|
||||||
|
expect(result.totalEstimates).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes regional public holidays from project health budget usage", async () => {
|
it("excludes regional public holidays from project health budget usage", async () => {
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ export {
|
|||||||
type DemandRowDerivation,
|
type DemandRowDerivation,
|
||||||
type DashboardDemandRow,
|
type DashboardDemandRow,
|
||||||
getDashboardSkillGaps,
|
getDashboardSkillGaps,
|
||||||
|
getDashboardSkillGapSummary,
|
||||||
type SkillGapRow,
|
type SkillGapRow,
|
||||||
|
type DashboardSkillGapSummary,
|
||||||
getDashboardProjectHealth,
|
getDashboardProjectHealth,
|
||||||
type ProjectHealthRow,
|
type ProjectHealthRow,
|
||||||
} from "./use-cases/dashboard/index.js";
|
} from "./use-cases/dashboard/index.js";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PrismaClient } from "@capakraken/db";
|
import type { PrismaClient } from "@capakraken/db";
|
||||||
|
import { VacationStatus } from "@capakraken/db";
|
||||||
import { AllocationStatus } from "@capakraken/shared";
|
import { AllocationStatus } from "@capakraken/shared";
|
||||||
import { buildSplitAllocationReadModel } from "../allocation/build-split-allocation-read-model.js";
|
import { buildSplitAllocationReadModel } from "../allocation/build-split-allocation-read-model.js";
|
||||||
import { calculateInclusiveDays } from "./shared.js";
|
import { calculateInclusiveDays } from "./shared.js";
|
||||||
@@ -25,6 +26,8 @@ export async function getDashboardOverview(db: PrismaClient) {
|
|||||||
budgetAssignments,
|
budgetAssignments,
|
||||||
recentActivity,
|
recentActivity,
|
||||||
allResources,
|
allResources,
|
||||||
|
approvedVacations,
|
||||||
|
totalEstimates,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
db.resource.count(),
|
db.resource.count(),
|
||||||
db.resource.count({ where: { isActive: true } }),
|
db.resource.count({ where: { isActive: true } }),
|
||||||
@@ -95,6 +98,8 @@ export async function getDashboardOverview(db: PrismaClient) {
|
|||||||
db.resource.findMany({
|
db.resource.findMany({
|
||||||
select: { chapter: true, chargeabilityTarget: true },
|
select: { chapter: true, chargeabilityTarget: true },
|
||||||
}),
|
}),
|
||||||
|
db.vacation.count({ where: { status: VacationStatus.APPROVED } }),
|
||||||
|
db.estimate.count(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const planningReadModel = buildSplitAllocationReadModel({
|
const planningReadModel = buildSplitAllocationReadModel({
|
||||||
@@ -200,6 +205,8 @@ export async function getDashboardOverview(db: PrismaClient) {
|
|||||||
totalAllocations,
|
totalAllocations,
|
||||||
activeAllocations,
|
activeAllocations,
|
||||||
cancelledAllocations,
|
cancelledAllocations,
|
||||||
|
approvedVacations,
|
||||||
|
totalEstimates,
|
||||||
budgetSummary: {
|
budgetSummary: {
|
||||||
totalBudgetCents,
|
totalBudgetCents,
|
||||||
totalCostCents,
|
totalCostCents,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface ProjectHealthRow {
|
|||||||
id: string;
|
id: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
shortCode: string;
|
shortCode: string;
|
||||||
|
status: string;
|
||||||
clientId: string | null;
|
clientId: string | null;
|
||||||
clientName: string | null;
|
clientName: string | null;
|
||||||
budgetHealth: number;
|
budgetHealth: number;
|
||||||
@@ -74,6 +75,7 @@ export async function getDashboardProjectHealth(
|
|||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
shortCode: true,
|
shortCode: true,
|
||||||
|
status: true,
|
||||||
budgetCents: true,
|
budgetCents: true,
|
||||||
endDate: true,
|
endDate: true,
|
||||||
clientId: true,
|
clientId: true,
|
||||||
@@ -232,6 +234,7 @@ export async function getDashboardProjectHealth(
|
|||||||
id: p.id,
|
id: p.id,
|
||||||
projectName: p.name,
|
projectName: p.name,
|
||||||
shortCode: p.shortCode,
|
shortCode: p.shortCode,
|
||||||
|
status: p.status,
|
||||||
clientId: p.clientId,
|
clientId: p.clientId,
|
||||||
clientName: p.client?.name ?? null,
|
clientName: p.client?.name ?? null,
|
||||||
budgetHealth,
|
budgetHealth,
|
||||||
|
|||||||
@@ -12,6 +12,31 @@ interface SkillEntry {
|
|||||||
level?: number;
|
level?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SkillGapSummaryRoleGap {
|
||||||
|
role: string;
|
||||||
|
needed: number;
|
||||||
|
filled: number;
|
||||||
|
gap: number;
|
||||||
|
fillRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillSupplySummaryRow {
|
||||||
|
skill: string;
|
||||||
|
resourceCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourcesByRoleSummaryRow {
|
||||||
|
role: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSkillGapSummary {
|
||||||
|
roleGaps: SkillGapSummaryRoleGap[];
|
||||||
|
totalOpenPositions: number;
|
||||||
|
skillSupplyTop10: SkillSupplySummaryRow[];
|
||||||
|
resourcesByRole: ResourcesByRoleSummaryRow[];
|
||||||
|
}
|
||||||
|
|
||||||
export async function getDashboardSkillGaps(
|
export async function getDashboardSkillGaps(
|
||||||
db: PrismaClient,
|
db: PrismaClient,
|
||||||
): Promise<SkillGapRow[]> {
|
): Promise<SkillGapRow[]> {
|
||||||
@@ -87,3 +112,86 @@ export async function getDashboardSkillGaps(
|
|||||||
rows.sort((a, b) => a.gap - b.gap);
|
rows.sort((a, b) => a.gap - b.gap);
|
||||||
return rows.slice(0, 10);
|
return rows.slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDashboardSkillGapSummary(
|
||||||
|
db: PrismaClient,
|
||||||
|
): Promise<DashboardSkillGapSummary> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const demands = await db.demandRequirement.findMany({
|
||||||
|
where: {
|
||||||
|
project: { status: { in: ["ACTIVE", "DRAFT"] } },
|
||||||
|
status: { not: "CANCELLED" },
|
||||||
|
endDate: { gte: now },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
role: true,
|
||||||
|
headcount: true,
|
||||||
|
roleEntity: { select: { name: true } },
|
||||||
|
_count: { select: { assignments: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const demandByRole = new Map<string, { needed: number; filled: number }>();
|
||||||
|
for (const demand of demands) {
|
||||||
|
const roleName = demand.roleEntity?.name ?? demand.role ?? "Unknown";
|
||||||
|
const existing = demandByRole.get(roleName) ?? { needed: 0, filled: 0 };
|
||||||
|
existing.needed += demand.headcount;
|
||||||
|
existing.filled += Math.min(demand._count.assignments, demand.headcount);
|
||||||
|
demandByRole.set(roleName, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = await db.resource.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: {
|
||||||
|
skills: true,
|
||||||
|
areaRole: { select: { name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const skillSupply = new Map<string, number>();
|
||||||
|
const supplyByRole = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const resource of resources) {
|
||||||
|
const rawSkills = Array.isArray(resource.skills)
|
||||||
|
? resource.skills as Array<Record<string, unknown>>
|
||||||
|
: [];
|
||||||
|
for (const entry of rawSkills) {
|
||||||
|
const skillName = typeof entry.skill === "string"
|
||||||
|
? entry.skill
|
||||||
|
: typeof entry.name === "string"
|
||||||
|
? entry.name
|
||||||
|
: null;
|
||||||
|
if (!skillName) continue;
|
||||||
|
skillSupply.set(skillName.toLowerCase(), (skillSupply.get(skillName.toLowerCase()) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleName = resource.areaRole?.name;
|
||||||
|
if (roleName) {
|
||||||
|
supplyByRole.set(roleName, (supplyByRole.get(roleName) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleGaps = [...demandByRole.entries()]
|
||||||
|
.map(([role, { needed, filled }]) => ({
|
||||||
|
role,
|
||||||
|
needed,
|
||||||
|
filled,
|
||||||
|
gap: needed - filled,
|
||||||
|
fillRate: needed > 0 ? Math.round((filled / needed) * 100) : 100,
|
||||||
|
}))
|
||||||
|
.filter((gap) => gap.gap > 0)
|
||||||
|
.sort((left, right) => right.gap - left.gap);
|
||||||
|
|
||||||
|
return {
|
||||||
|
roleGaps,
|
||||||
|
totalOpenPositions: roleGaps.reduce((sum, gap) => sum + gap.gap, 0),
|
||||||
|
skillSupplyTop10: [...skillSupply.entries()]
|
||||||
|
.sort((left, right) => right[1] - left[1])
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(([skill, resourceCount]) => ({ skill, resourceCount })),
|
||||||
|
resourcesByRole: [...supplyByRole.entries()]
|
||||||
|
.sort((left, right) => right[1] - left[1])
|
||||||
|
.map(([role, count]) => ({ role, count })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ export {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
getDashboardSkillGaps,
|
getDashboardSkillGaps,
|
||||||
|
getDashboardSkillGapSummary,
|
||||||
type SkillGapRow,
|
type SkillGapRow,
|
||||||
|
type DashboardSkillGapSummary,
|
||||||
} from "./get-skill-gaps.js";
|
} from "./get-skill-gaps.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- GIN indexes for JSONB dynamic field filtering
|
||||||
|
-- Run: psql $DATABASE_URL -f packages/db/prisma/migrations/20260310_jsonb_gin_indexes.sql
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_resource_dynamic_fields_gin
|
||||||
|
ON resources USING gin ("dynamicFields" jsonb_path_ops);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_project_dynamic_fields_gin
|
||||||
|
ON projects USING gin ("dynamicFields" jsonb_path_ops);
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
-- Additive persistence split for planning demand vs assignment
|
||||||
|
-- Run: psql $DATABASE_URL -f packages/db/prisma/migrations/20260313_demand_assignment_additive.sql
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS demand_requirements (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
"legacyAllocationId" text UNIQUE,
|
||||||
|
"projectId" text NOT NULL REFERENCES projects(id),
|
||||||
|
"startDate" date NOT NULL,
|
||||||
|
"endDate" date NOT NULL,
|
||||||
|
"hoursPerDay" double precision NOT NULL,
|
||||||
|
percentage double precision NOT NULL,
|
||||||
|
role text,
|
||||||
|
"roleId" text REFERENCES roles(id),
|
||||||
|
headcount integer NOT NULL DEFAULT 1,
|
||||||
|
status "AllocationStatus" NOT NULL DEFAULT 'PROPOSED',
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS assignments (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
"legacyAllocationId" text UNIQUE,
|
||||||
|
"demandRequirementId" text REFERENCES demand_requirements(id),
|
||||||
|
"resourceId" text NOT NULL REFERENCES resources(id),
|
||||||
|
"projectId" text NOT NULL REFERENCES projects(id),
|
||||||
|
"startDate" date NOT NULL,
|
||||||
|
"endDate" date NOT NULL,
|
||||||
|
"hoursPerDay" double precision NOT NULL,
|
||||||
|
percentage double precision NOT NULL,
|
||||||
|
role text,
|
||||||
|
"roleId" text REFERENCES roles(id),
|
||||||
|
"dailyCostCents" integer NOT NULL,
|
||||||
|
status "AllocationStatus" NOT NULL DEFAULT 'PROPOSED',
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS unique_assignment
|
||||||
|
ON assignments ("resourceId", "projectId", "startDate", "endDate");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_demand_requirements_legacy_allocation_id
|
||||||
|
ON demand_requirements ("legacyAllocationId");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_demand_requirements_project_id
|
||||||
|
ON demand_requirements ("projectId");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_demand_requirements_start_end
|
||||||
|
ON demand_requirements ("startDate", "endDate");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_demand_requirements_status
|
||||||
|
ON demand_requirements (status);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assignments_legacy_allocation_id
|
||||||
|
ON assignments ("legacyAllocationId");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assignments_demand_requirement_id
|
||||||
|
ON assignments ("demandRequirementId");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assignments_resource_id
|
||||||
|
ON assignments ("resourceId");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assignments_project_id
|
||||||
|
ON assignments ("projectId");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assignments_start_end
|
||||||
|
ON assignments ("startDate", "endDate");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assignments_status
|
||||||
|
ON assignments (status);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "DispoImportSourceKind" ADD VALUE IF NOT EXISTS 'ROSTER';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user