From 819345acfab41d0efa6a73c48af6d8a72e25d069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 00:27:31 +0200 Subject: [PATCH] feat(platform): harden access scoping and delivery baseline --- .github/workflows/deploy-prod.yml | 86 + .github/workflows/deploy-staging.yml | 86 + .github/workflows/nightly-security.yml | 35 + .github/workflows/release-image.yml | 63 + .gitignore | 1 + Dockerfile.prod | 11 +- .../app/(app)/projects/[id]/scenario/page.tsx | 2 +- .../app/(app)/resources/ResourcesClient.tsx | 12 +- apps/web/src/app/api/sse/timeline/route.ts | 58 +- .../src/components/admin/BatchSkillImport.tsx | 2 +- .../allocations/AllocationModal.tsx | 2 +- .../allocations/FillOpenDemandModal.tsx | 2 +- .../useComputationGraphData.ts | 2 +- .../components/estimates/EstimateWizard.tsx | 2 +- .../EstimateWorkspaceDraftEditor.tsx | 2 +- .../src/components/projects/ProjectWizard.tsx | 2 +- .../components/resources/BulkEditModal.tsx | 5 +- .../components/resources/ResourceModal.tsx | 83 +- .../src/components/timeline/ProjectPanel.tsx | 2 +- .../components/timeline/TimelineContext.tsx | 93 +- .../timeline/TimelineQuickFilters.tsx | 4 +- .../components/timeline/TimelineToolbar.tsx | 2 +- .../src/components/timeline/TimelineView.tsx | 54 +- .../src/components/ui/ResourceCombobox.tsx | 4 +- .../vacations/PublicHolidayBatch.tsx | 2 +- .../src/components/vacations/TeamCalendar.tsx | 2 +- .../components/vacations/VacationClient.tsx | 2 +- .../components/vacations/VacationModal.tsx | 2 +- .../src/hooks/useInvalidatePlanningViews.ts | 6 + apps/web/src/hooks/useProjectDragContext.ts | 4 +- apps/web/src/hooks/useTimelineSSE.ts | 13 + apps/web/src/server/trpc.ts | 2 +- docker-compose.cicd.yml | 77 + docs/README.md | 2 + docs/ai-excellence-due-diligence-roadmap.md | 239 + docs/assistant-capability-gap-analysis.md | 111 +- docs/cicd-target-architecture.md | 193 + ...formance-optimization-review-2026-03-18.md | 2 +- docs/route-access-matrix.md | 122 + .../src/__tests__/allocation-router.test.ts | 444 +- .../src/__tests__/assistant-router.test.ts | 405 +- .../assistant-tools-advanced.test.ts | 213 +- .../__tests__/assistant-tools-country.test.ts | 49 +- .../assistant-tools-holidays.test.ts | 267 +- .../assistant-tools-import-export.test.ts | 6233 +++++++++++- .../src/__tests__/audit-log-router.test.ts | 124 + .../chargeability-report-router.test.ts | 107 + .../computation-graph-router.test.ts | 123 + .../src/__tests__/dashboard-router.test.ts | 323 +- .../src/__tests__/entitlement-router.test.ts | 112 + .../api/src/__tests__/estimate-router.test.ts | 59 + .../src/__tests__/event-bus-debounce.test.ts | 78 + .../__tests__/holiday-calendar-router.test.ts | 266 + .../__tests__/identifier-resolvers.test.ts | 207 + .../api/src/__tests__/insights-router.test.ts | 198 + .../api/src/__tests__/project-router.test.ts | 316 +- .../src/__tests__/rate-card-router.test.ts | 72 +- .../api/src/__tests__/report-router.test.ts | 76 + .../__tests__/resource-router-crud.test.ts | 73 +- .../api/src/__tests__/resource-router.test.ts | 892 +- .../api/src/__tests__/staffing-router.test.ts | 298 + .../api/src/__tests__/timeline-router.test.ts | 638 ++ .../api/src/__tests__/vacation-router.test.ts | 272 +- packages/api/src/db/selects.ts | 2 +- packages/api/src/lib/audit.ts | 7 +- packages/api/src/middleware/rate-limit.ts | 15 +- packages/api/src/router/allocation.ts | 955 +- packages/api/src/router/assistant-tools.ts | 8447 +++++++---------- packages/api/src/router/assistant.ts | 498 +- packages/api/src/router/audit-log.ts | 427 +- packages/api/src/router/blueprint.ts | 76 + .../api/src/router/chargeability-report.ts | 488 +- packages/api/src/router/client.ts | 99 +- packages/api/src/router/computation-graph.ts | 1943 ++-- packages/api/src/router/country.ts | 109 +- packages/api/src/router/dashboard.ts | 337 +- packages/api/src/router/entitlement.ts | 285 +- packages/api/src/router/estimate.ts | 423 +- packages/api/src/router/holiday-calendar.ts | 527 +- packages/api/src/router/insights.ts | 562 +- packages/api/src/router/notification.ts | 155 + packages/api/src/router/org-unit.ts | 92 + packages/api/src/router/project.ts | 497 +- packages/api/src/router/rate-card.ts | 240 +- packages/api/src/router/report.ts | 213 +- packages/api/src/router/resource.ts | 1282 ++- packages/api/src/router/role.ts | 81 + packages/api/src/router/staffing.ts | 1261 ++- packages/api/src/router/timeline.ts | 606 +- packages/api/src/router/vacation.ts | 240 +- packages/api/src/sse/event-bus.ts | 193 +- .../src/__tests__/dashboard.test.ts | 30 + packages/application/src/index.ts | 2 + .../src/use-cases/dashboard/get-overview.ts | 7 + .../use-cases/dashboard/get-project-health.ts | 3 + .../src/use-cases/dashboard/get-skill-gaps.ts | 108 + .../src/use-cases/dashboard/index.ts | 2 + .../migrations/20260310_jsonb_gin_indexes.sql | 8 + .../20260313_demand_assignment_additive.sql | 71 + ...260314_dispo_import_roster_source_kind.sql | 1 + ...314_dispo_import_staged_resource_rates.sql | 3 + .../20260314_dispo_import_staging.sql | 328 + .../20260328_assistant_approvals.sql | 29 + .../20260328_holiday_calendar_integrity.sql | 143 + .../migrations/20260328_holiday_calendars.sql | 52 + .../migrations/20260328_report_templates.sql | 26 + tooling/deploy/.env.production.example | 13 + tooling/deploy/README.md | 41 + tooling/deploy/deploy-compose.sh | 57 + 109 files changed, 26142 insertions(+), 8081 deletions(-) create mode 100644 .github/workflows/deploy-prod.yml create mode 100644 .github/workflows/deploy-staging.yml create mode 100644 .github/workflows/nightly-security.yml create mode 100644 .github/workflows/release-image.yml create mode 100644 docker-compose.cicd.yml create mode 100644 docs/ai-excellence-due-diligence-roadmap.md create mode 100644 docs/cicd-target-architecture.md create mode 100644 docs/route-access-matrix.md create mode 100644 packages/api/src/__tests__/audit-log-router.test.ts create mode 100644 packages/api/src/__tests__/identifier-resolvers.test.ts create mode 100644 packages/api/src/__tests__/insights-router.test.ts create mode 100644 packages/api/src/__tests__/timeline-router.test.ts create mode 100644 packages/db/prisma/migrations/20260310_jsonb_gin_indexes.sql create mode 100644 packages/db/prisma/migrations/20260313_demand_assignment_additive.sql create mode 100644 packages/db/prisma/migrations/20260314_dispo_import_roster_source_kind.sql create mode 100644 packages/db/prisma/migrations/20260314_dispo_import_staged_resource_rates.sql create mode 100644 packages/db/prisma/migrations/20260314_dispo_import_staging.sql create mode 100644 packages/db/prisma/migrations/20260328_assistant_approvals.sql create mode 100644 packages/db/prisma/migrations/20260328_holiday_calendar_integrity.sql create mode 100644 packages/db/prisma/migrations/20260328_holiday_calendars.sql create mode 100644 packages/db/prisma/migrations/20260328_report_templates.sql create mode 100644 tooling/deploy/.env.production.example create mode 100644 tooling/deploy/README.md create mode 100755 tooling/deploy/deploy-compose.sh diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 0000000..5985603 --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,86 @@ +name: Deploy Production + +on: + workflow_dispatch: + inputs: + image_tag: + description: Image tag to promote to production, for example sha- + 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 < + 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 < + 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 diff --git a/.gitignore b/.gitignore index e5d25db..db6e0f8 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ vectors.db # Prisma packages/db/prisma/migrations/* !packages/db/prisma/migrations/.gitkeep +!packages/db/prisma/migrations/** # Editors and OS files .vscode/* diff --git a/Dockerfile.prod b/Dockerfile.prod index 81f5758..17f0f19 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -47,7 +47,16 @@ ENV NODE_ENV=production 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 diff --git a/apps/web/src/app/(app)/projects/[id]/scenario/page.tsx b/apps/web/src/app/(app)/projects/[id]/scenario/page.tsx index 3efee4e..e16906e 100644 --- a/apps/web/src/app/(app)/projects/[id]/scenario/page.tsx +++ b/apps/web/src/app/(app)/projects/[id]/scenario/page.tsx @@ -20,7 +20,7 @@ export default async function ScenarioPage({ params }: ScenarioPageProps) { // Load resources and roles for the pickers const [resources, roles] = await Promise.all([ - trpc.resource.list({ isActive: true }), + trpc.resource.listStaff({ isActive: true }), trpc.role.list({ isActive: true }), ]); diff --git a/apps/web/src/app/(app)/resources/ResourcesClient.tsx b/apps/web/src/app/(app)/resources/ResourcesClient.tsx index 84ef356..12742f1 100644 --- a/apps/web/src/app/(app)/resources/ResourcesClient.tsx +++ b/apps/web/src/app/(app)/resources/ResourcesClient.tsx @@ -212,7 +212,7 @@ export function ResourcesClient() { fetchNextPage, hasNextPage, // 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", search: search || undefined, @@ -309,13 +309,15 @@ export function ResourcesClient() { // ─── Mutations ──────────────────────────────────────────────────────────── const deactivateMutation = trpc.resource.deactivate.useMutation({ - onSuccess: async () => { - await utils.resource.list.invalidate(); + onSuccess: () => { + void utils.resource.directory.invalidate(); + void utils.resource.listStaff.invalidate(); }, }); const batchDeactivateMutation = trpc.resource.batchDeactivate.useMutation({ - onSuccess: async () => { - await utils.resource.list.invalidate(); + onSuccess: () => { + void utils.resource.directory.invalidate(); + void utils.resource.listStaff.invalidate(); selection.clear(); }, }); diff --git a/apps/web/src/app/api/sse/timeline/route.ts b/apps/web/src/app/api/sse/timeline/route.ts index 533fd4e..38ffc13 100644 --- a/apps/web/src/app/api/sse/timeline/route.ts +++ b/apps/web/src/app/api/sse/timeline/route.ts @@ -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 { 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"; // Start the reminder scheduler (idempotent — only starts once) @@ -16,6 +18,38 @@ export async function GET() { 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([ + userAudience(dbUser.id), + roleAudience(dbUser.systemRole), + ]); + for (const permission of permissions) { + audiences.add(permissionAudience(permission)); + } + const encoder = new TextEncoder(); const stream = new ReadableStream({ @@ -26,13 +60,19 @@ export async function GET() { ); // Subscribe to event bus - const unsubscribe = eventBus.subscribe((event) => { - try { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); - } catch { - // Client disconnected - } - }); + const unsubscribe = eventBus.subscribe( + (event) => { + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } catch { + // Client disconnected + } + }, + { + audiences, + includeUnscoped: false, + }, + ); // Heartbeat every 30 seconds const heartbeat = setInterval(() => { diff --git a/apps/web/src/components/admin/BatchSkillImport.tsx b/apps/web/src/components/admin/BatchSkillImport.tsx index db3cf46..6eaf7b3 100644 --- a/apps/web/src/components/admin/BatchSkillImport.tsx +++ b/apps/web/src/components/admin/BatchSkillImport.tsx @@ -23,7 +23,7 @@ export function BatchSkillImport() { const fileRef = useRef(null); 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 }, { staleTime: 60_000 }, ); diff --git a/apps/web/src/components/allocations/AllocationModal.tsx b/apps/web/src/components/allocations/AllocationModal.tsx index 11a3edf..a490786 100644 --- a/apps/web/src/components/allocations/AllocationModal.tsx +++ b/apps/web/src/components/allocations/AllocationModal.tsx @@ -60,7 +60,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo const panelRef = useRef(null); useFocusTrap(panelRef, true); - const { data: resources } = trpc.resource.list.useQuery( + const { data: resources } = trpc.resource.directory.useQuery( { isActive: true, limit: 500 }, { staleTime: 60_000 }, ); diff --git a/apps/web/src/components/allocations/FillOpenDemandModal.tsx b/apps/web/src/components/allocations/FillOpenDemandModal.tsx index a0b4564..66576bb 100644 --- a/apps/web/src/components/allocations/FillOpenDemandModal.tsx +++ b/apps/web/src/components/allocations/FillOpenDemandModal.tsx @@ -72,7 +72,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen const invalidatePlanningViews = useInvalidatePlanningViews(); - const { data: resources } = trpc.resource.list.useQuery( + const { data: resources } = trpc.resource.listStaff.useQuery( { isActive: true, search: debouncedSearch || undefined, limit: 50 }, { staleTime: 15_000 }, ) as { data: { resources: Array<{ id: string; displayName: string; eid: string; lcrCents: number }> } | undefined }; diff --git a/apps/web/src/components/analytics/computation-graph/useComputationGraphData.ts b/apps/web/src/components/analytics/computation-graph/useComputationGraphData.ts index a5329a4..5e9dbce 100644 --- a/apps/web/src/components/analytics/computation-graph/useComputationGraphData.ts +++ b/apps/web/src/components/analytics/computation-graph/useComputationGraphData.ts @@ -56,7 +56,7 @@ export function useComputationGraphData(): ComputationGraphState { const [domainFilter, setDomainFilter] = useState>(new Set()); // Load selectors - const { data: resourceData } = trpc.resource.list.useQuery( + const { data: resourceData } = trpc.resource.directory.useQuery( { isActive: true, limit: 500 }, { staleTime: 60_000 }, ); diff --git a/apps/web/src/components/estimates/EstimateWizard.tsx b/apps/web/src/components/estimates/EstimateWizard.tsx index 35dcce1..35de50e 100644 --- a/apps/web/src/components/estimates/EstimateWizard.tsx +++ b/apps/web/src/components/estimates/EstimateWizard.tsx @@ -152,7 +152,7 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) { const utils = trpc.useUtils(); const projectsQuery = trpc.project.list.useQuery({ limit: 200 }, { 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 }, { staleTime: 60_000 }, ); diff --git a/apps/web/src/components/estimates/EstimateWorkspaceDraftEditor.tsx b/apps/web/src/components/estimates/EstimateWorkspaceDraftEditor.tsx index bcd0e89..7071d83 100644 --- a/apps/web/src/components/estimates/EstimateWorkspaceDraftEditor.tsx +++ b/apps/web/src/components/estimates/EstimateWorkspaceDraftEditor.tsx @@ -103,7 +103,7 @@ export function EstimateWorkspaceDraftEditor({ }) { const utils = trpc.useUtils(); const versions = estimate.versions as EstimateVersionView[]; - const resourcesQuery = trpc.resource.list.useQuery( + const resourcesQuery = trpc.resource.listStaff.useQuery( { isActive: true, limit: 200 }, { staleTime: 15_000 }, ); diff --git a/apps/web/src/components/projects/ProjectWizard.tsx b/apps/web/src/components/projects/ProjectWizard.tsx index b6f4db0..773497a 100644 --- a/apps/web/src/components/projects/ProjectWizard.tsx +++ b/apps/web/src/components/projects/ProjectWizard.tsx @@ -316,7 +316,7 @@ function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v }, [query]); // 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 }, // eslint-disable-next-line @typescript-eslint/no-explicit-any { staleTime: 15_000, placeholderData: (prev: any) => prev }, diff --git a/apps/web/src/components/resources/BulkEditModal.tsx b/apps/web/src/components/resources/BulkEditModal.tsx index bd8a3d5..6503624 100644 --- a/apps/web/src/components/resources/BulkEditModal.tsx +++ b/apps/web/src/components/resources/BulkEditModal.tsx @@ -23,8 +23,9 @@ export function BulkEditModal({ selectedIds, fieldDefs, onClose, onSuccess }: Pr const utils = trpc.useUtils(); const mutation = trpc.resource.batchUpdateCustomFields.useMutation({ - onSuccess: async () => { - await utils.resource.list.invalidate(); + onSuccess: () => { + void utils.resource.directory.invalidate(); + void utils.resource.listStaff.invalidate(); onSuccess(); onClose(); }, diff --git a/apps/web/src/components/resources/ResourceModal.tsx b/apps/web/src/components/resources/ResourceModal.tsx index f4c224b..e74d5d8 100644 --- a/apps/web/src/components/resources/ResourceModal.tsx +++ b/apps/web/src/components/resources/ResourceModal.tsx @@ -12,8 +12,11 @@ interface RoleAssignment { isPrimary: boolean; } -type CountryWithCities = { id: string; metroCities: { id: string; name: string }[] }; -type ManagementGroupWithLevels = { id: string; levels: { id: string; name: string }[] }; +type RoleOption = { id: string; name: string; color?: string | null }; +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 { 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: 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 - const countryRows = (countries ?? []) as unknown as CountryWithCities[]; - const selectedCountry = countryRows.find((c) => c.id === form.countryId); + const selectedCountry = countryOptions.find((c) => c.id === form.countryId); const metroCities = selectedCountry?.metroCities ?? []; // Derive levels from selected group - const managementGroups = (mgmtGroups ?? []) as unknown as ManagementGroupWithLevels[]; - const selectedGroup = managementGroups.find((g) => g.id === form.managementLevelGroupId); + const selectedGroup = managementGroupOptions.find((g) => g.id === form.managementLevelGroupId); const mgmtLevels = selectedGroup?.levels ?? []; - const createMutation = trpc.resource.create.useMutation({ - onSuccess: async () => { - 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 createMutation = trpc.resource.create.useMutation(); + const updateMutation = trpc.resource.update.useMutation(); 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(); setErrorMsg(null); const payload = buildPayload(); - if (mode === "create") { - createMutation.mutate(payload); - } else if (resource) { - updateMutation.mutate({ id: resource.id, data: payload }); + try { + if (mode === "create") { + await createMutation.mutateAsync(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)} > - {(availableRoles ?? []).map((r) => ( + {roleOptions.map((r) => ( ))} @@ -552,8 +551,8 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) { }} > - {(countries ?? []).map((c) => ( - + {countryOptions.map((c) => ( + ))} @@ -584,10 +583,10 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) { onChange={(e) => setField("orgUnitId", e.target.value)} > - {(orgUnits ?? []) - .filter((u) => (u as unknown as { level: number }).level === 7 && (u as unknown as { isActive: boolean }).isActive) + {orgUnitOptions + .filter((u) => u.level === 7 && u.isActive) .map((u) => ( - + ))} @@ -600,8 +599,8 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) { onChange={(e) => setField("clientUnitId", e.target.value)} > - {(clients ?? []).map((c) => ( - + {clientOptions.map((c) => ( + ))} @@ -620,8 +619,8 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) { }} > - {(mgmtGroups ?? []).map((g) => ( - + {managementGroupOptions.map((g) => ( + ))} @@ -895,7 +894,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {

Roles

- {(availableRoles ?? []).map((role) => { + {roleOptions.map((role) => { const assignment = form.roles.find((r) => r.roleId === role.id); const isChecked = Boolean(assignment); @@ -942,7 +941,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
); })} - {(availableRoles ?? []).length === 0 && ( + {roleOptions.length === 0 && (

No roles defined yet. Create roles on the Roles page.

)} diff --git a/apps/web/src/components/timeline/ProjectPanel.tsx b/apps/web/src/components/timeline/ProjectPanel.tsx index 06f6d8f..10335d9 100644 --- a/apps/web/src/components/timeline/ProjectPanel.tsx +++ b/apps/web/src/components/timeline/ProjectPanel.tsx @@ -125,7 +125,7 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) { >({}); const [confirmDelete, setConfirmDelete] = useState(null); - const { data: allResources } = trpc.resource.list.useQuery( + const { data: allResources } = trpc.resource.directory.useQuery( { search: resourceSearch }, { enabled: addingMember, staleTime: 10_000 }, ); diff --git a/apps/web/src/components/timeline/TimelineContext.tsx b/apps/web/src/components/timeline/TimelineContext.tsx index 42293a5..67dbee9 100644 --- a/apps/web/src/components/timeline/TimelineContext.tsx +++ b/apps/web/src/components/timeline/TimelineContext.tsx @@ -8,6 +8,7 @@ import { type DemandRequirement, } from "@capakraken/shared"; import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useSession } from "next-auth/react"; import { useSearchParams } from "next/navigation"; import { useTimelineSSE } from "~/hooks/useTimelineSSE.js"; import { trpc } from "~/lib/trpc/client.js"; @@ -217,7 +218,13 @@ export function TimelineProvider({ isDragging, children, }: TimelineProviderProps) { + const { data: session, status: sessionStatus } = useSession(); 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 d = new Date(); @@ -283,19 +290,25 @@ export function TimelineProvider({ // ─── Data queries ────────────────────────────────────────────────────────── 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( - { - 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 staffEntriesViewQuery = trpc.timeline.getEntriesView.useQuery( + timelineQueryInput, // 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 ) as { data: TimelineEntriesView | undefined; @@ -303,6 +316,23 @@ export function TimelineProvider({ refetch: () => Promise; }; + 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; + }; + + const entriesViewQuery = isSelfServiceTimeline ? selfEntriesViewQuery : staffEntriesViewQuery; const { data: entriesView, isLoading, refetch: refetchEntriesView } = entriesViewQuery; const assignments = entriesView?.assignments ?? []; @@ -316,24 +346,33 @@ export function TimelineProvider({ { 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 { data: holidayOverlayEntries = [], refetch: refetchHolidayOverlays, - } = trpc.timeline.getHolidayOverlays.useQuery( - { - 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 }, - ); + } = activeHolidayOverlayQuery; useEffect(() => { if (mountedRef.current) return; + if (isRoleLoading) return; mountedRef.current = true; // Harden client-side route transitions: the timeline must actively refresh @@ -341,7 +380,7 @@ export function TimelineProvider({ void refetchEntriesView(); void refetchVacations(); void refetchHolidayOverlays(); - }, [refetchEntriesView, refetchHolidayOverlays, refetchVacations]); + }, [isRoleLoading, refetchEntriesView, refetchHolidayOverlays, refetchVacations]); const vacationsByResource = useMemo(() => { const map = new Map(); @@ -378,9 +417,9 @@ export function TimelineProvider({ }, [holidayOverlayEntries, vacationEntries]); // 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 }, - { enabled: filters.eids.length > 0, staleTime: 30_000 }, + { enabled: !isSelfServiceTimeline && filters.eids.length > 0, staleTime: 30_000 }, ); // ─── Filtered entries ────────────────────────────────────────────────────── @@ -633,7 +672,7 @@ export function TimelineProvider({ ]); // eslint-disable-line react-hooks/exhaustive-deps // ─── Derived counts ─────────────────────────────────────────────────────── - const isInitialLoading = isLoading && !entriesView; + const isInitialLoading = (isRoleLoading || isLoading) && !entriesView; const totalAllocCount = entriesView?.allocations.length ?? 0; const activeFilterCount = filters.clientIds.length + diff --git a/apps/web/src/components/timeline/TimelineQuickFilters.tsx b/apps/web/src/components/timeline/TimelineQuickFilters.tsx index 42b3d00..16dd1ec 100644 --- a/apps/web/src/components/timeline/TimelineQuickFilters.tsx +++ b/apps/web/src/components/timeline/TimelineQuickFilters.tsx @@ -107,11 +107,11 @@ interface TimelineQuickFiltersProps { export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFiltersProps) { const [eidSearch, setEidSearch] = useState(""); 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 }, { staleTime: 60_000 }, ); - const { data: eidSearchData } = trpc.resource.list.useQuery( + const { data: eidSearchData } = trpc.resource.directory.useQuery( { isActive: true, search: eidSearch, limit: 100 }, { staleTime: 15_000 }, ); diff --git a/apps/web/src/components/timeline/TimelineToolbar.tsx b/apps/web/src/components/timeline/TimelineToolbar.tsx index d74d1c8..b689854 100644 --- a/apps/web/src/components/timeline/TimelineToolbar.tsx +++ b/apps/web/src/components/timeline/TimelineToolbar.tsx @@ -57,7 +57,7 @@ export function TimelineToolbar({ const [selectedResourceId, setSelectedResourceId] = useState(null); // Look up resource to get EID when selected - const { data: resourceLookup } = trpc.resource.list.useQuery( + const { data: resourceLookup } = trpc.resource.directory.useQuery( { limit: 500 }, { staleTime: 60_000 }, ); diff --git a/apps/web/src/components/timeline/TimelineView.tsx b/apps/web/src/components/timeline/TimelineView.tsx index 186824c..6c7240f 100644 --- a/apps/web/src/components/timeline/TimelineView.tsx +++ b/apps/web/src/components/timeline/TimelineView.tsx @@ -1,6 +1,7 @@ "use client"; import { clsx } from "clsx"; +import { useSession } from "next-auth/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { useAllocationHistory } from "~/hooks/useAllocationHistory.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. export function TimelineView() { + const { data: session, status: sessionStatus } = useSession(); 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 pushHistoryRef = useRef(pushHistory); @@ -147,8 +154,8 @@ export function TimelineView() { const [openPanelProjectId, setOpenPanelProjectId] = useState(null); const dragProjectId = dragState.isDragging ? dragState.projectId : null; - const contextProjectId = dragProjectId ?? openPanelProjectId; - const { contextResourceIds, contextAllocations } = useProjectDragContext(contextProjectId); + const contextProjectId = canManageTimeline ? (dragProjectId ?? openPanelProjectId) : null; + const { contextResourceIds, contextAllocations } = useProjectDragContext(contextProjectId, canManageTimeline); return ( @@ -232,6 +240,7 @@ function TimelineViewContent({ setOpenPanelProjectId, canUndo, canRedo, + isSelfServiceTimeline, undo, redo, }: { @@ -278,6 +287,7 @@ function TimelineViewContent({ setOpenPanelProjectId: React.Dispatch>; canUndo: boolean; canRedo: boolean; + isSelfServiceTimeline: boolean; undo: () => Promise; redo: () => Promise; }) { @@ -642,7 +652,7 @@ function TimelineViewContent({ onMouseUp={(e) => void onCanvasMouseUp(e)} onMouseLeave={onCanvasMouseLeave} onMouseDown={(e) => { - if (e.button === 2) { + if (!isSelfServiceTimeline && e.button === 2) { onCanvasRightMouseDown(e); } }} @@ -666,11 +676,11 @@ function TimelineViewContent({ rangeState={effectiveRangeState} shiftPreview={shiftPreview} contextResourceIds={contextResourceIds} - onAllocMouseDown={onAllocMouseDown} - onAllocTouchStart={onAllocTouchStart} - onRowMouseDown={onRowMouseDown} - onRowTouchStart={onRowTouchStart} - onAllocationContextMenu={openAllocationPopoverAt} + onAllocMouseDown={isSelfServiceTimeline ? () => undefined : onAllocMouseDown} + onAllocTouchStart={isSelfServiceTimeline ? () => undefined : onAllocTouchStart} + onRowMouseDown={isSelfServiceTimeline ? () => undefined : onRowMouseDown} + onRowTouchStart={isSelfServiceTimeline ? () => undefined : onRowTouchStart} + onAllocationContextMenu={isSelfServiceTimeline ? () => undefined : openAllocationPopoverAt} multiSelectState={multiSelectState} CELL_WIDTH={CELL_WIDTH} dates={dates} @@ -689,15 +699,15 @@ function TimelineViewContent({ allocDragState={allocDragState} rangeState={effectiveRangeState} multiSelectState={multiSelectState} - onProjectBarMouseDown={onProjectBarMouseDown} - onProjectBarTouchStart={onProjectBarTouchStart} - onAllocMouseDown={onAllocMouseDown} - onAllocTouchStart={onAllocTouchStart} - onRowMouseDown={onRowMouseDown} - onRowTouchStart={onRowTouchStart} - onOpenPanel={setOpenPanelProjectId} - onOpenDemandClick={setOpenDemandToAssign} - onAllocationContextMenu={openAllocationPopoverAt} + onProjectBarMouseDown={isSelfServiceTimeline ? () => undefined : onProjectBarMouseDown} + onProjectBarTouchStart={isSelfServiceTimeline ? () => undefined : onProjectBarTouchStart} + onAllocMouseDown={isSelfServiceTimeline ? () => undefined : onAllocMouseDown} + onAllocTouchStart={isSelfServiceTimeline ? () => undefined : onAllocTouchStart} + onRowMouseDown={isSelfServiceTimeline ? () => undefined : onRowMouseDown} + onRowTouchStart={isSelfServiceTimeline ? () => undefined : onRowTouchStart} + onOpenPanel={isSelfServiceTimeline ? () => undefined : setOpenPanelProjectId} + onOpenDemandClick={isSelfServiceTimeline ? () => undefined : setOpenDemandToAssign} + onAllocationContextMenu={isSelfServiceTimeline ? () => undefined : openAllocationPopoverAt} CELL_WIDTH={CELL_WIDTH} dates={dates} totalCanvasWidth={totalCanvasWidth} @@ -815,7 +825,7 @@ function TimelineViewContent({ )} {/* Allocation / Demand popover (click path) */} - {popover && (() => { + {!isSelfServiceTimeline && popover && (() => { // Check if clicked allocation is actually a demand const clickedDemand = openDemandsByProject.get(popover.projectId)?.find((d) => d.id === popover.allocationId); if (clickedDemand) { @@ -863,7 +873,7 @@ function TimelineViewContent({ })()} {/* Demand popover */} - {demandPopover && ( + {!isSelfServiceTimeline && demandPopover && ( setDemandPopover(null)} @@ -892,7 +902,7 @@ function TimelineViewContent({ )} {/* New allocation popover */} - {newAllocPopover && ( + {!isSelfServiceTimeline && newAllocPopover && ( setOpenPanelProjectId(null)} /> )} {/* Open-demand assignment modal */} - {openDemandToAssign && ( + {!isSelfServiceTimeline && openDemandToAssign && ( setOpenDemandToAssign(null)} diff --git a/apps/web/src/components/ui/ResourceCombobox.tsx b/apps/web/src/components/ui/ResourceCombobox.tsx index e8aaaed..7fb370a 100644 --- a/apps/web/src/components/ui/ResourceCombobox.tsx +++ b/apps/web/src/components/ui/ResourceCombobox.tsx @@ -20,7 +20,7 @@ export function ResourceCombobox({ ...props }: ResourceComboboxProps) { const useSearchQuery = (search: string, enabled: boolean) => { - const { data } = trpc.resource.list.useQuery( + const { data } = trpc.resource.directory.useQuery( { search: search || undefined, limit: 15, isActive }, { enabled, staleTime: 30_000 }, ); @@ -28,7 +28,7 @@ export function ResourceCombobox({ }; const useSelectedQuery = (_id: string | null, enabled: boolean) => { - const { data } = trpc.resource.list.useQuery( + const { data } = trpc.resource.directory.useQuery( { limit: 500 }, { enabled, staleTime: 60_000 }, ); diff --git a/apps/web/src/components/vacations/PublicHolidayBatch.tsx b/apps/web/src/components/vacations/PublicHolidayBatch.tsx index f8fdd7a..1ee4620 100644 --- a/apps/web/src/components/vacations/PublicHolidayBatch.tsx +++ b/apps/web/src/components/vacations/PublicHolidayBatch.tsx @@ -11,7 +11,7 @@ export function PublicHolidayBatch() { const [replaceExisting, setReplaceExisting] = useState(false); 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 }, { staleTime: 60_000 }, ); diff --git a/apps/web/src/components/vacations/TeamCalendar.tsx b/apps/web/src/components/vacations/TeamCalendar.tsx index f1f181b..4e19c95 100644 --- a/apps/web/src/components/vacations/TeamCalendar.tsx +++ b/apps/web/src/components/vacations/TeamCalendar.tsx @@ -25,7 +25,7 @@ export function TeamCalendar() { const firstDay = new Date(Date.UTC(year, month, 1)); 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 } : {}) }, { staleTime: 60_000 }, ); diff --git a/apps/web/src/components/vacations/VacationClient.tsx b/apps/web/src/components/vacations/VacationClient.tsx index c053104..da2086b 100644 --- a/apps/web/src/components/vacations/VacationClient.tsx +++ b/apps/web/src/components/vacations/VacationClient.tsx @@ -44,7 +44,7 @@ export function VacationClient() { { staleTime: 15_000 }, ); - const { data: resources } = trpc.resource.list.useQuery( + const { data: resources } = trpc.resource.directory.useQuery( { isActive: true, limit: 500 }, { staleTime: 60_000 }, ); diff --git a/apps/web/src/components/vacations/VacationModal.tsx b/apps/web/src/components/vacations/VacationModal.tsx index e015b33..c3519f9 100644 --- a/apps/web/src/components/vacations/VacationModal.tsx +++ b/apps/web/src/components/vacations/VacationModal.tsx @@ -91,7 +91,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces const debouncedStart = useDebounce(startDate, 400); const debouncedEnd = useDebounce(endDate, 400); - const { data: resources } = trpc.resource.list.useQuery( + const { data: resources } = trpc.resource.directory.useQuery( { isActive: true, limit: 500 }, { staleTime: 60_000 }, ); diff --git a/apps/web/src/hooks/useInvalidatePlanningViews.ts b/apps/web/src/hooks/useInvalidatePlanningViews.ts index 02c2bdb..160cd0f 100644 --- a/apps/web/src/hooks/useInvalidatePlanningViews.ts +++ b/apps/web/src/hooks/useInvalidatePlanningViews.ts @@ -6,6 +6,9 @@ export function useInvalidateTimeline() { return () => { void utils.timeline.getEntries.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.getBudgetStatus.invalidate(); }; @@ -25,6 +28,9 @@ export function useInvalidatePlanningViews() { void utils.allocation.listAssignments.invalidate(); void utils.timeline.getEntries.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.getBudgetStatus.invalidate(); }; diff --git a/apps/web/src/hooks/useProjectDragContext.ts b/apps/web/src/hooks/useProjectDragContext.ts index 2c26ddf..e0f081d 100644 --- a/apps/web/src/hooks/useProjectDragContext.ts +++ b/apps/web/src/hooks/useProjectDragContext.ts @@ -18,11 +18,11 @@ type ProjectDragContextResult = { 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 const { data } = trpc.timeline.getProjectContext.useQuery( { projectId: projectId! }, - { enabled: !!projectId, staleTime: 10_000 }, + { enabled: enabled && !!projectId, staleTime: 10_000 }, ) as { data: any }; return { diff --git a/apps/web/src/hooks/useTimelineSSE.ts b/apps/web/src/hooks/useTimelineSSE.ts index 0e51758..cc8dd60 100644 --- a/apps/web/src/hooks/useTimelineSSE.ts +++ b/apps/web/src/hooks/useTimelineSSE.ts @@ -28,11 +28,19 @@ export function useTimelineSSE() { case SSE_EVENT_TYPES.ALLOCATION_UPDATED: case SSE_EVENT_TYPES.ALLOCATION_DELETED: 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"]] }); break; case SSE_EVENT_TYPES.PROJECT_SHIFTED: 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"]] }); break; @@ -40,6 +48,11 @@ export function useTimelineSSE() { void queryClient.invalidateQueries({ queryKey: [["timeline", "getBudgetStatus"]] }); 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_COMPLETED: case SSE_EVENT_TYPES.TASK_STATUS_CHANGED: diff --git a/apps/web/src/server/trpc.ts b/apps/web/src/server/trpc.ts index da31683..7867da6 100644 --- a/apps/web/src/server/trpc.ts +++ b/apps/web/src/server/trpc.ts @@ -6,7 +6,7 @@ import { auth } from "./auth.js"; /** * 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() { const session = await auth(); diff --git a/docker-compose.cicd.yml b/docker-compose.cicd.yml new file mode 100644 index 0000000..17ff2ab --- /dev/null +++ b/docker-compose.cicd.yml @@ -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 diff --git a/docs/README.md b/docs/README.md index 660c307..4de8694 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,8 @@ | 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 | | 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 | diff --git a/docs/ai-excellence-due-diligence-roadmap.md b/docs/ai-excellence-due-diligence-roadmap.md new file mode 100644 index 0000000..df6f954 --- /dev/null +++ b/docs/ai-excellence-due-diligence-roadmap.md @@ -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. diff --git a/docs/assistant-capability-gap-analysis.md b/docs/assistant-capability-gap-analysis.md index bbec0f4..128f451 100644 --- a/docs/assistant-capability-gap-analysis.md +++ b/docs/assistant-capability-gap-analysis.md @@ -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: -- fehlenden Admin- und Konfigurationsfaehigkeiten, -- fehlenden tiefen Fach-Readmodels, -- inkonsistentem Permission-Gating, -- fehlender serverseitiger Absicherung fuer schreibende AI-Aktionen, -- und einigen objektbezogenen Sichtbarkeitsfehlern. +- weiterhin fehlenden tiefen Fach-Readmodels und Spezialworkflows, +- noch nicht vollstaendiger Router-/Objektscope-Paritaet, +- fehlender Approval-/Governance-UX ausserhalb des Chats, +- und einigen verbleibenden objektbezogenen Sichtbarkeitsfehlern. ## Architektur des Assistants @@ -76,8 +75,38 @@ Es gibt aktuell vier Permission-/Scope-Ebenen: - `import_csv_data` - `list_dispo_import_batches` - `get_dispo_import_batch` - - damit sind CSV-Export, CSV-Import und die Batch-Uebersicht der Dispo-Importe jetzt ueber echte Router-Pfade verfuegbar -- Estimates: nur Suche, Detail und Anlegen, aber kein voller Lifecycle + - `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` + - 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 - Chargeability / Transparenz: - `get_chargeability_report` @@ -88,24 +117,24 @@ Es gibt aktuell vier Permission-/Scope-Ebenen: - vereinfachte History-Abfragen - echte Audit-API fuer Liste, Detail, Entity-History, Timeline und Activity Summary - 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 - 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 -- Webhook-Administration -- System Settings / AI / SMTP / Image-Provider Administration -- System Role Config Administration -- Import/Export-Flows -- User Self-Service und Preferences -- Country- und Metro-City-Administration -- Voller Estimate-Lifecycle -- Dispo-/Import-spezifische Flows +- Country- und Metro-City-Administration ausserhalb der bereits vorhandenen Kernmutationen +- Governance-/Approval-Workspace ausserhalb des Chats ## 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 @@ -174,6 +203,7 @@ Aktuell im Assistant vorhanden: Restluecke: - 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 @@ -261,9 +291,17 @@ Fehlend: - Webhooks: - 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 @@ -276,7 +314,7 @@ 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 @@ -296,50 +334,42 @@ Restluecke: ### 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: - Suche -- Baseline-Detail -- Anlegen +- Detail / Weekly Phasing / Commercial Terms +- Anlegen, Klonen, Draft-Update, Submit, Approve, Revision, Export, Planning Handoff Fehlend: -- der eigentliche Arbeitsprozess auf Estimate-Ebene +- tiefere Unterobjekt- und Spezialworkflows jenseits der bereits angebundenen Router-Prozeduren ### Notifications, Tasks und Reminder 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: -- Reminder-Liste -- Reminder-Update/Delete -- Unread Count -- Task Counts -- generische Notification-Erstellung mit derselben Tiefe wie `notificationRouter` +- weitere Spezialfaelle ausserhalb der bereits exponierten Notification-Router-Prozeduren ## Capability Gaps nach Router ### Komplett fehlende Router-Paritaet -- `settings` -- `systemRoleConfig` -- `webhook` +- derzeit keine in den zuvor priorisierten Admin-/Audit-/Import-Bereichen ### Deutlich unvollstaendige Router-Paritaet - `importExport` -- `dispo` - `timeline` (Kern-Readmodels und wichtigste Write-Paritaet vorhanden, Spezial-Workflows fehlen) - `vacation` +- `user` - `estimate` - `notification` -- `user` - `country` - `insights` - `scenario` @@ -354,6 +384,11 @@ Fehlend: - `staffing` - `report` - `dashboard` +- `settings` +- `systemRoleConfig` +- `webhook` +- `importExport` +- `dispo` ## 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` - `delete_holiday_entry` - `preview_resolved_holidays` +- Status: Kern-Read/Write-Pfad und Preview sind umgesetzt; offen bleiben nur weitergehende Editor-/Governance-Flows. 2. Timeline-Assistant-Strang bauen - Read: @@ -422,6 +458,7 @@ Die Human-in-the-Loop-Regel ist inzwischen serverseitig erzwungen. Der Prompt so - `get_chargeability_report` - `get_resource_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 diff --git a/docs/cicd-target-architecture.md b/docs/cicd-target-architecture.md new file mode 100644 index 0000000..b1e7fb3 --- /dev/null +++ b/docs/cicd-target-architecture.md @@ -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-` + +Example: + +```text +ghcr.io//capakraken-app:sha-abc123 +ghcr.io//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= +NEXTAUTH_URL=https://capakraken.example.com +NEXTAUTH_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-` +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. diff --git a/docs/performance-optimization-review-2026-03-18.md b/docs/performance-optimization-review-2026-03-18.md index a89d353..91f89ad 100644 --- a/docs/performance-optimization-review-2026-03-18.md +++ b/docs/performance-optimization-review-2026-03-18.md @@ -230,7 +230,7 @@ Estimated effort: medium 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()`. - On the resources page, fetch chargeability/stat enrichments only for the visible page or current filtered result slice. diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md new file mode 100644 index 0000000..c0c24bc --- /dev/null +++ b/docs/route-access-matrix.md @@ -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`. diff --git a/packages/api/src/__tests__/allocation-router.test.ts b/packages/api/src/__tests__/allocation-router.test.ts index 3cbd213..aa36e12 100644 --- a/packages/api/src/__tests__/allocation-router.test.ts +++ b/packages/api/src/__tests__/allocation-router.test.ts @@ -1,7 +1,12 @@ 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 { 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"; vi.mock("../sse/event-bus.js", () => ({ @@ -19,12 +24,29 @@ vi.mock("../lib/cache.js", () => ({ invalidateDashboardCache: vi.fn(), })); +vi.mock("../lib/auto-staffing.js", () => ({ + generateAutoSuggestions: vi.fn(), +})); + 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(), + }, +})); + const createCaller = createCallerFactory(allocationRouter); +beforeEach(() => { + vi.clearAllMocks(); +}); + function createManagerCaller(db: Record) { return createCaller({ session: { @@ -112,6 +134,9 @@ describe("allocation entry resolution router", () => { }, ]), }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, }; 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 () => { const createdDemandRequirement = { id: "demand_1", @@ -346,6 +462,217 @@ describe("allocation entry resolution router", () => { 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; + 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; + 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 () => { 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 () => { vi.mocked(emitAllocationDeleted).mockClear(); diff --git a/packages/api/src/__tests__/assistant-router.test.ts b/packages/api/src/__tests__/assistant-router.test.ts index 729b864..5531129 100644 --- a/packages/api/src/__tests__/assistant-router.test.ts +++ b/packages/api/src/__tests__/assistant-router.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared"; +import { apiRateLimiter } from "../middleware/rate-limit.js"; import { ASSISTANT_CONFIRMATION_PREFIX, canExecuteMutationTool, @@ -9,6 +10,7 @@ import { getAvailableAssistantTools, listPendingAssistantApprovals, peekPendingAssistantApproval, + selectAssistantToolsForRequest, } from "../router/assistant.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); } +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_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", () => { let approvalStore = createApprovalStoreMock(); beforeEach(() => { approvalStore = createApprovalStoreMock(); + apiRateLimiter.reset(); }); 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"); }); - it("keeps user administration tools behind manageUsers", () => { - const withoutManageUsers = getToolNames([]); - const withManageUsers = getToolNames([PermissionKey.MANAGE_USERS]); + it("keeps user self-service tools available to plain authenticated users", () => { + const userNames = getToolNames([], SystemRole.USER); - expect(withoutManageUsers).not.toContain("list_users"); - expect(withManageUsers).toContain("list_users"); + expect(userNames).toContain("get_current_user"); + 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", () => { @@ -273,6 +402,66 @@ describe("assistant router tool gating", () => { 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", () => { const managerNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.MANAGER); const controllerNames = getToolNames([], SystemRole.CONTROLLER); @@ -284,11 +473,54 @@ describe("assistant router tool gating", () => { expect(controllerNames).toContain("export_projects_csv"); expect(adminNames).toContain("list_dispo_import_batches"); 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("export_resources_csv"); expect(userNames).not.toContain("export_projects_csv"); expect(userNames).not.toContain("list_dispo_import_batches"); 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", () => { @@ -506,6 +738,59 @@ describe("assistant router tool gating", () => { 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", () => { expect(canExecuteMutationTool([ { 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("set_entitlement")).toContain("manageVacations"); - expect(toolDescriptions.get("create_org_unit")).toContain("manageResources"); - expect(toolDescriptions.get("update_org_unit")).toContain("manageResources"); - expect(toolDescriptions.get("list_users")).toContain("manageUsers"); - expect(toolDescriptions.get("create_task_for_user")).toContain("manageProjects"); - expect(toolDescriptions.get("send_broadcast")).toContain("manageProjects"); + expect(toolDescriptions.get("create_estimate_planning_handoff")).toContain("manageAllocations"); + expect(toolDescriptions.get("get_estimate_detail")).toContain("Controller/manager/admin"); + expect(toolDescriptions.get("list_estimate_versions")).toContain("Controller/manager/admin"); + expect(toolDescriptions.get("get_estimate_version_snapshot")).toContain("viewCosts"); + expect(toolDescriptions.get("get_estimate_weekly_phasing")).toContain("Controller/manager/admin"); + 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_entry")).toContain("Admin role"); 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("list_dispo_import_batches")).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("get_audit_log_entry")).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_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; + } + )?.properties?.status?.enum; + const updateEstimateStatus = ( + definitionByName.get("update_estimate_draft")?.parameters as { + properties?: Record; + } + )?.properties?.status?.enum; + const estimateExportFormats = ( + definitionByName.get("create_estimate_export")?.parameters as { + properties?: Record; + } + )?.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"]); + }); }); diff --git a/packages/api/src/__tests__/assistant-tools-advanced.test.ts b/packages/api/src/__tests__/assistant-tools-advanced.test.ts index d57f437..701b6a9 100644 --- a/packages/api/src/__tests__/assistant-tools-advanced.test.ts +++ b/packages/api/src/__tests__/assistant-tools-advanced.test.ts @@ -126,6 +126,15 @@ describe("assistant advanced tools and scoping", () => { findUnique: vi .fn() .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({ id: "project_lari", 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 }) => { + 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 () => { 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 ctx = createToolContext({ + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, notification: { 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 ctx = createToolContext({ + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, notification: { - findUnique: vi.fn().mockResolvedValue({ id: "notif_1", userId: "someone_else" }), update, }, }); - const result = await executeTool( + await executeTool( "mark_notification_read", JSON.stringify({ notificationId: "notif_1" }), ctx, ); - expect(JSON.parse(result.content)).toEqual({ - error: "Access denied: this notification does not belong to you", + expect(update).toHaveBeenCalledWith({ + 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 ctx = createToolContext({ user: { findMany, }, - }); + }, [], SystemRole.MANAGER); const result = await executeTool("list_users", JSON.stringify({ limit: 10 }), ctx); expect(JSON.parse(result.content)).toEqual( expect.objectContaining({ - error: expect.stringContaining(PermissionKey.MANAGE_USERS), + error: expect.stringContaining("Admin role required"), }), ); expect(findMany).not.toHaveBeenCalled(); diff --git a/packages/api/src/__tests__/assistant-tools-country.test.ts b/packages/api/src/__tests__/assistant-tools-country.test.ts index bffcac6..02f7fa0 100644 --- a/packages/api/src/__tests__/assistant-tools-country.test.ts +++ b/packages/api/src/__tests__/assistant-tools-country.test.ts @@ -12,30 +12,50 @@ function createToolContext( userId: "user_1", userRole, 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", () => { 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({ country: { - findMany: vi.fn().mockResolvedValue([ - { - id: "country_de", - code: "DE", - name: "Deutschland", - dailyWorkingHours: 8, - scheduleRules: null, - isActive: true, - metroCities: [{ id: "city_muc", name: "Munich" }], - }, - ]), + findMany, }, }); const result = await executeTool( "list_countries", - JSON.stringify({ includeInactive: true }), + JSON.stringify({ search: "deu" }), 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.countries[0]).toMatchObject({ code: "DE", diff --git a/packages/api/src/__tests__/assistant-tools-holidays.test.ts b/packages/api/src/__tests__/assistant-tools-holidays.test.ts index a143fb4..a5e49ba 100644 --- a/packages/api/src/__tests__/assistant-tools-holidays.test.ts +++ b/packages/api/src/__tests__/assistant-tools-holidays.test.ts @@ -6,6 +6,7 @@ vi.mock("@capakraken/application", async (importOriginal) => { return { ...actual, getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), }; }); @@ -25,6 +26,16 @@ function createToolContext( userId: "user_1", userRole, 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 .fn() .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" } }), 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 () => { const ctx = createToolContext({ country: { - findUnique: vi.fn().mockResolvedValue({ code: "DE" }), + findUnique: vi.fn().mockResolvedValue({ id: "country_de", code: "DE", name: "Deutschland" }), }, metroCity: { - findUnique: vi.fn().mockResolvedValue({ name: "Augsburg" }), + findUnique: vi.fn().mockResolvedValue({ id: "city_augsburg", name: "Augsburg", countryId: "country_de" }), }, holidayCalendar: { 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 () => { @@ -301,36 +321,58 @@ describe("assistant holiday tools", () => { }); 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 = { resource: { findUnique: vi .fn() - .mockResolvedValueOnce({ - id: "res_1", - 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, - }), + .mockResolvedValueOnce(resourceRecord), + findUniqueOrThrow: vi.fn().mockResolvedValue(resourceRecord), findFirst: vi.fn(), }, assignment: { findMany: vi.fn().mockResolvedValue([ { + id: "assign_1", hoursPerDay: 8, startDate: new Date("2026-01-05T00:00:00.000Z"), endDate: new Date("2026-01-06T00:00:00.000Z"), + dailyCostCents: 40000, 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); @@ -356,12 +398,12 @@ describe("assistant holiday tools", () => { expect(parsed.bookedHours).toBe(8); expect(parsed.allocations).toEqual([expect.objectContaining({ hours: 8 })]); - expect(parsed.baseWorkingDays).toBe(23); - expect(parsed.baseAvailableHours).toBe(184); - expect(parsed.availableHours).toBe(168); - expect(parsed.workingDays).toBe(21); - expect(parsed.targetHours).toBe(134.4); - expect(parsed.unassignedHours).toBe(160); + expect(parsed.baseWorkingDays).toBe(22); + expect(parsed.baseAvailableHours).toBe(176); + expect(parsed.availableHours).toBe(160); + expect(parsed.workingDays).toBe(20); + expect(parsed.targetHours).toBe(128); + expect(parsed.unassignedHours).toBe(152); expect(parsed.locationContext.federalState).toBe("BY"); expect(parsed.holidaySummary).toEqual( expect.objectContaining({ @@ -409,7 +451,6 @@ describe("assistant holiday tools", () => { }>; }; - expect(getDashboardBudgetForecast).toHaveBeenCalled(); expect(parsed.forecasts).toEqual([ expect.objectContaining({ projectName: "Gelddruckmaschine", @@ -425,21 +466,23 @@ describe("assistant holiday tools", () => { }); 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 = { resource: { findUnique: vi .fn() - .mockResolvedValueOnce({ - 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, - }), + .mockResolvedValue(resourceRecord), findFirst: vi.fn(), }, assignment: { @@ -581,13 +624,17 @@ describe("assistant holiday tools", () => { it("prefers resources without a local holiday in staffing suggestions", async () => { const db = { project: { - findFirst: vi.fn().mockResolvedValue({ + findUnique: vi.fn().mockResolvedValue({ id: "project_1", name: "Holiday Project", shortCode: "HP", startDate: 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: { findMany: vi.fn().mockResolvedValue([ @@ -597,15 +644,17 @@ describe("assistant holiday tools", () => { eid: "BY-1", fte: 1, lcrCents: 10000, + chargeabilityTarget: 80, + valueScore: 10, + skills: [], availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, countryId: "country_de", federalState: "BY", metroCityId: null, - country: { code: "DE" }, + country: { code: "DE", name: "Deutschland" }, metroCity: null, areaRole: { name: "Consultant" }, chapter: "CGI", - assignments: [], }, { id: "res_hh", @@ -613,21 +662,20 @@ describe("assistant holiday tools", () => { eid: "HH-1", fte: 1, lcrCents: 10000, + chargeabilityTarget: 80, + valueScore: 10, + skills: [], availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, countryId: "country_de", federalState: "HH", metroCityId: null, - country: { code: "DE" }, + country: { code: "DE", name: "Deutschland" }, metroCity: null, areaRole: { name: "Consultant" }, chapter: "CGI", - assignments: [], }, ]), }, - vacation: { - findMany: vi.fn().mockResolvedValue([]), - }, }; const ctx = createToolContext(db); @@ -645,6 +693,16 @@ describe("assistant holiday tools", () => { expect(parsed.suggestions[0]).toEqual( 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 () => { @@ -714,6 +772,12 @@ describe("assistant holiday tools", () => { id: "project_1", name: "Holiday Project", shortCode: "HP", + status: "ACTIVE", + responsiblePerson: null, + }) + .mockResolvedValueOnce({ + id: "project_1", + name: "Holiday Project", shoringThreshold: 55, onshoreCountryCode: "DE", }), @@ -726,6 +790,7 @@ describe("assistant holiday tools", () => { startDate: new Date("2026-01-06T00:00:00.000Z"), endDate: new Date("2026-01-06T00:00:00.000Z"), resource: { + id: "res_by", availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, countryId: "country_de", federalState: "BY", @@ -740,6 +805,7 @@ describe("assistant holiday tools", () => { startDate: new Date("2026-01-06T00:00:00.000Z"), endDate: new Date("2026-01-06T00:00:00.000Z"), resource: { + id: "res_in", availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, countryId: "country_in", federalState: null, @@ -765,4 +831,121 @@ describe("assistant holiday tools", () => { expect(result.content).toContain("0% onshore (DE), 100% offshore"); 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", + }), + ], + }), + ); + }); }); diff --git a/packages/api/src/__tests__/assistant-tools-import-export.test.ts b/packages/api/src/__tests__/assistant-tools-import-export.test.ts index 3687597..bb09de0 100644 --- a/packages/api/src/__tests__/assistant-tools-import-export.test.ts +++ b/packages/api/src/__tests__/assistant-tools-import-export.test.ts @@ -1,16 +1,79 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { VacationType } from "@capakraken/db"; +import { apiRateLimiter } from "../middleware/rate-limit.js"; vi.mock("@capakraken/application", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + countPlanningEntries: vi.fn().mockResolvedValue({ countsByRoleId: new Map() }), + loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map()), + getDashboardDemand: vi.fn().mockResolvedValue([]), getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardOverview: vi.fn(), + getDashboardSkillGapSummary: vi.fn().mockResolvedValue({ + roleGaps: [], + totalOpenPositions: 0, + skillSupplyTop10: [], + resourcesByRole: [], + }), + getDashboardProjectHealth: vi.fn().mockResolvedValue([]), getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + getDashboardTopValueResources: vi.fn().mockResolvedValue([]), + getEstimateById: vi.fn(), listAssignmentBookings: vi.fn().mockResolvedValue([]), }; }); +vi.mock("../lib/cache.js", () => ({ + cacheGet: vi.fn().mockResolvedValue(null), + cacheSet: vi.fn().mockResolvedValue(undefined), + cacheInvalidate: vi.fn().mockResolvedValue(undefined), + invalidateDashboardCache: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../ai-client.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createAiClient: vi.fn(() => ({ + chat: { + completions: { + create: vi.fn().mockResolvedValue({ + choices: [ + { + message: { + content: "Project is on track overall, but staffing remains partially open.", + }, + }, + ], + }), + }, + }, + })), + createDalleClient: vi.fn(() => ({ + images: { + generate: vi.fn().mockResolvedValue({ + data: [{ b64_json: "ZmFrZQ==" }], + }), + }, + })), + loggedAiCall: vi.fn(async (_provider, _model, _promptLength, fn) => fn()), + }; +}); + +import { + countPlanningEntries, + getDashboardDemand, + getDashboardOverview, + getDashboardProjectHealth, + getDashboardPeakTimes, + getDashboardSkillGapSummary, + getDashboardTopValueResources, + getEstimateById, + listAssignmentBookings, +} from "@capakraken/application"; import { executeTool, type ToolContext } from "../router/assistant-tools.js"; function createToolContext( @@ -40,6 +103,13 @@ function createToolContext( } describe("assistant import/export and dispo tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + apiRateLimiter.reset(); + vi.mocked(countPlanningEntries).mockResolvedValue({ countsByRoleId: new Map() }); + vi.mocked(getEstimateById).mockReset(); + }); + it("exports resources CSV through the real import/export router path", async () => { const ctx = createToolContext( { @@ -124,4 +194,6165 @@ describe("assistant import/export and dispo tools", () => { }), ); }); + + it("surfaces protected AI configuration checks to non-admin users", async () => { + const ctx = createToolContext( + { + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + aiProvider: "openai", + azureOpenAiDeployment: "gpt-4o-mini", + azureOpenAiApiKey: "secret", + }), + }, + }, + { userRole: SystemRole.USER }, + ); + + const result = await executeTool("get_ai_configured", "{}", ctx); + + expect(JSON.parse(result.content)).toEqual({ configured: true }); + }); + + it("masks webhook secrets in assistant responses", async () => { + const ctx = createToolContext( + { + webhook: { + findMany: vi.fn().mockResolvedValue([ + { + id: "wh_1", + name: "Primary", + url: "https://example.com/hook", + secret: "super-secret", + events: ["project.updated"], + isActive: true, + createdAt: new Date("2026-03-29T00:00:00.000Z"), + }, + ]), + findUnique: vi.fn().mockResolvedValue({ + id: "wh_1", + name: "Primary", + url: "https://example.com/hook", + secret: "super-secret", + events: ["project.updated"], + isActive: true, + createdAt: new Date("2026-03-29T00:00:00.000Z"), + }), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const listResult = await executeTool("list_webhooks", "{}", ctx); + const getResult = await executeTool("get_webhook", JSON.stringify({ id: "wh_1" }), ctx); + + expect(JSON.parse(listResult.content)).toEqual([ + expect.objectContaining({ + id: "wh_1", + hasSecret: true, + }), + ]); + expect(JSON.parse(listResult.content)[0]).not.toHaveProperty("secret"); + expect(JSON.parse(getResult.content)).toEqual( + expect.objectContaining({ + id: "wh_1", + hasSecret: true, + }), + ); + expect(JSON.parse(getResult.content)).not.toHaveProperty("secret"); + }); + + it("forwards staged dispo resource queries through the real dispo router path", async () => { + const ctx = createToolContext( + { + stagedResource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "sr_1", + importBatchId: "batch_1", + canonicalExternalId: "EMP-001", + status: "PARSED", + }, + ]), + }, + }, + { userRole: SystemRole.ADMIN }, + ); + + const result = await executeTool( + "list_dispo_staged_resources", + JSON.stringify({ importBatchId: "batch_1", status: "PARSED", limit: 10 }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + items: [ + expect.objectContaining({ + id: "sr_1", + importBatchId: "batch_1", + canonicalExternalId: "EMP-001", + status: "PARSED", + }), + ], + }); + }); + + it("lists notifications for the current session user through the real notification router path", async () => { + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findMany: vi.fn().mockResolvedValue([ + { + id: "note_1", + title: "Check staffing", + body: "Review the new staffing suggestion", + readAt: null, + createdAt: new Date("2026-03-29T08:00:00.000Z"), + }, + ]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const result = await executeTool( + "list_notifications", + JSON.stringify({ unreadOnly: true, limit: 5 }), + ctx, + ); + + expect(db.user.findUnique).toHaveBeenCalledWith({ + where: { email: "assistant@example.com" }, + select: { id: true }, + }); + expect(db.notification.findMany).toHaveBeenCalledWith({ + where: { + userId: "user_1", + readAt: null, + }, + orderBy: { createdAt: "desc" }, + take: 5, + }); + expect(JSON.parse(result.content)).toEqual([ + expect.objectContaining({ + id: "note_1", + title: "Check staffing", + }), + ]); + }); + + it("routes statistics through the dashboard overview path", 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 ctx = createToolContext({}, { userRole: SystemRole.CONTROLLER }); + + const result = await executeTool("get_statistics", "{}", ctx); + + expect(JSON.parse(result.content)).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 }, + ], + }); + }); + + it("marks all unread notifications as read through the notification router", async () => { + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + updateMany: vi.fn().mockResolvedValue({ count: 3 }), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const result = await executeTool("mark_notification_read", "{}", ctx); + + expect(db.notification.updateMany).toHaveBeenCalledWith({ + where: { userId: "user_1", readAt: null }, + data: { readAt: expect.any(Date) }, + }); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + success: true, + message: "All unread notifications marked as read.", + }), + ); + }); + + it("routes audit timeline reads through the real audit router detail path", async () => { + const ctx = createToolContext({ + 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", + changes: { budget: [1000, 1200] }, + createdAt: new Date("2026-03-29T12:00:00.000Z"), + user: { + id: "user_1", + name: "Assistant User", + email: "assistant@example.com", + }, + }, + ]), + }, + }, { userRole: SystemRole.CONTROLLER }); + + const result = await executeTool( + "get_audit_log_timeline", + JSON.stringify({ limit: 10 }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + "2026-03-29": [ + { + 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", + changes: { budget: [1000, 1200] }, + user: { + id: "user_1", + name: "Assistant User", + email: "assistant@example.com", + }, + }, + ], + }); + }); + + it("returns task lists and counts for the current user through the notification router", async () => { + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findMany: vi.fn().mockResolvedValue([ + { + id: "task_1", + title: "Approve vacation", + category: "APPROVAL", + taskStatus: "OPEN", + priority: "HIGH", + }, + ]), + groupBy: vi.fn().mockResolvedValue([ + { taskStatus: "OPEN", _count: 2 }, + { taskStatus: "IN_PROGRESS", _count: 1 }, + ]), + count: vi.fn().mockResolvedValue(1), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const listResult = await executeTool( + "list_tasks", + JSON.stringify({ status: "OPEN", includeAssigned: false, limit: 10 }), + ctx, + ); + const countsResult = await executeTool("get_task_counts", "{}", ctx); + + expect(db.notification.findMany).toHaveBeenCalledWith({ + where: { + userId: "user_1", + category: { in: ["TASK", "APPROVAL"] }, + taskStatus: "OPEN", + }, + orderBy: [{ priority: "desc" }, { dueDate: "asc" }, { createdAt: "desc" }], + take: 10, + }); + expect(JSON.parse(listResult.content)).toEqual([ + expect.objectContaining({ + id: "task_1", + title: "Approve vacation", + }), + ]); + expect(JSON.parse(countsResult.content)).toEqual({ + open: 2, + inProgress: 1, + done: 0, + dismissed: 0, + overdue: 1, + }); + }); + + it("reads task details through the real notification router path", async () => { + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + title: "Approve vacation", + body: "Please approve the request", + type: "TASK_CREATED", + priority: "HIGH", + category: "APPROVAL", + taskStatus: "OPEN", + taskAction: "approve_vacation:vac_1", + dueDate: new Date("2026-04-01T12:00:00.000Z"), + entityId: "vac_1", + entityType: "vacation", + completedAt: null, + completedBy: null, + createdAt: new Date("2026-03-29T08:00:00.000Z"), + userId: "user_1", + assigneeId: null, + sender: { id: "sender_1", name: "Scheduler", email: "scheduler@example.com" }, + }), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); + + const result = await executeTool( + "get_task_detail", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(db.notification.findFirst).toHaveBeenCalledWith({ + where: { + id: "task_1", + OR: [{ userId: "user_1" }, { assigneeId: "user_1" }], + category: { in: ["TASK", "APPROVAL"] }, + }, + select: expect.objectContaining({ + id: true, + title: true, + sender: { select: { id: true, name: true, email: true } }, + }), + }); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + id: "task_1", + title: "Approve vacation", + sender: expect.objectContaining({ + id: "sender_1", + name: "Scheduler", + }), + }), + ); + }); + + it("routes task action execution through the real notification router path", async () => { + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: null, + taskAction: "approve_vacation:vac_1", + taskStatus: "OPEN", + }), + update: vi.fn().mockResolvedValue({ + id: "task_1", + taskStatus: "DONE", + completedBy: "user_1", + }), + }, + vacation: { + findUnique: vi.fn().mockResolvedValue({ + id: "vac_1", + status: "PENDING", + }), + update: vi.fn().mockResolvedValue({ + id: "vac_1", + status: "APPROVED", + }), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); + + const result = await executeTool( + "execute_task_action", + JSON.stringify({ taskId: "task_1" }), + ctx, + ); + + expect(db.notification.findFirst).toHaveBeenCalledWith({ + where: { + id: "task_1", + OR: [{ userId: "user_1" }, { assigneeId: "user_1" }], + category: { in: ["TASK", "APPROVAL"] }, + }, + select: { + id: true, + userId: true, + assigneeId: true, + taskAction: true, + taskStatus: true, + }, + }); + expect(db.vacation.update).toHaveBeenCalledWith({ + where: { id: "vac_1" }, + data: { status: "APPROVED" }, + }); + expect(db.notification.update).toHaveBeenCalledWith({ + where: { id: "task_1" }, + data: expect.objectContaining({ + taskStatus: "DONE", + completedBy: "user_1", + completedAt: expect.any(Date), + }), + }); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + success: true, + message: "Vacation approved", + task: expect.objectContaining({ + id: "task_1", + taskStatus: "DONE", + }), + }), + ); + }); + + it("creates, lists, updates, and deletes reminders through the notification router", async () => { + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + notification: { + create: vi.fn().mockResolvedValue({ + id: "rem_1", + title: "Submit report", + category: "REMINDER", + }), + findMany: vi.fn().mockResolvedValue([ + { + id: "rem_1", + title: "Submit report", + category: "REMINDER", + }, + ]), + findFirst: vi.fn() + .mockResolvedValueOnce({ + id: "rem_1", + userId: "user_1", + category: "REMINDER", + }) + .mockResolvedValueOnce({ + id: "rem_1", + userId: "user_1", + category: "REMINDER", + }), + update: vi.fn().mockResolvedValue({ + id: "rem_1", + title: "Submit updated report", + category: "REMINDER", + }), + delete: vi.fn().mockResolvedValue({ id: "rem_1" }), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); + + const createResult = await executeTool( + "create_reminder", + JSON.stringify({ + title: "Submit report", + remindAt: "2026-04-01T09:00:00.000Z", + recurrence: "weekly", + }), + ctx, + ); + const listResult = await executeTool("list_reminders", JSON.stringify({ limit: 10 }), ctx); + const updateResult = await executeTool( + "update_reminder", + JSON.stringify({ + id: "rem_1", + title: "Submit updated report", + recurrence: null, + }), + ctx, + ); + const deleteResult = await executeTool( + "delete_reminder", + JSON.stringify({ id: "rem_1" }), + ctx, + ); + + expect(db.notification.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "user_1", + title: "Submit report", + category: "REMINDER", + recurrence: "weekly", + remindAt: new Date("2026-04-01T09:00:00.000Z"), + nextRemindAt: new Date("2026-04-01T09:00:00.000Z"), + }), + }); + expect(db.notification.findMany).toHaveBeenCalledWith({ + where: { userId: "user_1", category: "REMINDER" }, + orderBy: { nextRemindAt: "asc" }, + take: 10, + }); + expect(db.notification.update).toHaveBeenCalledWith({ + where: { id: "rem_1" }, + data: { + title: "Submit updated report", + recurrence: null, + }, + }); + expect(db.notification.delete).toHaveBeenCalledWith({ where: { id: "rem_1" } }); + expect(JSON.parse(createResult.content)).toEqual( + expect.objectContaining({ reminderId: "rem_1", success: true }), + ); + expect(JSON.parse(listResult.content)).toEqual([ + expect.objectContaining({ id: "rem_1", title: "Submit report" }), + ]); + expect(JSON.parse(updateResult.content)).toEqual( + expect.objectContaining({ reminderId: "rem_1", success: true }), + ); + expect(JSON.parse(deleteResult.content)).toEqual( + expect.objectContaining({ id: "rem_1", success: true }), + ); + }); + + it("reads broadcast details through the real notification router and rejects plain users", async () => { + const db = { + notificationBroadcast: { + findUnique: vi.fn().mockResolvedValue({ + id: "broadcast_1", + title: "Office update", + body: "New schedule", + sender: { id: "sender_1", name: "Manager", email: "manager@example.com" }, + }), + }, + }; + const managerCtx = createToolContext(db, { userRole: SystemRole.MANAGER }); + const userCtx = createToolContext({}, { userRole: SystemRole.USER }); + + const successResult = await executeTool( + "get_broadcast_detail", + JSON.stringify({ id: "broadcast_1" }), + managerCtx, + ); + const deniedResult = await executeTool( + "get_broadcast_detail", + JSON.stringify({ id: "broadcast_1" }), + userCtx, + ); + + expect(db.notificationBroadcast.findUnique).toHaveBeenCalledWith({ + where: { id: "broadcast_1" }, + include: { + sender: { select: { id: true, name: true, email: true } }, + }, + }); + expect(JSON.parse(successResult.content)).toEqual( + expect.objectContaining({ + id: "broadcast_1", + title: "Office update", + }), + ); + expect(JSON.parse(deniedResult.content)).toEqual( + expect.objectContaining({ + error: expect.stringContaining("Manager or Admin role required"), + }), + ); + }); + + it("lists users only for admins through the real user router", async () => { + const db = { + user: { + findMany: vi.fn().mockResolvedValue([ + { id: "user_1", name: "Alice", email: "alice@example.com" }, + { id: "user_2", name: "Bob", email: "bob@example.com" }, + ]), + }, + }; + const adminCtx = createToolContext(db, { userRole: SystemRole.ADMIN }); + const managerCtx = createToolContext({}, { userRole: SystemRole.MANAGER }); + + const adminResult = await executeTool( + "list_users", + JSON.stringify({ limit: 1 }), + adminCtx, + ); + const deniedResult = await executeTool("list_users", "{}", managerCtx); + + expect(db.user.findMany).toHaveBeenCalledWith({ + select: { + id: true, + name: true, + email: true, + systemRole: true, + createdAt: true, + lastLoginAt: true, + lastActiveAt: true, + permissionOverrides: true, + totpEnabled: true, + }, + orderBy: { name: "asc" }, + }); + expect(JSON.parse(adminResult.content)).toEqual([ + expect.objectContaining({ id: "user_1", name: "Alice" }), + ]); + expect(JSON.parse(deniedResult.content)).toEqual( + expect.objectContaining({ + error: expect.stringContaining("Admin role required"), + }), + ); + }); + + it("reads estimate details through the real estimate router and rejects plain users", async () => { + vi.mocked(getEstimateById).mockResolvedValue({ + id: "est_1", + name: "North Cluster Estimate", + status: "DRAFT", + versions: [], + } as Awaited>); + + const controllerCtx = createToolContext({}, { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_COSTS], + }); + const userCtx = createToolContext({}, { userRole: SystemRole.USER }); + + const successResult = await executeTool( + "get_estimate_detail", + JSON.stringify({ estimateId: "est_1" }), + controllerCtx, + ); + const deniedResult = await executeTool( + "get_estimate_detail", + JSON.stringify({ estimateId: "est_1" }), + userCtx, + ); + + expect(vi.mocked(getEstimateById)).toHaveBeenCalledWith(controllerCtx.db, "est_1"); + expect(JSON.parse(successResult.content)).toEqual( + expect.objectContaining({ + id: "est_1", + name: "North Cluster Estimate", + }), + ); + expect(JSON.parse(deniedResult.content)).toEqual( + expect.objectContaining({ + error: expect.stringContaining(PermissionKey.VIEW_COSTS), + }), + ); + }); + + it("lists estimate versions through the real estimate router and rejects plain users", async () => { + const db = { + estimate: { + findUnique: vi.fn().mockResolvedValue({ + id: "est_1", + name: "North Cluster Estimate", + status: "DRAFT", + latestVersionNumber: 4, + versions: [ + { + id: "ver_4", + versionNumber: 4, + label: "Rev 4", + status: "IN_REVIEW", + notes: "Latest", + lockedAt: null, + createdAt: new Date("2026-03-28T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + _count: { + assumptions: 2, + scopeItems: 3, + demandLines: 4, + resourceSnapshots: 1, + exports: 0, + }, + }, + ], + }), + }, + }; + const controllerCtx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + const userCtx = createToolContext({}, { userRole: SystemRole.USER }); + + const successResult = await executeTool( + "list_estimate_versions", + JSON.stringify({ estimateId: "est_1" }), + controllerCtx, + ); + const deniedResult = await executeTool( + "list_estimate_versions", + JSON.stringify({ estimateId: "est_1" }), + userCtx, + ); + + expect(db.estimate.findUnique).toHaveBeenCalledWith({ + where: { id: "est_1" }, + select: expect.objectContaining({ + id: true, + versions: expect.any(Object), + }), + }); + expect(JSON.parse(successResult.content)).toEqual( + expect.objectContaining({ + id: "est_1", + latestVersionNumber: 4, + versions: [ + expect.objectContaining({ + id: "ver_4", + versionNumber: 4, + }), + ], + }), + ); + expect(JSON.parse(deniedResult.content)).toEqual( + expect.objectContaining({ + error: expect.stringContaining("Controller access required"), + }), + ); + }); + + it("reads estimate version snapshots through the real estimate router, requires viewCosts, and rejects plain users", async () => { + const db = { + estimate: { + findUnique: vi.fn().mockResolvedValue({ + id: "est_1", + name: "North Cluster Estimate", + status: "APPROVED", + baseCurrency: "EUR", + versions: [ + { + id: "ver_4", + versionNumber: 4, + label: "Rev 4", + status: "APPROVED", + notes: "Latest", + lockedAt: new Date("2026-03-29T00:00:00.000Z"), + createdAt: new Date("2026-03-28T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + assumptions: [ + { id: "ass_1", category: "DELIVERY", key: "onsite", label: "Onsite support" }, + ], + scopeItems: [ + { id: "scope_1", scopeType: "EPIC", sequenceNo: 1, name: "Pipeline" }, + ], + demandLines: [ + { + id: "dl_1", + name: "Modeling", + chapter: "3D", + hours: 40, + costTotalCents: 400000, + priceTotalCents: 600000, + currency: "EUR", + }, + ], + resourceSnapshots: [ + { + id: "snap_1", + displayName: "Alice", + chapter: "3D", + currency: "EUR", + lcrCents: 10000, + ucrCents: 15000, + }, + ], + exports: [ + { + id: "exp_1", + format: "XLSX", + fileName: "estimate.xlsx", + createdAt: new Date("2026-03-29T10:00:00.000Z"), + }, + ], + }, + ], + }), + }, + }; + const controllerCtx = createToolContext(db, { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_COSTS], + }); + const controllerWithoutCostsCtx = createToolContext(db, { + userRole: SystemRole.CONTROLLER, + permissions: [], + }); + const userCtx = createToolContext({}, { userRole: SystemRole.USER }); + + const successResult = await executeTool( + "get_estimate_version_snapshot", + JSON.stringify({ estimateId: "est_1", versionId: "ver_4" }), + controllerCtx, + ); + const missingPermissionResult = await executeTool( + "get_estimate_version_snapshot", + JSON.stringify({ estimateId: "est_1", versionId: "ver_4" }), + controllerWithoutCostsCtx, + ); + const deniedResult = await executeTool( + "get_estimate_version_snapshot", + JSON.stringify({ estimateId: "est_1", versionId: "ver_4" }), + userCtx, + ); + + expect(db.estimate.findUnique).toHaveBeenCalledWith({ + where: { id: "est_1" }, + select: expect.objectContaining({ + id: true, + versions: expect.objectContaining({ + where: { id: "ver_4" }, + }), + }), + }); + expect(JSON.parse(successResult.content)).toEqual( + expect.objectContaining({ + estimate: expect.objectContaining({ + id: "est_1", + baseCurrency: "EUR", + }), + version: expect.objectContaining({ + id: "ver_4", + versionNumber: 4, + }), + totals: expect.objectContaining({ + hours: 40, + costTotalCents: 400000, + priceTotalCents: 600000, + }), + chapterBreakdown: [ + expect.objectContaining({ + chapter: "3D", + lineCount: 1, + }), + ], + }), + ); + expect(JSON.parse(missingPermissionResult.content)).toEqual( + expect.objectContaining({ + error: expect.stringContaining(PermissionKey.VIEW_COSTS), + }), + ); + expect(JSON.parse(deniedResult.content)).toEqual( + expect.objectContaining({ + error: expect.stringContaining(PermissionKey.VIEW_COSTS), + }), + ); + }); + + it("reads countries through the real country router identifier path", async () => { + const db = { + country: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "country_de", + code: "DE", + name: "Germany", + dailyWorkingHours: 8, + scheduleRules: null, + isActive: true, + metroCities: [{ id: "city_muc", name: "Munich" }], + _count: { resources: 12 }, + }), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const result = await executeTool( + "get_country", + JSON.stringify({ identifier: "Germany" }), + ctx, + ); + + expect(db.country.findUnique).toHaveBeenCalledWith({ + where: { id: "Germany" }, + include: { + metroCities: { orderBy: { name: "asc" } }, + _count: { select: { resources: true } }, + }, + }); + expect(db.country.findFirst).toHaveBeenNthCalledWith(1, { + where: { code: { equals: "GERMANY", mode: "insensitive" } }, + include: { + metroCities: { orderBy: { name: "asc" } }, + _count: { select: { resources: true } }, + }, + }); + expect(db.country.findFirst).toHaveBeenNthCalledWith(2, { + where: { name: { equals: "Germany", mode: "insensitive" } }, + include: { + metroCities: { orderBy: { name: "asc" } }, + _count: { select: { resources: true } }, + }, + }); + expect(JSON.parse(result.content)).toEqual({ + id: "country_de", + code: "DE", + name: "Germany", + dailyWorkingHours: 8, + scheduleRules: null, + isActive: true, + resourceCount: 12, + metroCities: [{ id: "city_muc", name: "Munich" }], + cities: ["Munich"], + }); + }); + + it("creates and updates countries through the real country router path", async () => { + const db = { + country: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: "country_de", code: "DE", name: "Germany" }), + create: vi.fn().mockResolvedValue({ + id: "country_de", + code: "DE", + name: "Germany", + dailyWorkingHours: 8, + scheduleRules: null, + isActive: true, + metroCities: [], + _count: { resources: 0 }, + }), + update: vi.fn().mockResolvedValue({ + id: "country_de", + code: "DE", + name: "Germany Updated", + dailyWorkingHours: 7.5, + scheduleRules: { shortFriday: true }, + isActive: false, + metroCities: [], + _count: { resources: 0 }, + }), + }, + auditLog: { + create: vi.fn().mockResolvedValue({ id: "audit_1" }), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); + + const createResult = await executeTool( + "create_country", + JSON.stringify({ code: "DE", name: "Germany", dailyWorkingHours: 8 }), + ctx, + ); + const updateResult = await executeTool( + "update_country", + JSON.stringify({ + id: "country_de", + data: { + name: "Germany Updated", + dailyWorkingHours: 7.5, + scheduleRules: null, + isActive: false, + }, + }), + ctx, + ); + + expect(db.country.create).toHaveBeenCalledWith({ + data: { + code: "DE", + name: "Germany", + dailyWorkingHours: 8, + }, + include: { metroCities: true }, + }); + expect(db.country.update).toHaveBeenCalledWith({ + where: { id: "country_de" }, + data: { + name: "Germany Updated", + dailyWorkingHours: 7.5, + scheduleRules: expect.anything(), + isActive: false, + }, + include: { metroCities: true }, + }); + expect(JSON.parse(createResult.content)).toEqual( + expect.objectContaining({ + success: true, + message: "Created country: Germany", + country: expect.objectContaining({ + id: "country_de", + code: "DE", + }), + }), + ); + expect(createResult.action).toEqual({ + type: "invalidate", + scope: ["country", "resource", "holidayCalendar", "vacation"], + }); + expect(JSON.parse(updateResult.content)).toEqual( + expect.objectContaining({ + success: true, + message: "Updated country: Germany Updated", + country: expect.objectContaining({ + id: "country_de", + isActive: false, + }), + }), + ); + }); + + it("creates, updates, and deletes metro cities through the real country router path", async () => { + const metroCityFindUnique = vi.fn() + .mockResolvedValueOnce({ id: "city_muc", name: "Munich", countryId: "country_de" }) + .mockResolvedValueOnce({ id: "city_muc", name: "Munich", _count: { resources: 0 } }); + const db = { + country: { + findUnique: vi.fn().mockResolvedValue({ id: "country_de", name: "Germany" }), + }, + metroCity: { + create: vi.fn().mockResolvedValue({ id: "city_muc", name: "Munich", countryId: "country_de" }), + findUnique: metroCityFindUnique, + update: vi.fn().mockResolvedValue({ id: "city_muc", name: "Muenchen", countryId: "country_de" }), + delete: vi.fn().mockResolvedValue({ id: "city_muc" }), + }, + auditLog: { + create: vi.fn().mockResolvedValue({ id: "audit_1" }), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); + + const createResult = await executeTool( + "create_metro_city", + JSON.stringify({ countryId: "country_de", name: "Munich" }), + ctx, + ); + const updateResult = await executeTool( + "update_metro_city", + JSON.stringify({ id: "city_muc", data: { name: "Muenchen" } }), + ctx, + ); + const deleteResult = await executeTool( + "delete_metro_city", + JSON.stringify({ id: "city_muc" }), + ctx, + ); + + expect(db.metroCity.create).toHaveBeenCalledWith({ + data: { name: "Munich", countryId: "country_de" }, + }); + expect(db.metroCity.update).toHaveBeenCalledWith({ + where: { id: "city_muc" }, + data: { name: "Muenchen" }, + }); + expect(db.metroCity.delete).toHaveBeenCalledWith({ where: { id: "city_muc" } }); + expect(JSON.parse(createResult.content)).toEqual( + expect.objectContaining({ + success: true, + message: "Created metro city: Munich", + metroCity: expect.objectContaining({ id: "city_muc", name: "Munich" }), + }), + ); + expect(JSON.parse(updateResult.content)).toEqual( + expect.objectContaining({ + success: true, + message: "Updated metro city: Muenchen", + metroCity: expect.objectContaining({ id: "city_muc", name: "Muenchen" }), + }), + ); + expect(JSON.parse(deleteResult.content)).toEqual( + expect.objectContaining({ + success: true, + message: "Deleted metro city: Munich", + }), + ); + }); + + it("routes role, client, and org unit mutations through their backing routers", async () => { + const db = { + role: { + findUnique: vi.fn().mockImplementation(async (args?: any) => { + if (args?.where?.name === "Pipeline TD") return null; + if (args?.where?.name === "Pipeline Lead") return null; + if (args?.where?.id === "role_pipeline") { + return { + id: "role_pipeline", + name: "Pipeline TD", + description: "Pipeline craft", + color: "#123456", + isActive: true, + _count: { resourceRoles: 0 }, + resourceRoles: [], + }; + } + return null; + }), + create: vi.fn().mockResolvedValue({ + id: "role_pipeline", + name: "Pipeline TD", + description: "Pipeline craft", + color: "#123456", + isActive: true, + _count: { resourceRoles: 0 }, + }), + update: vi.fn().mockResolvedValue({ + id: "role_pipeline", + name: "Pipeline Lead", + description: "Lead pipeline craft", + color: "#654321", + isActive: false, + _count: { resourceRoles: 0 }, + }), + delete: vi.fn().mockResolvedValue({ id: "role_pipeline" }), + }, + client: { + findUnique: vi.fn().mockImplementation(async (args?: any) => { + if (args?.where?.id === "client_1") { + return { + id: "client_1", + name: "Acme", + code: "ACME", + parentId: null, + isActive: true, + sortOrder: 0, + tags: [], + }; + } + if (args?.where?.code === "ACME-NEW") return null; + return null; + }), + create: vi.fn().mockResolvedValue({ + id: "client_1", + name: "Acme", + code: "ACME", + parentId: null, + isActive: true, + sortOrder: 2, + tags: ["key"], + }), + update: vi.fn().mockResolvedValue({ + id: "client_1", + name: "Acme Updated", + code: "ACME-NEW", + parentId: null, + isActive: false, + sortOrder: 3, + tags: ["vip"], + }), + }, + orgUnit: { + findUnique: vi.fn().mockImplementation(async (args?: any) => { + if (args?.where?.id === "ou_1") { + return { + id: "ou_1", + name: "Operations", + shortName: "OPS", + level: 5, + parentId: null, + sortOrder: 1, + isActive: true, + }; + } + return null; + }), + create: vi.fn().mockResolvedValue({ + id: "ou_1", + name: "Operations", + shortName: "OPS", + level: 5, + parentId: null, + sortOrder: 1, + isActive: true, + }), + update: vi.fn().mockResolvedValue({ + id: "ou_1", + name: "Operations EU", + shortName: null, + level: 5, + parentId: null, + sortOrder: 4, + isActive: false, + }), + }, + auditLog: { + create: vi.fn().mockResolvedValue({ id: "audit_1" }), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); + + const createRoleResult = await executeTool( + "create_role", + JSON.stringify({ name: "Pipeline TD", description: "Pipeline craft", color: "#123456" }), + ctx, + ); + const updateRoleResult = await executeTool( + "update_role", + JSON.stringify({ id: "role_pipeline", name: "Pipeline Lead", description: "Lead pipeline craft", color: "#654321", isActive: false }), + ctx, + ); + const deleteRoleResult = await executeTool( + "delete_role", + JSON.stringify({ id: "role_pipeline" }), + ctx, + ); + const createClientResult = await executeTool( + "create_client", + JSON.stringify({ name: "Acme", code: "ACME", sortOrder: 2, tags: ["key"] }), + ctx, + ); + const updateClientResult = await executeTool( + "update_client", + JSON.stringify({ id: "client_1", name: "Acme Updated", code: "ACME-NEW", sortOrder: 3, isActive: false, tags: ["vip"] }), + ctx, + ); + const createOrgUnitResult = await executeTool( + "create_org_unit", + JSON.stringify({ name: "Operations", shortName: "OPS", level: 5, sortOrder: 1 }), + ctx, + ); + const updateOrgUnitResult = await executeTool( + "update_org_unit", + JSON.stringify({ id: "ou_1", name: "Operations EU", shortName: null, sortOrder: 4, isActive: false }), + ctx, + ); + + expect(JSON.parse(createRoleResult.content)).toEqual(expect.objectContaining({ + success: true, + roleId: "role_pipeline", + message: "Created role: Pipeline TD", + })); + expect(JSON.parse(updateRoleResult.content)).toEqual(expect.objectContaining({ + success: true, + roleId: "role_pipeline", + message: "Updated role: Pipeline Lead", + })); + expect(JSON.parse(deleteRoleResult.content)).toEqual(expect.objectContaining({ + success: true, + message: "Deleted role: Pipeline TD", + })); + expect(JSON.parse(createClientResult.content)).toEqual(expect.objectContaining({ + success: true, + clientId: "client_1", + message: "Created client: Acme", + })); + expect(JSON.parse(updateClientResult.content)).toEqual(expect.objectContaining({ + success: true, + clientId: "client_1", + message: "Updated client: Acme Updated", + })); + expect(JSON.parse(createOrgUnitResult.content)).toEqual(expect.objectContaining({ + success: true, + orgUnitId: "ou_1", + message: "Created org unit: Operations", + })); + expect(JSON.parse(updateOrgUnitResult.content)).toEqual(expect.objectContaining({ + success: true, + orgUnitId: "ou_1", + message: "Updated org unit: Operations EU", + })); + + expect(db.role.create).toHaveBeenCalled(); + expect(db.role.update).toHaveBeenCalled(); + expect(db.role.delete).toHaveBeenCalledWith({ where: { id: "role_pipeline" } }); + expect(db.client.create).toHaveBeenCalled(); + expect(db.client.update).toHaveBeenCalled(); + expect(db.orgUnit.create).toHaveBeenCalled(); + expect(db.orgUnit.update).toHaveBeenCalled(); + expect(db.auditLog.create).toHaveBeenCalled(); + }); + + it("routes vacation balance, requests, approvals, rejections, and entitlements through the real routers", async () => { + const vacationFindMany = vi.fn().mockImplementation(async (args?: any) => { + if (args?.where?.type === VacationType.PUBLIC_HOLIDAY) { + return []; + } + if (args?.where?.type?.in) { + return [ + { + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-06T00:00:00.000Z"), + status: "APPROVED", + isHalfDay: false, + }, + { + startDate: new Date("2026-02-03T00:00:00.000Z"), + endDate: new Date("2026-02-03T00:00:00.000Z"), + status: "PENDING", + isHalfDay: true, + }, + ]; + } + if (args?.where?.type === VacationType.SICK) { + return [ + { + startDate: new Date("2026-03-10T00:00:00.000Z"), + endDate: new Date("2026-03-10T00:00:00.000Z"), + isHalfDay: false, + }, + ]; + } + if (args?.where?.resource?.chapter) { + return []; + } + return []; + }); + const vacationFindUnique = vi.fn().mockImplementation(async (args?: any) => { + const id = args?.where?.id; + if (id === "vac_cancelled") { + return { + id, + resourceId: "res_1", + requestedById: "user_1", + status: "CANCELLED", + startDate: new Date("2026-07-01T00:00:00.000Z"), + endDate: new Date("2026-07-02T00:00:00.000Z"), + type: VacationType.ANNUAL, + isHalfDay: false, + resource: { + id: "res_1", + displayName: "Alice Example", + eid: "EMP-001", + chapter: "Delivery", + }, + requestedBy: { id: "user_1", name: "Assistant User", email: "assistant@example.com" }, + approvedBy: null, + }; + } + if (id === "vac_pending") { + return { + id, + resourceId: "res_1", + requestedById: "user_1", + status: "PENDING", + startDate: new Date("2026-08-03T00:00:00.000Z"), + endDate: new Date("2026-08-04T00:00:00.000Z"), + type: VacationType.ANNUAL, + isHalfDay: false, + resource: { + id: "res_1", + displayName: "Alice Example", + eid: "EMP-001", + chapter: "Delivery", + }, + requestedBy: { id: "user_1", name: "Assistant User", email: "assistant@example.com" }, + approvedBy: null, + }; + } + return null; + }); + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "MANAGER" }), + }, + resource: { + findUnique: vi.fn().mockImplementation(async (args?: any) => { + if (args?.where?.id === "res_1") { + return { + id: "res_1", + eid: "EMP-001", + displayName: "Alice Example", + userId: "user_1", + chapter: "Delivery", + federalState: "BY", + countryId: "country_de", + metroCityId: null, + country: { code: "DE", name: "Germany" }, + metroCity: null, + }; + } + return null; + }), + count: vi.fn().mockResolvedValue(1), + }, + holidayCalendar: { + findMany: vi.fn().mockResolvedValue([]), + }, + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ vacationDefaultDays: 30 }), + }, + vacationEntitlement: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockImplementation(async (args?: any) => ({ + id: `ent_${args?.data?.year ?? "unknown"}`, + resourceId: args?.data?.resourceId ?? "res_1", + year: args?.data?.year ?? 2026, + entitledDays: args?.data?.entitledDays ?? 30, + carryoverDays: args?.data?.carryoverDays ?? 0, + usedDays: args?.data?.usedDays ?? 0, + pendingDays: args?.data?.pendingDays ?? 0, + })), + update: vi.fn().mockImplementation(async (args?: any) => ({ + id: args?.where?.id ?? "ent_2026", + resourceId: "res_1", + year: 2026, + entitledDays: 30, + carryoverDays: 0, + usedDays: args?.data?.usedDays ?? 0, + pendingDays: args?.data?.pendingDays ?? 0, + })), + }, + vacation: { + findUnique: vacationFindUnique, + findMany: vacationFindMany, + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ + id: "vac_created", + resourceId: "res_1", + status: "APPROVED", + type: VacationType.ANNUAL, + startDate: new Date("2026-07-01T00:00:00.000Z"), + endDate: new Date("2026-07-02T00:00:00.000Z"), + isHalfDay: false, + resource: { + id: "res_1", + displayName: "Alice Example", + eid: "EMP-001", + chapter: "Delivery", + }, + requestedBy: { id: "user_1", name: "Assistant User", email: "assistant@example.com" }, + effectiveDays: 2, + }), + update: vi.fn().mockImplementation(async (args?: any) => ({ + id: args?.where?.id ?? "vac_unknown", + resourceId: "res_1", + startDate: args?.where?.id === "vac_pending" + ? new Date("2026-08-03T00:00:00.000Z") + : new Date("2026-07-01T00:00:00.000Z"), + endDate: args?.where?.id === "vac_pending" + ? new Date("2026-08-04T00:00:00.000Z") + : new Date("2026-07-02T00:00:00.000Z"), + type: VacationType.ANNUAL, + isHalfDay: false, + status: args?.data?.status ?? "APPROVED", + rejectionReason: args?.data?.rejectionReason ?? null, + approvedById: args?.data?.approvedById ?? null, + approvedAt: args?.data?.approvedAt ?? null, + })), + }, + notification: { + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + create: vi.fn().mockResolvedValue({ id: "note_1", userId: "user_1" }), + }, + auditLog: { + create: vi.fn().mockResolvedValue({ id: "audit_1" }), + }, + webhook: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.MANAGER }); + + const balanceResult = await executeTool( + "get_vacation_balance", + JSON.stringify({ resourceId: "res_1", year: 2026 }), + ctx, + ); + const createResult = await executeTool( + "create_vacation", + JSON.stringify({ + resourceId: "res_1", + type: "ANNUAL", + startDate: "2026-07-01", + endDate: "2026-07-02", + }), + ctx, + ); + const approveResult = await executeTool( + "approve_vacation", + JSON.stringify({ vacationId: "vac_cancelled" }), + ctx, + ); + const rejectResult = await executeTool( + "reject_vacation", + JSON.stringify({ vacationId: "vac_pending", reason: "Capacity freeze" }), + ctx, + ); + const setEntitlementResult = await executeTool( + "set_entitlement", + JSON.stringify({ resourceId: "res_1", year: 2027, entitledDays: 32 }), + ctx, + ); + + expect(JSON.parse(balanceResult.content)).toEqual({ + resource: "Alice Example", + eid: "EMP-001", + year: 2026, + entitlement: 30, + carryOver: 0, + taken: 1, + pending: 0.5, + remaining: 28.5, + sickDays: 1, + }); + expect(JSON.parse(createResult.content)).toEqual(expect.objectContaining({ + success: true, + vacationId: "vac_created", + message: "Created ANNUAL for Alice Example: 2026-07-01 to 2026-07-02 (status: APPROVED, deducted 2 day(s))", + })); + expect(JSON.parse(approveResult.content)).toEqual(expect.objectContaining({ + success: true, + message: "Approved vacation for Alice Example", + })); + expect(JSON.parse(rejectResult.content)).toEqual(expect.objectContaining({ + success: true, + message: "Rejected vacation for Alice Example: Capacity freeze", + })); + expect(JSON.parse(setEntitlementResult.content)).toEqual(expect.objectContaining({ + success: true, + message: "Set entitlement for Alice Example (2027): 32 days", + })); + + expect(db.vacation.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + resourceId: "res_1", + type: VacationType.ANNUAL, + status: "APPROVED", + requestedById: "user_1", + approvedById: "user_1", + }), + })); + expect(db.vacation.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: "vac_cancelled" }, + data: expect.objectContaining({ status: "APPROVED" }), + })); + expect(db.vacation.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: "vac_pending" }, + data: expect.objectContaining({ status: "REJECTED", rejectionReason: "Capacity freeze" }), + })); + expect(db.vacationEntitlement.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + resourceId: "res_1", + year: 2027, + entitledDays: 32, + }), + })); + }); + + it("routes entitlement summary through the entitlement year summary workflow", async () => { + const db = { + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ vacationDefaultDays: 28 }), + }, + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + displayName: "Alice Example", + eid: "EMP-001", + lcrCents: 0, + chapter: "Delivery", + }, + { + id: "res_2", + displayName: "Bob Example", + eid: "EMP-002", + lcrCents: 0, + chapter: "CGI", + }, + ]), + }, + vacationEntitlement: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockImplementation(async (args?: any) => ({ + id: `ent_${args?.data?.resourceId ?? "unknown"}_${args?.data?.year ?? "unknown"}`, + resourceId: args?.data?.resourceId ?? "res_1", + year: args?.data?.year ?? 2026, + entitledDays: args?.data?.entitledDays ?? 28, + carryoverDays: args?.data?.carryoverDays ?? 0, + usedDays: args?.data?.usedDays ?? 0, + pendingDays: args?.data?.pendingDays ?? 0, + })), + update: vi.fn().mockImplementation(async (args?: any) => ({ + id: args?.where?.id ?? "ent_unknown", + resourceId: args?.where?.id?.includes("res_2") ? "res_2" : "res_1", + year: 2026, + entitledDays: 28, + carryoverDays: 0, + usedDays: args?.data?.usedDays ?? 0, + pendingDays: args?.data?.pendingDays ?? 0, + })), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.MANAGER }); + + const result = await executeTool( + "get_entitlement_summary", + JSON.stringify({ year: 2026, resourceName: "alice" }), + ctx, + ); + + expect(db.resource.findMany).toHaveBeenCalledWith({ + where: { isActive: true }, + select: { id: true, displayName: true, eid: true, lcrCents: true, chapter: true }, + orderBy: [{ chapter: "asc" }, { displayName: "asc" }], + }); + expect(JSON.parse(result.content)).toEqual([ + { + resource: "Alice Example", + eid: "EMP-001", + chapter: "Delivery", + year: 2026, + entitled: 28, + carryover: 0, + used: 0, + pending: 0, + remaining: 28, + }, + ]); + }); + + it("allows self-service vacation cancellation through the real vacation router path", async () => { + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }), + }, + vacation: { + findUnique: vi.fn().mockResolvedValue({ + id: "vac_self", + resourceId: "res_1", + requestedById: "user_1", + status: "APPROVED", + startDate: new Date("2026-09-07T00:00:00.000Z"), + endDate: new Date("2026-09-09T00:00:00.000Z"), + resource: { + id: "res_1", + displayName: "Alice Example", + eid: "EMP-001", + chapter: "Delivery", + }, + requestedBy: { id: "user_1", name: "Assistant User", email: "assistant@example.com" }, + approvedBy: null, + }), + update: vi.fn().mockResolvedValue({ + id: "vac_self", + resourceId: "res_1", + status: "CANCELLED", + }), + }, + resource: { + findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }), + }, + auditLog: { + create: vi.fn().mockResolvedValue({ id: "audit_1" }), + }, + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ anonymizationEnabled: false }), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.USER, permissions: [] }); + + const result = await executeTool( + "cancel_vacation", + JSON.stringify({ vacationId: "vac_self" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + success: true, + message: "Cancelled vacation for Alice Example", + })); + expect(db.vacation.update).toHaveBeenCalledWith({ + where: { id: "vac_self" }, + data: { status: "CANCELLED" }, + }); + }); + + it("routes comment listing, creation, and resolution through the real comment router path", async () => { + const commentFindUnique = vi.fn().mockResolvedValue({ + id: "comment_1", + authorId: "user_1", + }); + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }), + }, + comment: { + findMany: vi.fn().mockResolvedValue([ + { + id: "comment_1", + body: "Initial note", + resolved: false, + createdAt: new Date("2026-03-29T09:00:00.000Z"), + author: { id: "user_1", name: "Assistant User", email: "assistant@example.com", image: null }, + replies: [ + { + id: "comment_reply_1", + body: "Reply", + resolved: false, + createdAt: new Date("2026-03-29T10:00:00.000Z"), + author: { id: "user_2", name: "Reviewer", email: "reviewer@example.com", image: null }, + }, + ], + }, + ]), + findUnique: commentFindUnique, + create: vi.fn().mockResolvedValue({ + id: "comment_created", + body: "Please review this estimate.", + resolved: false, + createdAt: new Date("2026-03-29T11:00:00.000Z"), + author: { id: "user_1", name: "Assistant User", email: "assistant@example.com", image: null }, + }), + update: vi.fn().mockResolvedValue({ + id: "comment_1", + body: "Initial note", + resolved: true, + createdAt: new Date("2026-03-29T09:00:00.000Z"), + author: { id: "user_1", name: "Assistant User", email: "assistant@example.com", image: null }, + }), + }, + notification: { + create: vi.fn(), + }, + auditLog: { + create: vi.fn().mockResolvedValue({ id: "audit_1" }), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const listResult = await executeTool( + "list_comments", + JSON.stringify({ entityType: "estimate", entityId: "est_1" }), + ctx, + ); + const createResult = await executeTool( + "create_comment", + JSON.stringify({ + entityType: "estimate", + entityId: "est_1", + body: "Please review this estimate.", + }), + ctx, + ); + const resolveResult = await executeTool( + "resolve_comment", + JSON.stringify({ commentId: "comment_1", resolved: true }), + ctx, + ); + + expect(db.comment.findMany).toHaveBeenCalledWith({ + where: { + entityType: "estimate", + entityId: "est_1", + parentId: null, + }, + include: { + author: { select: { id: true, name: true, email: true, image: true } }, + replies: { + include: { + author: { select: { id: true, name: true, email: true, image: true } }, + }, + orderBy: { createdAt: "asc" }, + }, + }, + orderBy: { createdAt: "asc" }, + }); + expect(db.user.findUnique).toHaveBeenCalledWith({ + where: { email: "assistant@example.com" }, + select: { id: true }, + }); + expect(db.comment.create).toHaveBeenCalledWith({ + data: { + entityType: "estimate", + entityId: "est_1", + authorId: "user_1", + body: "Please review this estimate.", + mentions: [], + }, + include: { + author: { select: { id: true, name: true, email: true, image: true } }, + }, + }); + expect(commentFindUnique).toHaveBeenCalledWith({ + where: { id: "comment_1" }, + select: { id: true, authorId: true }, + }); + expect(db.comment.update).toHaveBeenCalledWith({ + where: { id: "comment_1" }, + data: { resolved: true }, + include: { + author: { select: { id: true, name: true, email: true, image: true } }, + }, + }); + + expect(JSON.parse(listResult.content)).toEqual([ + { + id: "comment_1", + author: "Assistant User", + body: "Initial note", + resolved: false, + createdAt: "2026-03-29T09:00:00.000Z", + replyCount: 1, + replies: [ + { + id: "comment_reply_1", + author: "Reviewer", + body: "Reply", + resolved: false, + createdAt: "2026-03-29T10:00:00.000Z", + }, + ], + }, + ]); + expect(JSON.parse(createResult.content)).toEqual({ + id: "comment_created", + author: "Assistant User", + body: "Please review this estimate.", + createdAt: "2026-03-29T11:00:00.000Z", + }); + expect(createResult.action).toEqual({ + type: "invalidate", + scope: ["comment"], + }); + expect(JSON.parse(resolveResult.content)).toEqual({ + id: "comment_1", + resolved: true, + author: "Assistant User", + body: "Initial note", + }); + expect(resolveResult.action).toEqual({ + type: "invalidate", + scope: ["comment"], + }); + }); + + it("routes taxonomy and rule read tools through their backing routers", async () => { + const db = { + managementLevelGroup: { + findMany: vi.fn().mockResolvedValue([ + { + id: "mlg_exec", + name: "Executive", + targetPercentage: 75, + levels: [{ id: "ml_partner", name: "Partner" }], + }, + ]), + }, + utilizationCategory: { + findMany: vi.fn().mockResolvedValue([ + { + id: "util_billable", + code: "BILLABLE", + name: "Billable", + description: "Client work", + isActive: true, + sortOrder: 1, + }, + ]), + findUnique: vi.fn().mockResolvedValue({ + id: "util_billable", + code: "BILLABLE", + name: "Billable", + description: "Client work", + isActive: true, + sortOrder: 1, + _count: { projects: 3 }, + }), + }, + calculationRule: { + findMany: vi.fn().mockResolvedValue([ + { + id: "calc_1", + name: "Rush Fee", + description: "Adds cost for rush work", + isActive: true, + triggerType: "RUSH", + orderType: null, + costEffect: "INCREASE", + costReductionPercent: null, + chargeabilityEffect: "NONE", + priority: 90, + project: { id: "proj_1", name: "Falcon", shortCode: "FAL" }, + }, + ]), + }, + effortRuleSet: { + findMany: vi.fn().mockResolvedValue([ + { + id: "ers_default", + name: "Default Effort", + isDefault: true, + rules: [ + { + id: "eff_1", + description: "Animation per shot", + scopeType: "SHOT", + discipline: "Animation", + chapter: "3D", + unitMode: "per_item", + hoursPerUnit: 12, + sortOrder: 0, + }, + ], + }, + ]), + }, + experienceMultiplierSet: { + findMany: vi.fn().mockResolvedValue([ + { + id: "ems_default", + name: "Default Multipliers", + isDefault: true, + rules: [ + { + id: "exp_1", + description: "Senior DE uplift", + chapter: "3D", + location: "DE", + level: "Senior", + costMultiplier: 1.2, + billMultiplier: 1.15, + shoringRatio: 0.4, + additionalEffortRatio: 0.1, + sortOrder: 0, + }, + ], + }, + ]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const managementLevelsResult = await executeTool("list_management_levels", "{}", ctx); + const utilizationCategoriesResult = await executeTool("list_utilization_categories", "{}", ctx); + const calculationRulesResult = await executeTool("list_calculation_rules", "{}", ctx); + const effortRulesResult = await executeTool("list_effort_rules", "{}", ctx); + const experienceMultipliersResult = await executeTool("list_experience_multipliers", "{}", ctx); + + expect(db.managementLevelGroup.findMany).toHaveBeenCalledWith({ + include: { levels: { orderBy: { name: "asc" } } }, + orderBy: { sortOrder: "asc" }, + }); + expect(db.utilizationCategory.findMany).toHaveBeenCalledWith({ + where: {}, + orderBy: { sortOrder: "asc" }, + }); + expect(db.utilizationCategory.findUnique).toHaveBeenCalledWith({ + where: { id: "util_billable" }, + include: { _count: { select: { projects: true } } }, + }); + expect(db.calculationRule.findMany).toHaveBeenCalledWith({ + orderBy: [{ priority: "desc" }, { name: "asc" }], + include: { + project: { + select: { + id: true, + shortCode: true, + name: true, + status: true, + endDate: true, + }, + }, + }, + }); + expect(db.effortRuleSet.findMany).toHaveBeenCalledWith({ + include: { + rules: { orderBy: { sortOrder: "asc" } }, + }, + orderBy: [{ isDefault: "desc" }, { name: "asc" }], + }); + expect(db.experienceMultiplierSet.findMany).toHaveBeenCalledWith({ + include: { + rules: { orderBy: { sortOrder: "asc" } }, + }, + orderBy: [{ isDefault: "desc" }, { name: "asc" }], + }); + + expect(JSON.parse(managementLevelsResult.content)).toEqual([ + { + id: "mlg_exec", + name: "Executive", + target: "75%", + levels: [{ id: "ml_partner", name: "Partner" }], + }, + ]); + expect(JSON.parse(utilizationCategoriesResult.content)).toEqual([ + { + id: "util_billable", + code: "BILLABLE", + name: "Billable", + description: "Client work", + projectCount: 3, + }, + ]); + expect(JSON.parse(calculationRulesResult.content)).toEqual([ + expect.objectContaining({ + id: "calc_1", + name: "Rush Fee", + project: expect.objectContaining({ + id: "proj_1", + shortCode: "FAL", + }), + }), + ]); + expect(JSON.parse(effortRulesResult.content)).toEqual([ + { + id: "eff_1", + description: "Animation per shot", + scopeType: "SHOT", + discipline: "Animation", + chapter: "3D", + unitMode: "per_item", + hoursPerUnit: 12, + sortOrder: 0, + ruleSet: { name: "Default Effort", isDefault: true }, + }, + ]); + expect(JSON.parse(experienceMultipliersResult.content)).toEqual([ + { + id: "exp_1", + description: "Senior DE uplift", + chapter: "3D", + location: "DE", + level: "Senior", + costMultiplier: 1.2, + billMultiplier: 1.15, + shoringRatio: 0.4, + additionalEffortRatio: 0.1, + sortOrder: 0, + multiplierSet: { name: "Default Multipliers", isDefault: true }, + }, + ]); + }); + + it("lists and reads holiday calendars through the real holiday router paths", async () => { + const db = { + holidayCalendar: { + findMany: vi.fn().mockResolvedValue([ + { + id: "cal_de", + name: "Germany National", + scopeType: "COUNTRY", + stateCode: null, + isActive: true, + priority: 0, + country: { id: "country_de", code: "DE", name: "Germany" }, + metroCity: null, + _count: { entries: 1 }, + entries: [ + { + id: "entry_1", + date: new Date("2026-01-01T00:00:00.000Z"), + name: "New Year", + isRecurringAnnual: true, + source: "seed", + }, + ], + }, + ]), + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn() + .mockResolvedValueOnce({ + id: "cal_de", + name: "Germany National", + scopeType: "COUNTRY", + stateCode: null, + isActive: true, + priority: 0, + country: { id: "country_de", code: "DE", name: "Germany" }, + metroCity: null, + entries: [ + { + id: "entry_1", + date: new Date("2026-01-01T00:00:00.000Z"), + name: "New Year", + isRecurringAnnual: true, + source: "seed", + }, + ], + }), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.MANAGER }); + + const listResult = await executeTool( + "list_holiday_calendars", + JSON.stringify({ countryCode: "de", scopeType: "COUNTRY" }), + ctx, + ); + const getResult = await executeTool( + "get_holiday_calendar", + JSON.stringify({ identifier: "Germany National" }), + ctx, + ); + + expect(db.holidayCalendar.findMany).toHaveBeenCalledWith({ + where: { + isActive: true, + country: { code: { equals: "DE", mode: "insensitive" } }, + scopeType: "COUNTRY", + }, + 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" }, + ], + }); + expect(db.holidayCalendar.findUnique).toHaveBeenCalledWith({ + where: { id: "Germany National" }, + include: { + country: { select: { id: true, code: true, name: true } }, + metroCity: { select: { id: true, name: true } }, + entries: { orderBy: [{ date: "asc" }, { name: "asc" }] }, + }, + }); + expect(db.holidayCalendar.findFirst).toHaveBeenCalledWith({ + where: { name: { equals: "Germany National", mode: "insensitive" } }, + include: { + country: { select: { id: true, code: true, name: true } }, + metroCity: { select: { id: true, name: true } }, + entries: { orderBy: [{ date: "asc" }, { name: "asc" }] }, + }, + }); + expect(JSON.parse(listResult.content)).toEqual({ + count: 1, + calendars: [ + expect.objectContaining({ + id: "cal_de", + name: "Germany National", + entryCount: 1, + }), + ], + }); + expect(JSON.parse(getResult.content)).toEqual( + expect.objectContaining({ + id: "cal_de", + name: "Germany National", + }), + ); + }); + + it("previews resolved holiday calendars through the real holiday router path", async () => { + const holidayCallerDb = { + country: { + findUnique: vi.fn().mockResolvedValue({ id: "country_de", code: "DE", name: "Germany" }), + }, + metroCity: { + findUnique: vi.fn().mockResolvedValue({ id: "city_muc", name: "Munich", countryId: "country_de" }), + }, + holidayCalendar: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + const ctx = createToolContext(holidayCallerDb, { userRole: SystemRole.USER }); + + const result = await executeTool( + "preview_resolved_holiday_calendar", + JSON.stringify({ + countryId: "country_de", + stateCode: "BY", + metroCityId: "city_muc", + year: 2026, + }), + ctx, + ); + + expect(holidayCallerDb.country.findUnique).toHaveBeenCalledWith({ + where: { id: "country_de" }, + select: { id: true, code: true, name: true }, + }); + expect(holidayCallerDb.metroCity.findUnique).toHaveBeenCalledWith({ + where: { id: "city_muc" }, + select: { id: true, name: true, countryId: true }, + }); + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + count: expect.any(Number), + locationContext: { + countryId: "country_de", + countryCode: "DE", + stateCode: "BY", + metroCityId: "city_muc", + metroCity: "Munich", + year: 2026, + }, + summary: expect.objectContaining({ + byCalendar: expect.any(Array), + byScope: expect.any(Array), + bySourceType: expect.arrayContaining([ + expect.objectContaining({ sourceType: "BUILTIN" }), + ]), + }), + holidays: expect.arrayContaining([ + expect.objectContaining({ + date: "2026-01-01", + name: "Neujahr", + scope: "COUNTRY", + sourceType: "BUILTIN", + }), + expect.objectContaining({ + date: "2026-01-06", + name: "Heilige Drei Könige", + scope: "STATE", + sourceType: "BUILTIN", + }), + ]), + })); + }); + + it("routes role, client, and org unit reads through their backing routers", async () => { + const db = { + role: { + findMany: vi.fn().mockResolvedValue([ + { + id: "role_anim", + name: "Animation", + color: "#112233", + _count: { resourceRoles: 2 }, + }, + ]), + }, + client: { + findMany: vi.fn().mockResolvedValue([ + { + id: "client_1", + name: "Acme Mobility", + code: "ACM", + parentId: null, + isActive: true, + sortOrder: 1, + tags: [], + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + _count: { children: 0, projects: 4 }, + }, + ]), + }, + orgUnit: { + findMany: vi.fn().mockResolvedValue([ + { + id: "ou_delivery", + name: "Delivery", + shortName: "DEL", + level: 5, + parentId: null, + sortOrder: 1, + isActive: true, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + }, + ]), + findUnique: vi.fn().mockResolvedValue({ + id: "ou_delivery", + name: "Delivery", + shortName: "DEL", + level: 5, + parentId: null, + sortOrder: 1, + isActive: true, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + parent: null, + children: [], + _count: { resources: 7 }, + }), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const rolesResult = await executeTool("list_roles", "{}", ctx); + const clientsResult = await executeTool( + "list_clients", + JSON.stringify({ query: "ACM", limit: 5 }), + ctx, + ); + const orgUnitsResult = await executeTool( + "list_org_units", + JSON.stringify({ level: 5 }), + ctx, + ); + + expect(db.role.findMany).toHaveBeenCalledWith({ + where: {}, + include: { + _count: { + select: { resourceRoles: true }, + }, + }, + orderBy: { name: "asc" }, + }); + expect(db.client.findMany).toHaveBeenCalledWith({ + where: { + isActive: true, + OR: [ + { name: { contains: "ACM", mode: "insensitive" } }, + { code: { contains: "ACM", mode: "insensitive" } }, + ], + }, + include: { _count: { select: { children: true, projects: true } } }, + orderBy: [{ sortOrder: "asc" }, { name: "asc" }], + }); + expect(db.orgUnit.findMany).toHaveBeenCalledWith({ + where: { + level: 5, + isActive: true, + }, + orderBy: [{ level: "asc" }, { sortOrder: "asc" }, { name: "asc" }], + }); + expect(db.orgUnit.findUnique).toHaveBeenCalledWith({ + where: { id: "ou_delivery" }, + include: { + parent: true, + children: { orderBy: { sortOrder: "asc" } }, + _count: { select: { resources: true } }, + }, + }); + + expect(JSON.parse(rolesResult.content)).toEqual([ + { + id: "role_anim", + name: "Animation", + color: "#112233", + }, + ]); + expect(JSON.parse(clientsResult.content)).toEqual([ + { + id: "client_1", + name: "Acme Mobility", + code: "ACM", + projectCount: 4, + }, + ]); + expect(JSON.parse(orgUnitsResult.content)).toEqual([ + { + id: "ou_delivery", + name: "Delivery", + shortName: "DEL", + level: 5, + parent: null, + resourceCount: 7, + }, + ]); + }); + + it("routes blueprint and rate card reads through their backing routers", async () => { + const db = { + blueprint: { + findMany: vi.fn().mockResolvedValue([ + { + id: "bp_project", + name: "Project Default", + _count: { projects: 3 }, + }, + ]), + findUnique: vi.fn().mockResolvedValue({ + id: "bp_project", + name: "Project Default", + fieldDefs: [{ key: "market", type: "text" }], + rolePresets: [{ role: "Consulting", share: 0.5 }], + }), + }, + rateCard: { + findMany: vi.fn().mockResolvedValue([ + { + id: "rc_2026", + name: "Standard 2026", + effectiveFrom: new Date("2026-01-01T00:00:00.000Z"), + effectiveTo: null, + _count: { lines: 12 }, + client: null, + }, + ]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); + + const blueprintsResult = await executeTool("list_blueprints", "{}", ctx); + const blueprintResult = await executeTool( + "get_blueprint", + JSON.stringify({ identifier: "bp_project" }), + ctx, + ); + const rateCardsResult = await executeTool( + "list_rate_cards", + JSON.stringify({ query: "Standard", limit: 10 }), + ctx, + ); + + expect(db.blueprint.findMany).toHaveBeenCalledWith({ + select: { + id: true, + name: true, + _count: { select: { projects: true } }, + }, + orderBy: { name: "asc" }, + }); + expect(db.blueprint.findUnique).toHaveBeenCalledWith({ + where: { id: "bp_project" }, + }); + expect(db.rateCard.findMany).toHaveBeenCalledWith({ + where: { + isActive: true, + name: { contains: "Standard", mode: "insensitive" }, + }, + include: { + _count: { select: { lines: true } }, + client: { select: { id: true, name: true, code: true } }, + }, + orderBy: [{ isActive: "desc" }, { effectiveFrom: "desc" }, { name: "asc" }], + }); + + expect(JSON.parse(blueprintsResult.content)).toEqual([ + { + id: "bp_project", + name: "Project Default", + projectCount: 3, + }, + ]); + expect(JSON.parse(blueprintResult.content)).toEqual( + expect.objectContaining({ + id: "bp_project", + name: "Project Default", + fieldDefs: [{ key: "market", type: "text" }], + rolePresets: [{ role: "Consulting", share: 0.5 }], + }), + ); + expect(JSON.parse(rateCardsResult.content)).toEqual([ + { + id: "rc_2026", + name: "Standard 2026", + effectiveFrom: "2026-01-01", + effectiveTo: null, + lineCount: 12, + }, + ]); + }); + + it("routes project health reads through the dashboard router path", async () => { + vi.mocked(getDashboardProjectHealth).mockResolvedValue([ + { + id: "project_1", + projectName: "Apollo", + shortCode: "APL", + status: "ACTIVE", + clientId: "client_1", + clientName: "Acme Mobility", + budgetHealth: 74, + staffingHealth: 88, + timelineHealth: 63, + compositeScore: 75, + }, + ]); + + const ctx = createToolContext({}, { userRole: SystemRole.CONTROLLER }); + const result = await executeTool("get_project_health", "{}", ctx); + + expect(getDashboardProjectHealth).toHaveBeenCalledTimes(1); + expect(JSON.parse(result.content)).toEqual({ + projects: [ + { + projectId: "project_1", + projectName: "Apollo", + shortCode: "APL", + status: "ACTIVE", + overall: 75, + budget: 74, + staffing: 88, + timeline: 63, + rating: "at_risk", + }, + ], + summary: { + healthy: 0, + atRisk: 1, + critical: 0, + }, + }); + }); + + it("routes dashboard detail reads through dashboard router callers", async () => { + vi.mocked(getDashboardOverview).mockResolvedValue({ + totalResources: 12, + activeResources: 10, + inactiveResources: 2, + totalProjects: 4, + activeProjects: 3, + inactiveProjects: 1, + totalAllocations: 8, + activeAllocations: 7, + cancelledAllocations: 1, + approvedVacations: 2, + totalEstimates: 5, + budgetSummary: { + totalBudgetCents: 500_000, + totalCostCents: 240_000, + avgUtilizationPercent: 48, + }, + budgetBasis: { + remainingBudgetCents: 260_000, + budgetedProjects: 3, + unbudgetedProjects: 1, + trackedAssignmentCount: 8, + windowStart: new Date("2026-01-01T00:00:00.000Z"), + windowEnd: new Date("2026-06-30T00:00:00.000Z"), + }, + recentActivity: [], + projectsByStatus: [], + chapterUtilization: [ + { + chapter: "Delivery", + resourceCount: 4, + avgChargeabilityTarget: 78, + }, + ], + }); + vi.mocked(getDashboardPeakTimes).mockResolvedValue([ + { + period: "2026-03", + groups: [], + totalHours: 320.4, + capacityHours: 400.2, + utilizationPct: 80, + derivation: { + periodStart: "2026-03-01", + periodEnd: "2026-03-31", + resourceCount: 4, + groupCount: 1, + bookedHours: 320.4, + capacityHours: 400.2, + remainingCapacityHours: 79.8, + overbookedHours: 0, + 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: { + periodStart: "2026-01-01", + periodEnd: "2026-06-30", + periodWorkingHoursBase: 1040, + requiredHours: 2080, + requiredFTEs: 4, + fillPct: 50, + demandSource: "DEMAND_REQUIREMENTS", + calendarLocations: [ + { + countryCode: "DE", + federalState: "BY", + metroCityName: "Augsburg", + resourceCount: 2, + allocatedHours: 120, + }, + ], + }, + }, + ]); + + const ctx = createToolContext( + { + systemSettings: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const result = await executeTool("get_dashboard_detail", JSON.stringify({ section: "all" }), ctx); + + expect(getDashboardOverview).toHaveBeenCalledTimes(1); + expect(getDashboardPeakTimes).toHaveBeenCalledWith( + ctx.db, + expect.objectContaining({ + startDate: new Date("2026-01-01T00:00:00.000Z"), + endDate: new Date("2026-06-30T00:00:00.000Z"), + granularity: "month", + groupBy: "project", + }), + ); + expect(getDashboardTopValueResources).toHaveBeenCalledWith( + ctx.db, + expect.objectContaining({ + limit: 10, + userRole: SystemRole.CONTROLLER, + }), + ); + expect(getDashboardDemand).toHaveBeenCalledWith( + ctx.db, + expect.objectContaining({ + startDate: new Date("2026-01-01T00:00:00.000Z"), + endDate: new Date("2026-06-30T00:00:00.000Z"), + groupBy: "project", + }), + ); + expect(JSON.parse(result.content)).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%", + }, + ], + }); + }); + + it("routes skill gap reads through the dashboard router path", async () => { + vi.mocked(getDashboardSkillGapSummary).mockResolvedValue({ + roleGaps: [ + { role: "Pipeline TD", needed: 4, filled: 1, gap: 3, fillRate: 25 }, + ], + totalOpenPositions: 3, + skillSupplyTop10: [ + { skill: "houdini", resourceCount: 5 }, + ], + resourcesByRole: [ + { role: "Pipeline TD", count: 2 }, + ], + }); + + const ctx = createToolContext({}, { userRole: SystemRole.CONTROLLER }); + const result = await executeTool("get_skill_gaps", "{}", ctx); + + expect(getDashboardSkillGapSummary).toHaveBeenCalledTimes(1); + expect(JSON.parse(result.content)).toEqual({ + roleGaps: [ + { role: "Pipeline TD", needed: 4, filled: 1, gap: 3, fillRate: 25 }, + ], + totalOpenPositions: 3, + skillSupplyTop10: [ + { skill: "houdini", resourceCount: 5 }, + ], + resourcesByRole: [ + { role: "Pipeline TD", count: 2 }, + ], + }); + }); + + it("routes insights summary reads through the insights router path", async () => { + const db = { + project: { + findMany: vi.fn().mockResolvedValue([ + { + 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-03-31T00:00:00.000Z"), + hoursPerDay: 8, + dailyCostCents: 10_000, + status: "ACTIVE", + }, + ], + }, + ]), + }, + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + }, + ]), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([ + { + resourceId: "res_1", + hoursPerDay: 2, + }, + ]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const result = await executeTool("get_insights_summary", "{}", ctx); + + expect(db.project.findMany).toHaveBeenCalledWith({ + 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, + }, + }, + }, + }); + expect(db.resource.findMany).toHaveBeenCalledWith({ + where: { isActive: true }, + select: { id: true, displayName: true, availability: true }, + }); + expect(db.assignment.findMany).toHaveBeenCalledWith({ + where: { + status: { in: ["ACTIVE", "CONFIRMED"] }, + startDate: { lte: expect.any(Date) }, + endDate: { gte: expect.any(Date) }, + }, + select: { resourceId: true, hoursPerDay: true }, + }); + expect(JSON.parse(result.content)).toEqual({ + total: 3, + criticalCount: 2, + budget: 1, + staffing: 1, + timeline: 0, + utilization: 1, + }); + }); + + it("returns anomaly details and count from the insights-backed detector", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-29T12:00:00.000Z")); + + try { + const ctx = createToolContext({ + 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: [ + { + id: "demand_1", + headcount: 3, + startDate: new Date("2026-03-20T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + status: "CONFIRMED", + _count: { assignments: 1 }, + }, + ], + assignments: [ + { + id: "assignment_1", + 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 result = await executeTool("detect_anomalies", "{}", ctx); + const parsed = JSON.parse(result.content) as { + count: number; + anomalies: Array<{ type: string; severity: string; entityName: string }>; + }; + + expect(parsed.count).toBe(4); + expect(parsed.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", + }), + ]); + } finally { + vi.useRealTimers(); + } + }); + + it("routes project search and detail reads through the project router path", async () => { + const db = { + project: { + findMany: vi.fn().mockResolvedValue([ + { + id: "project_1", + shortCode: "GDM", + name: "Gelddruckmaschine", + status: "ACTIVE", + budgetCents: 500_000, + 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 }, + }, + ]), + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "project_1", + shortCode: "GDM", + name: "Gelddruckmaschine", + status: "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: "ACTIVE", + orderType: "CHARGEABLE", + allocationType: "INT", + budgetCents: 500_000, + 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: "BILL", name: "Billable" }, + _count: { assignments: 3, estimates: 1 }, + }), + }, + 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 ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const searchResult = await executeTool( + "search_projects", + JSON.stringify({ query: "Gelddruckmaschine", limit: 10 }), + ctx, + ); + const detailResult = await executeTool( + "get_project", + JSON.stringify({ identifier: "GDM" }), + ctx, + ); + + expect(db.project.findMany).toHaveBeenCalledWith({ + where: { + OR: [ + { name: { contains: "Gelddruckmaschine", mode: "insensitive" } }, + { shortCode: { contains: "Gelddruckmaschine", mode: "insensitive" } }, + ], + }, + select: { + id: true, + shortCode: true, + name: true, + status: true, + budgetCents: true, + winProbability: true, + startDate: true, + endDate: true, + client: { select: { name: true } }, + _count: { select: { assignments: true, estimates: true } }, + }, + take: 10, + orderBy: { name: "asc" }, + }); + expect(db.project.findUnique).toHaveBeenNthCalledWith(1, { + where: { id: "GDM" }, + select: { + id: true, + shortCode: true, + name: true, + status: true, + startDate: true, + endDate: true, + }, + }); + expect(db.project.findUnique).toHaveBeenNthCalledWith(2, { + where: { shortCode: "GDM" }, + select: { + id: true, + shortCode: true, + name: true, + status: true, + startDate: true, + endDate: true, + }, + }); + expect(db.project.findUnique).toHaveBeenNthCalledWith(3, { + where: { id: "project_1" }, + 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 } }, + }, + }); + expect(db.assignment.findMany).toHaveBeenCalledWith({ + where: { + projectId: "project_1", + 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" }, + }); + + expect(JSON.parse(searchResult.content)).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, + }, + ]); + expect(JSON.parse(detailResult.content)).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("routes resource search and detail reads through the resource router path", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + eid: "EMP-001", + displayName: "Bruce Banner", + chapter: "Delivery", + fte: 1, + lcrCents: 8_500, + chargeabilityTarget: 80, + isActive: true, + areaRole: { name: "Pipeline TD" }, + country: { code: "DE", name: "Germany", dailyWorkingHours: 8 }, + metroCity: { name: "Munich" }, + orgUnit: { name: "Operations", level: 5 }, + email: "bruce@example.com", + ucrCents: 10_500, + availability: { monday: 8 }, + skills: [{ name: "Houdini", level: 5 }], + postalCode: "80331", + federalState: "BY", + managementLevelGroup: { name: "Senior", targetPercentage: 75 }, + _count: { assignments: 4, vacations: 2 }, + }, + ]), + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Bruce Banner", + chapter: "Delivery", + isActive: true, + }) + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Bruce Banner", + email: "bruce@example.com", + chapter: "Delivery", + fte: 1, + lcrCents: 8_500, + ucrCents: 10_500, + chargeabilityTarget: 80, + isActive: true, + availability: { monday: 8 }, + skills: [{ name: "Houdini", level: 5 }], + postalCode: "80331", + federalState: "BY", + areaRole: { name: "Pipeline TD", color: "#112233" }, + country: { code: "DE", name: "Germany", dailyWorkingHours: 8 }, + metroCity: { name: "Munich" }, + managementLevelGroup: { name: "Senior", targetPercentage: 75 }, + orgUnit: { name: "Operations", level: 5 }, + _count: { assignments: 4, vacations: 2 }, + }), + findFirst: vi.fn(), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const searchResult = await executeTool( + "search_resources", + JSON.stringify({ query: "Bruce", country: "DE", roleName: "Pipeline", limit: 5 }), + ctx, + ); + const detailResult = await executeTool( + "get_resource", + JSON.stringify({ identifier: "EMP-001" }), + ctx, + ); + + expect(db.resource.findMany).toHaveBeenCalledWith({ + where: { + isActive: true, + OR: [ + { displayName: { contains: "Bruce", mode: "insensitive" } }, + { eid: { contains: "Bruce", mode: "insensitive" } }, + { chapter: { contains: "Bruce", mode: "insensitive" } }, + ], + country: { + OR: [ + { code: { equals: "DE", mode: "insensitive" } }, + { name: { contains: "DE", mode: "insensitive" } }, + ], + }, + areaRole: { name: { contains: "Pipeline", mode: "insensitive" } }, + }, + select: { + id: true, + eid: true, + displayName: true, + chapter: true, + fte: true, + lcrCents: true, + chargeabilityTarget: true, + isActive: true, + areaRole: { select: { name: true } }, + country: { select: { code: true, name: true } }, + metroCity: { select: { name: true } }, + orgUnit: { select: { name: true } }, + }, + take: 5, + orderBy: { displayName: "asc" }, + }); + expect(db.resource.findUnique).toHaveBeenNthCalledWith(1, { + where: { id: "EMP-001" }, + select: { + id: true, + eid: true, + displayName: true, + chapter: true, + isActive: true, + }, + }); + expect(db.resource.findUnique).toHaveBeenNthCalledWith(2, { + where: { eid: "EMP-001" }, + select: { + id: true, + eid: true, + displayName: true, + chapter: true, + isActive: true, + }, + }); + expect(db.resource.findUnique).toHaveBeenNthCalledWith(3, { + where: { id: "res_1" }, + select: { + id: true, + eid: true, + displayName: true, + email: true, + chapter: true, + fte: true, + lcrCents: true, + ucrCents: true, + chargeabilityTarget: true, + isActive: true, + availability: true, + skills: true, + postalCode: true, + federalState: true, + areaRole: { select: { name: true, color: true } }, + country: { select: { code: true, name: true, dailyWorkingHours: true } }, + metroCity: { select: { name: true } }, + managementLevelGroup: { select: { name: true, targetPercentage: true } }, + orgUnit: { select: { name: true, level: true } }, + _count: { select: { assignments: true, vacations: true } }, + }, + }); + + expect(JSON.parse(searchResult.content)).toEqual([ + { + id: "res_1", + eid: "EMP-001", + name: "Bruce Banner", + chapter: "Delivery", + role: "Pipeline TD", + country: "Germany", + countryCode: "DE", + metroCity: "Munich", + orgUnit: "Operations", + fte: 1, + lcr: "85,00 EUR", + chargeabilityTarget: "80%", + active: true, + }, + ]); + expect(JSON.parse(detailResult.content)).toEqual({ + id: "res_1", + eid: "EMP-001", + name: "Bruce Banner", + email: "bruce@example.com", + chapter: "Delivery", + role: "Pipeline TD", + country: "Germany", + countryCode: "DE", + countryHours: 8, + metroCity: "Munich", + fte: 1, + lcr: "85,00 EUR", + ucr: "105,00 EUR", + chargeabilityTarget: "80%", + managementLevel: "Senior", + orgUnit: "Operations", + postalCode: "80331", + federalState: "BY", + active: true, + totalAssignments: 4, + totalVacations: 2, + skillCount: 1, + topSkills: ["Houdini (5)"], + }); + }); + + it("routes rate lookup reads through the rate-card router path", async () => { + const db = { + rateCard: { + findMany: vi.fn().mockResolvedValue([ + { + id: "rc_client", + name: "Client 2026", + client: { id: "client_1", name: "Acme Mobility" }, + lines: [ + { + id: "line_best", + chapter: "Delivery", + seniority: "Senior", + costRateCents: 12_000, + billRateCents: 18_000, + role: { id: "role_1", name: "Pipeline TD" }, + }, + ], + }, + { + id: "rc_default", + name: "Default 2026", + client: null, + lines: [ + { + id: "line_alt", + chapter: "Delivery", + seniority: "Senior", + costRateCents: 11_000, + billRateCents: 17_000, + role: { id: "role_1", name: "Pipeline TD" }, + }, + ], + }, + ]), + }, + role: { + findFirst: vi.fn().mockResolvedValue({ id: "role_1" }), + }, + }; + const ctx = createToolContext(db, { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_COSTS], + }); + + const result = await executeTool( + "lookup_rate", + JSON.stringify({ + clientId: "client_1", + chapter: "Delivery", + roleName: "Pipeline", + seniority: "Senior", + }), + ctx, + ); + + expect(db.rateCard.findMany).toHaveBeenCalledWith({ + where: { + isActive: true, + OR: [ + { clientId: "client_1" }, + { clientId: null }, + ], + }, + 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" }], + }); + expect(db.role.findFirst).toHaveBeenCalledWith({ + where: { name: { contains: "Pipeline", mode: "insensitive" } }, + select: { id: true }, + }); + expect(JSON.parse(result.content)).toEqual({ + bestMatch: { + rateCardName: "Client 2026", + clientId: "client_1", + clientName: "Acme Mobility", + lineId: "line_best", + chapter: "Delivery", + seniority: "Senior", + roleName: "Pipeline TD", + costRateCents: 12000, + billRateCents: 18000, + score: 10, + costRate: "120,00 EUR", + billRate: "180,00 EUR", + }, + alternatives: [ + { + rateCardName: "Default 2026", + clientId: null, + clientName: null, + lineId: "line_alt", + chapter: "Delivery", + seniority: "Senior", + roleName: "Pipeline TD", + costRateCents: 11000, + billRateCents: 17000, + score: 7, + costRate: "110,00 EUR", + billRate: "170,00 EUR", + }, + ], + totalCandidates: 2, + }); + }); + + it("routes report reads through the report router path", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + displayName: "Bruce Banner", + chapter: "Delivery", + country: { name: "Germany" }, + }, + ]), + count: vi.fn().mockResolvedValue(1), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const result = await executeTool( + "run_report", + JSON.stringify({ + entity: "resource", + columns: ["displayName", "chapter", "country.name"], + filters: [{ field: "displayName", op: "contains", value: "Bruce" }], + limit: 25, + }), + ctx, + ); + + expect(db.resource.findMany).toHaveBeenCalledWith({ + select: { + id: true, + displayName: true, + chapter: true, + country: { select: { name: true } }, + }, + where: { + displayName: { contains: "Bruce", mode: "insensitive" }, + }, + take: 25, + skip: 0, + }); + expect(db.resource.count).toHaveBeenCalledWith({ + where: { + displayName: { contains: "Bruce", mode: "insensitive" }, + }, + }); + expect(JSON.parse(result.content)).toEqual({ + rows: [ + { + id: "res_1", + displayName: "Bruce Banner", + chapter: "Delivery", + "country.name": "Germany", + }, + ], + rowCount: 1, + columns: ["id", "displayName", "chapter", "country.name"], + }); + }); + + it("routes budget status reads through the timeline and project router paths", async () => { + vi.mocked(listAssignmentBookings).mockResolvedValue([ + { + projectId: "project_1", + status: "CONFIRMED", + dailyCostCents: 10_000, + hoursPerDay: 8, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-02T00:00:00.000Z"), + project: { + id: "project_1", + status: "ACTIVE", + }, + }, + { + projectId: "project_1", + status: "PROPOSED", + dailyCostCents: 5_000, + hoursPerDay: 8, + startDate: new Date("2026-04-03T00:00:00.000Z"), + endDate: new Date("2026-04-03T00:00:00.000Z"), + project: { + id: "project_1", + status: "ACTIVE", + }, + }, + ] as Awaited>); + + const db = { + project: { + findUnique: vi + .fn() + .mockResolvedValueOnce({ + id: "project_1", + name: "Gelddruckmaschine", + shortCode: "GDM", + status: "ACTIVE", + responsiblePerson: "Larissa", + }) + .mockResolvedValueOnce({ + id: "project_1", + name: "Gelddruckmaschine", + shortCode: "GDM", + budgetCents: 100_000, + winProbability: 80, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-30T00:00:00.000Z"), + }), + findFirst: vi.fn(), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const result = await executeTool( + "get_budget_status", + JSON.stringify({ projectId: "project_1" }), + ctx, + ); + + expect(vi.mocked(listAssignmentBookings)).toHaveBeenCalledWith(db, { + projectIds: ["project_1"], + }); + expect(JSON.parse(result.content)).toEqual({ + project: "Gelddruckmaschine", + code: "GDM", + budget: "1.000,00 EUR", + confirmed: "200,00 EUR", + proposed: "50,00 EUR", + allocated: "250,00 EUR", + remaining: "750,00 EUR", + utilization: "25.0%", + winWeighted: "200,00 EUR", + }); + }); + + it("routes scenario simulation through the scenario router path", async () => { + const db = { + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "project_1", + name: "Gelddruckmaschine", + budgetCents: 200_000, + orderType: "CHARGEABLE", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-30T00:00:00.000Z"), + }), + }, + assignment: { + findMany: vi.fn() + .mockResolvedValueOnce([ + { + id: "assign_1", + resourceId: "res_1", + projectId: "project_1", + hoursPerDay: 4, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-03T00:00:00.000Z"), + status: "ACTIVE", + roleId: null, + role: "Pipeline TD", + resource: { + id: "res_1", + displayName: "Bruce Banner", + eid: "EMP-001", + lcrCents: 10_000, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + chargeabilityTarget: 80, + skills: [{ skill: "Houdini" }], + countryId: null, + federalState: null, + metroCityId: null, + country: null, + metroCity: null, + }, + roleEntity: null, + }, + ]) + .mockResolvedValueOnce([ + { + id: "assign_1", + resourceId: "res_1", + projectId: "project_1", + hoursPerDay: 4, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-03T00:00:00.000Z"), + }, + ]), + }, + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + displayName: "Bruce Banner", + eid: "EMP-001", + lcrCents: 10_000, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + chargeabilityTarget: 80, + skills: [{ skill: "Houdini" }], + countryId: null, + federalState: null, + metroCityId: null, + country: null, + metroCity: null, + }, + ]), + }, + role: { + findMany: vi.fn(), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const result = await executeTool( + "simulate_scenario", + JSON.stringify({ + projectId: "project_1", + changes: [ + { + assignmentId: "assign_1", + resourceId: "res_1", + startDate: "2026-04-01", + endDate: "2026-04-03", + hoursPerDay: 6, + }, + ], + }), + ctx, + ); + + expect(db.assignment.findMany).toHaveBeenNthCalledWith(1, { + where: { projectId: "project_1", status: { not: "CANCELLED" } }, + include: { + resource: { + select: { + id: true, + displayName: true, + eid: true, + lcrCents: true, + availability: true, + chargeabilityTarget: true, + skills: true, + countryId: true, + federalState: true, + metroCityId: true, + country: { select: { code: true } }, + metroCity: { select: { name: true } }, + }, + }, + }, + }); + expect(db.assignment.findMany).toHaveBeenNthCalledWith(2, { + where: { + resourceId: { in: ["res_1"] }, + status: { not: "CANCELLED" }, + }, + select: { + id: true, + resourceId: true, + projectId: true, + hoursPerDay: true, + startDate: true, + endDate: true, + }, + }); + expect(db.role.findMany).not.toHaveBeenCalled(); + expect(JSON.parse(result.content)).toEqual({ + baseline: { + totalCostCents: 120000, + totalHours: 12, + headcount: 1, + skillCount: 1, + totalCost: "1.200,00 EUR", + }, + scenario: { + totalCostCents: 180000, + totalHours: 18, + headcount: 1, + skillCount: 1, + totalCost: "1.800,00 EUR", + }, + delta: { + costCents: 60000, + hours: 6, + headcount: 0, + skillCoveragePct: 100, + cost: "600,00 EUR", + }, + resourceImpacts: [ + { + resourceId: "res_1", + resourceName: "Bruce Banner", + chargeabilityTarget: 80, + currentUtilization: 6.8, + scenarioUtilization: 10.2, + utilizationDelta: 3.4, + isOverallocated: false, + }, + ], + warnings: [], + budgetCents: 200000, + }); + }); + + it("routes allocation listing through the allocation router path", async () => { + const db = { + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + anonymizationEnabled: false, + anonymizationDomain: null, + anonymizationSeed: null, + anonymizationMode: null, + anonymizationAliases: null, + }), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([ + { + id: "demand_1", + projectId: "project_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-30T00:00:00.000Z"), + hoursPerDay: 8, + percentage: 100, + role: "TD", + roleId: null, + headcount: 1, + status: "OPEN", + metadata: null, + createdAt: new Date("2026-03-20T00:00:00.000Z"), + updatedAt: new Date("2026-03-20T00:00:00.000Z"), + project: { id: "project_1", name: "Gelddruckmaschine", shortCode: "GDM", status: "ACTIVE", endDate: new Date("2026-04-30T00:00:00.000Z") }, + roleEntity: null, + assignments: [], + }, + ]), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([ + { + id: "assign_1", + demandRequirementId: null, + resourceId: "res_1", + projectId: "project_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + hoursPerDay: 6, + percentage: 75, + role: "Pipeline TD", + roleId: null, + dailyCostCents: 12_000, + status: "ACTIVE", + metadata: null, + createdAt: new Date("2026-03-21T00:00:00.000Z"), + updatedAt: new Date("2026-03-21T00:00:00.000Z"), + resource: { id: "res_1", displayName: "Bruce Banner", eid: "EMP-001", lcrCents: 8_500 }, + project: { id: "project_1", name: "Gelddruckmaschine", shortCode: "GDM", status: "ACTIVE", endDate: new Date("2026-04-30T00:00:00.000Z") }, + roleEntity: null, + demandRequirement: null, + }, + ]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const result = await executeTool( + "list_allocations", + JSON.stringify({ + projectId: "project_1", + resourceName: "Bruce", + projectCode: "GDM", + status: "ACTIVE", + limit: 10, + }), + ctx, + ); + + expect(db.demandRequirement.findMany).toHaveBeenCalledWith({ + where: { + projectId: "project_1", + status: "ACTIVE", + }, + include: { + project: { + select: { + id: true, + name: true, + shortCode: true, + status: true, + endDate: true, + }, + }, + roleEntity: { + select: { id: true, name: true, color: true }, + }, + assignments: { + include: { + resource: { + select: { + id: true, + displayName: true, + eid: true, + lcrCents: true, + chapter: true, + }, + }, + project: { + select: { + id: true, + name: true, + shortCode: true, + status: true, + endDate: true, + }, + }, + roleEntity: { + select: { id: true, name: true, color: true }, + }, + }, + }, + }, + orderBy: { startDate: "asc" }, + }); + expect(db.assignment.findMany).toHaveBeenCalledWith({ + where: { + projectId: "project_1", + status: "ACTIVE", + }, + include: { + resource: { + select: { + id: true, + displayName: true, + eid: true, + lcrCents: true, + chapter: true, + }, + }, + project: { + select: { + id: true, + name: true, + shortCode: true, + status: true, + endDate: true, + }, + }, + roleEntity: { + select: { id: true, name: true, color: true }, + }, + demandRequirement: { + select: { + id: true, + projectId: true, + startDate: true, + endDate: true, + hoursPerDay: true, + percentage: true, + role: true, + roleId: true, + headcount: true, + status: true, + }, + }, + }, + orderBy: { startDate: "asc" }, + }); + expect(JSON.parse(result.content)).toEqual([ + { + id: "assign_1", + resource: "Bruce Banner", + resourceEid: "EMP-001", + project: "Gelddruckmaschine", + projectCode: "GDM", + role: "Pipeline TD", + status: "ACTIVE", + hoursPerDay: 6, + dailyCost: "120,00 EUR", + start: "2026-04-01", + end: "2026-04-10", + }, + ]); + }); + + it("routes upcoming vacation reads through the vacation router path and post-filters by chapter", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-01T00:00:00.000Z")); + + try { + const db = { + vacation: { + findMany: vi.fn().mockResolvedValue([ + { + id: "vac_1", + resourceId: "res_1", + status: "APPROVED", + type: VacationType.ANNUAL, + startDate: new Date("2026-03-05T00:00:00.000Z"), + endDate: new Date("2026-03-06T00:00:00.000Z"), + isHalfDay: false, + halfDayPart: null, + resource: { + id: "res_1", + displayName: "Bruce Banner", + eid: "EMP-001", + lcrCents: 8_000, + chapter: "Delivery", + }, + requestedBy: null, + approvedBy: null, + }, + { + id: "vac_2", + resourceId: "res_2", + status: "APPROVED", + type: VacationType.ANNUAL, + startDate: new Date("2026-03-07T00:00:00.000Z"), + endDate: new Date("2026-03-07T00:00:00.000Z"), + isHalfDay: true, + halfDayPart: "MORNING", + resource: { + id: "res_2", + displayName: "Tony Stark", + eid: "EMP-002", + lcrCents: 9_000, + chapter: "Finance", + }, + requestedBy: null, + approvedBy: null, + }, + ]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.MANAGER }); + + const result = await executeTool( + "list_vacations_upcoming", + JSON.stringify({ chapter: "Delivery", daysAhead: 14, limit: 10 }), + ctx, + ); + + expect(db.vacation.findMany).toHaveBeenCalledWith({ + where: { + status: "APPROVED", + endDate: { gte: new Date("2026-03-01T00:00:00.000Z") }, + startDate: { lte: new Date("2026-03-15T00:00:00.000Z") }, + }, + include: { + resource: { + select: { + id: true, + displayName: true, + eid: true, + lcrCents: true, + chapter: true, + }, + }, + requestedBy: { select: { id: true, name: true, email: true } }, + approvedBy: { select: { id: true, name: true, email: true } }, + }, + orderBy: { startDate: "asc" }, + take: 10, + }); + expect(JSON.parse(result.content)).toEqual([ + { + resource: "Bruce Banner", + eid: "EMP-001", + chapter: "Delivery", + type: VacationType.ANNUAL, + start: "2026-03-05", + end: "2026-03-06", + isHalfDay: false, + halfDayPart: null, + }, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it("routes skill searches through the resource router path", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + eid: "EMP-001", + displayName: "Bruce Banner", + chapter: "Delivery", + skills: [{ skill: "Houdini FX", proficiency: 4, category: "FX" }], + }, + { + id: "res_2", + eid: "EMP-002", + displayName: "Tony Stark", + chapter: "Tech", + skills: [{ skill: "Nuke", proficiency: 5, category: "Comp" }], + }, + ]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const result = await executeTool( + "search_by_skill", + JSON.stringify({ skill: "houdini" }), + ctx, + ); + + expect(db.resource.findMany).toHaveBeenCalledWith({ + where: { isActive: true }, + select: { + id: true, + eid: true, + displayName: true, + chapter: true, + skills: true, + }, + }); + expect(JSON.parse(result.content)).toEqual([ + { + id: "res_1", + eid: "EMP-001", + name: "Bruce Banner", + matchedSkill: "Houdini FX", + level: 4, + chapter: "Delivery", + }, + ]); + }); + + it("routes availability checks through the allocation and vacation router paths", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue({ + id: "res_1", + eid: "EMP-001", + displayName: "Bruce Banner", + fte: 1, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + countryId: null, + federalState: null, + metroCityId: null, + country: null, + metroCity: null, + }), + findFirst: vi.fn(), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([ + { + id: "assign_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", + resourceId: "res_1", + status: "APPROVED", + type: VacationType.ANNUAL, + startDate: new Date("2026-04-02T00:00:00.000Z"), + endDate: new Date("2026-04-02T00:00:00.000Z"), + isHalfDay: true, + halfDayPart: "AFTERNOON", + resource: { + id: "res_1", + displayName: "Bruce Banner", + eid: "EMP-001", + lcrCents: 8_000, + }, + requestedBy: null, + approvedBy: null, + }, + ]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const result = await executeTool( + "check_resource_availability", + JSON.stringify({ resourceId: "res_1", startDate: "2026-04-01", endDate: "2026-04-02" }), + ctx, + ); + + expect(db.assignment.findMany).toHaveBeenCalledWith({ + where: { + resourceId: "res_1", + status: { not: "CANCELLED" }, + startDate: { lte: new Date("2026-04-02T00:00:00.000Z") }, + endDate: { gte: new Date("2026-04-01T00:00:00.000Z") }, + }, + select: { + id: true, + startDate: true, + endDate: true, + hoursPerDay: true, + status: true, + project: { select: { name: true, shortCode: true } }, + }, + orderBy: { startDate: "asc" }, + }); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + resource: "Bruce Banner", + workingDays: 2, + periodAvailableHours: 16, + periodBookedHours: 4, + periodRemainingHours: 12, + availableHoursPerDay: 6, + isFullyAvailable: false, + existingAllocations: [ + { + project: "Gelddruckmaschine (GDM)", + hoursPerDay: 4, + status: "CONFIRMED", + start: "2026-04-01", + end: "2026-04-01", + }, + ], + vacations: [ + { + type: VacationType.ANNUAL, + start: "2026-04-02", + end: "2026-04-02", + isHalfDay: true, + }, + ], + }), + ); + }); + + it("routes rate resolution through the rate-card router path", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue({ + id: "res_1", + eid: "EMP-001", + displayName: "Bruce Banner", + chapter: "Delivery", + lcrCents: 8_000, + areaRole: { name: "Pipeline TD" }, + managementLevel: { id: "ml_senior" }, + }), + findFirst: vi.fn(), + }, + 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" }, + managementLevelId: "ml_senior", + }, + ], + }, + ]), + findUnique: vi.fn(), + }, + }; + const ctx = createToolContext(db, { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_COSTS], + }); + + const result = await executeTool( + "resolve_rate", + JSON.stringify({ resourceId: "res_1" }), + ctx, + ); + + expect(db.resource.findUnique).toHaveBeenNthCalledWith(1, { + where: { id: "res_1" }, + select: { + id: true, + eid: true, + displayName: true, + chapter: true, + isActive: true, + }, + }); + expect(db.resource.findUnique).toHaveBeenNthCalledWith(2, { + where: { id: "res_1" }, + select: { + id: true, + displayName: true, + chapter: true, + areaRole: { select: { name: true } }, + }, + }); + expect(JSON.parse(result.content)).toEqual({ + rateCard: "Standard 2026", + resource: "Bruce Banner", + rate: "120,00 EUR", + rateCents: 12000, + matchedBy: "role: Pipeline TD", + }); + }); + + it("creates, updates, and deletes holiday calendars and entries through the real holiday router path", async () => { + const db = { + country: { + findUnique: vi.fn().mockResolvedValue({ id: "country_de", name: "Germany" }), + }, + metroCity: { + findUnique: vi.fn().mockResolvedValue({ id: "city_muc", countryId: "country_de" }), + }, + holidayCalendar: { + findFirst: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null), + findUnique: vi.fn() + .mockResolvedValueOnce({ + id: "cal_de", + name: "Germany National", + scopeType: "COUNTRY", + countryId: "country_de", + stateCode: null, + metroCityId: null, + }) + .mockResolvedValueOnce({ + id: "cal_de", + name: "Germany National", + }) + .mockResolvedValueOnce({ + id: "cal_de", + name: "Germany National Updated", + entries: [], + }), + create: vi.fn().mockResolvedValue({ + id: "cal_de", + name: "Germany National", + scopeType: "COUNTRY", + stateCode: null, + isActive: true, + priority: 0, + country: { id: "country_de", code: "DE", name: "Germany" }, + metroCity: null, + entries: [], + }), + update: vi.fn().mockResolvedValue({ + id: "cal_de", + name: "Germany National Updated", + scopeType: "COUNTRY", + stateCode: null, + isActive: false, + priority: 1, + country: { id: "country_de", code: "DE", name: "Germany" }, + metroCity: null, + entries: [], + }), + delete: vi.fn().mockResolvedValue({ id: "cal_de" }), + }, + holidayCalendarEntry: { + findFirst: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null), + findUnique: vi.fn() + .mockResolvedValueOnce({ + id: "entry_1", + name: "New Year", + date: new Date("2026-01-01T00:00:00.000Z"), + holidayCalendarId: "cal_de", + }) + .mockResolvedValueOnce({ + id: "entry_1", + name: "New Year Observed", + }), + create: vi.fn().mockResolvedValue({ + id: "entry_1", + date: new Date("2026-01-01T00:00:00.000Z"), + name: "New Year", + isRecurringAnnual: true, + source: "seed", + }), + update: vi.fn().mockResolvedValue({ + id: "entry_1", + date: new Date("2026-01-02T00:00:00.000Z"), + name: "New Year Observed", + isRecurringAnnual: false, + source: null, + }), + delete: vi.fn().mockResolvedValue({ id: "entry_1" }), + }, + auditLog: { + create: vi.fn().mockResolvedValue({ id: "audit_1" }), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); + + const createCalendarResult = await executeTool( + "create_holiday_calendar", + JSON.stringify({ name: "Germany National", scopeType: "COUNTRY", countryId: "country_de" }), + ctx, + ); + const updateCalendarResult = await executeTool( + "update_holiday_calendar", + JSON.stringify({ id: "cal_de", data: { name: "Germany National Updated", isActive: false, priority: 1 } }), + ctx, + ); + const createEntryResult = await executeTool( + "create_holiday_calendar_entry", + JSON.stringify({ + holidayCalendarId: "cal_de", + date: "2026-01-01", + name: "New Year", + isRecurringAnnual: true, + source: "seed", + }), + ctx, + ); + const updateEntryResult = await executeTool( + "update_holiday_calendar_entry", + JSON.stringify({ + id: "entry_1", + data: { date: "2026-01-02", name: "New Year Observed", isRecurringAnnual: false, source: null }, + }), + ctx, + ); + const deleteEntryResult = await executeTool( + "delete_holiday_calendar_entry", + JSON.stringify({ id: "entry_1" }), + ctx, + ); + const deleteCalendarResult = await executeTool( + "delete_holiday_calendar", + JSON.stringify({ id: "cal_de" }), + ctx, + ); + + expect(db.holidayCalendar.create).toHaveBeenCalledWith({ + data: { + name: "Germany National", + scopeType: "COUNTRY", + countryId: "country_de", + isActive: true, + priority: 0, + }, + include: { + country: { select: { id: true, code: true, name: true } }, + metroCity: { select: { id: true, name: true } }, + entries: true, + }, + }); + expect(db.holidayCalendar.update).toHaveBeenCalledWith({ + where: { id: "cal_de" }, + data: { + name: "Germany National Updated", + isActive: false, + priority: 1, + }, + include: { + country: { select: { id: true, code: true, name: true } }, + metroCity: { select: { id: true, name: true } }, + entries: { orderBy: [{ date: "asc" }, { name: "asc" }] }, + }, + }); + expect(db.holidayCalendarEntry.create).toHaveBeenCalledWith({ + data: { + holidayCalendarId: "cal_de", + date: new Date("2026-01-01T00:00:00.000Z"), + name: "New Year", + isRecurringAnnual: true, + source: "seed", + }, + }); + expect(db.holidayCalendarEntry.update).toHaveBeenCalledWith({ + where: { id: "entry_1" }, + data: { + date: new Date("2026-01-02T00:00:00.000Z"), + name: "New Year Observed", + isRecurringAnnual: false, + source: null, + }, + }); + expect(db.holidayCalendarEntry.delete).toHaveBeenCalledWith({ where: { id: "entry_1" } }); + expect(db.holidayCalendar.delete).toHaveBeenCalledWith({ where: { id: "cal_de" } }); + expect(JSON.parse(createCalendarResult.content)).toEqual( + expect.objectContaining({ success: true, message: "Created holiday calendar: Germany National" }), + ); + expect(JSON.parse(updateCalendarResult.content)).toEqual( + expect.objectContaining({ success: true, message: "Updated holiday calendar: Germany National Updated" }), + ); + expect(JSON.parse(createEntryResult.content)).toEqual( + expect.objectContaining({ success: true, message: "Created holiday entry: New Year" }), + ); + expect(JSON.parse(updateEntryResult.content)).toEqual( + expect.objectContaining({ success: true, message: "Updated holiday entry: New Year Observed" }), + ); + expect(JSON.parse(deleteEntryResult.content)).toEqual( + expect.objectContaining({ success: true, message: "Deleted holiday entry: New Year Observed" }), + ); + expect(JSON.parse(deleteCalendarResult.content)).toEqual( + expect.objectContaining({ success: true, message: "Deleted holiday calendar: Germany National Updated" }), + ); + }); + + it("returns the expected assistant payloads for role, client, and org unit mutations", async () => { + const db = { + role: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: "role_1", name: "CG Artist", description: null, color: "#111111", isActive: true }) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "role_1", + name: "Senior CG Artist", + description: "Pipeline lead", + color: "#222222", + isActive: true, + _count: { resourceRoles: 0 }, + resourceRoles: [], + }) + .mockResolvedValueOnce({ + id: "role_1", + name: "Senior CG Artist", + description: "Pipeline lead", + color: "#222222", + isActive: true, + _count: { resourceRoles: 0 }, + resourceRoles: [], + }) + .mockResolvedValueOnce({ + id: "role_1", + name: "Senior CG Artist", + description: "Pipeline lead", + color: "#222222", + isActive: true, + _count: { resourceRoles: 0 }, + }), + create: vi.fn().mockResolvedValue({ + id: "role_1", + name: "CG Artist", + description: null, + color: "#111111", + isActive: true, + _count: { resourceRoles: 0 }, + }), + update: vi.fn().mockResolvedValue({ + id: "role_1", + name: "Senior CG Artist", + description: "Pipeline lead", + color: "#222222", + isActive: true, + _count: { resourceRoles: 0 }, + }), + delete: vi.fn().mockResolvedValue({ id: "role_1" }), + }, + client: { + findUnique: vi.fn(async ({ + where, + }: { + where: { id?: string; code?: string }; + }) => { + if (where.id === "client_1") { + return { + id: "client_1", + name: "Acme", + code: "AC", + sortOrder: 0, + isActive: true, + parentId: null, + tags: [], + }; + } + + return null; + }), + create: vi.fn().mockResolvedValue({ + id: "client_1", + name: "Acme", + code: "AC", + parentId: null, + sortOrder: 2, + tags: ["auto"], + }), + update: vi.fn().mockResolvedValue({ + id: "client_1", + name: "Acme Mobility", + code: "ACM", + parentId: null, + sortOrder: 3, + tags: ["auto", "priority"], + isActive: true, + }), + }, + orgUnit: { + findUnique: vi.fn(async ({ + where, + }: { + where: { id: string }; + }) => ( + where.id === "ou_1" + ? { + id: "ou_1", + name: "Delivery", + shortName: "DEL", + level: 5, + parentId: null, + sortOrder: 1, + isActive: true, + } + : null + )), + create: vi.fn().mockResolvedValue({ + id: "ou_1", + name: "Delivery", + shortName: "DEL", + level: 5, + parentId: null, + sortOrder: 1, + isActive: true, + }), + update: vi.fn().mockResolvedValue({ + id: "ou_1", + name: "Delivery Europe", + shortName: "DEU", + level: 5, + parentId: null, + sortOrder: 2, + isActive: true, + }), + }, + auditLog: { + create: vi.fn().mockResolvedValue({ id: "audit_1" }), + }, + demandRequirement: { + groupBy: vi.fn().mockResolvedValue([]), + }, + assignment: { + groupBy: vi.fn().mockResolvedValue([]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.ADMIN }); + + const createRoleResult = await executeTool( + "create_role", + JSON.stringify({ name: "CG Artist", color: "#111111" }), + ctx, + ); + const updateRoleResult = await executeTool( + "update_role", + JSON.stringify({ id: "role_1", name: "Senior CG Artist", description: "Pipeline lead", color: "#222222" }), + ctx, + ); + const deleteRoleResult = await executeTool( + "delete_role", + JSON.stringify({ id: "role_1" }), + ctx, + ); + const createClientResult = await executeTool( + "create_client", + JSON.stringify({ name: "Acme", code: "AC", sortOrder: 2, tags: ["auto"] }), + ctx, + ); + const updateClientResult = await executeTool( + "update_client", + JSON.stringify({ id: "client_1", name: "Acme Mobility", code: "ACM", sortOrder: 3, tags: ["auto", "priority"] }), + ctx, + ); + const createOrgUnitResult = await executeTool( + "create_org_unit", + JSON.stringify({ name: "Delivery", shortName: "DEL", level: 5, sortOrder: 1 }), + ctx, + ); + const updateOrgUnitResult = await executeTool( + "update_org_unit", + JSON.stringify({ id: "ou_1", name: "Delivery Europe", shortName: "DEU", sortOrder: 2 }), + ctx, + ); + + expect(JSON.parse(createRoleResult.content)).toEqual(expect.objectContaining({ + success: true, + message: "Created role: CG Artist", + })); + expect(JSON.parse(updateRoleResult.content)).toEqual(expect.objectContaining({ + success: true, + message: "Updated role: Senior CG Artist", + })); + expect(JSON.parse(deleteRoleResult.content)).toEqual(expect.objectContaining({ + success: true, + message: "Deleted role: Senior CG Artist", + })); + expect(JSON.parse(createClientResult.content)).toEqual(expect.objectContaining({ + success: true, + message: "Created client: Acme", + })); + expect(JSON.parse(updateClientResult.content)).toEqual(expect.objectContaining({ + success: true, + message: "Updated client: Acme Mobility", + })); + expect(JSON.parse(createOrgUnitResult.content)).toEqual(expect.objectContaining({ + success: true, + message: "Created org unit: Delivery", + })); + expect(JSON.parse(updateOrgUnitResult.content)).toEqual(expect.objectContaining({ + success: true, + message: "Updated org unit: Delivery Europe", + })); + }); + + it("routes resource creation through the real resource router path and writes an audit log", async () => { + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const resourceFindFirst = vi.fn().mockResolvedValue(null); + const resourceCreate = vi.fn().mockResolvedValue({ + id: "res_1", + eid: "EMP-001", + displayName: "Carol Danvers", + email: "carol@example.com", + resourceRoles: [], + }); + const ctx = createToolContext( + { + resource: { + findFirst: resourceFindFirst, + create: resourceCreate, + }, + role: { + findUnique: vi.fn().mockResolvedValue({ id: "role_1", name: "Designer", _count: { resourceRoles: 0 } }), + findFirst: vi.fn().mockResolvedValue(null), + }, + country: { + findUnique: vi.fn().mockResolvedValue({ id: "country_de", code: "DE", name: "Germany", metroCities: [], _count: { resources: 0 } }), + findFirst: vi.fn().mockResolvedValue(null), + }, + orgUnit: { + findUnique: vi.fn().mockResolvedValue({ id: "ou_1", name: "Delivery", shortName: "DEL", _count: { resources: 0 } }), + findFirst: vi.fn().mockResolvedValue(null), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([]), + }, + auditLog: { + create: auditCreate, + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_RESOURCES], + }, + ); + + const result = await executeTool( + "create_resource", + JSON.stringify({ + eid: "EMP-001", + displayName: "Carol Danvers", + email: "carol@example.com", + lcrCents: 8000, + roleName: "Designer", + countryCode: "DE", + orgUnitName: "Delivery", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + success: true, + message: "Created resource: Carol Danvers (EMP-001)", + })); + expect(resourceFindFirst).toHaveBeenCalled(); + expect(resourceCreate).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + roleId: "role_1", + countryId: "country_de", + orgUnitId: "ou_1", + }), + })); + expect(auditCreate).toHaveBeenCalledTimes(1); + }); + + it("routes project creation through the real project, blueprint, and client router paths", async () => { + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const projectCreate = vi.fn().mockResolvedValue({ + id: "project_1", + shortCode: "PROJ-1", + name: "Project One", + status: "DRAFT", + }); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue({ + displayName: "Peter Parker", + }), + findMany: vi.fn().mockResolvedValue([]), + }, + project: { + findUnique: vi.fn().mockResolvedValue(null), + create: projectCreate, + }, + blueprint: { + findUnique: vi.fn().mockResolvedValue({ id: "bp_1", name: "Consulting Blueprint", target: "PROJECT", fieldDefs: [] }), + findFirst: vi.fn().mockResolvedValue(null), + }, + client: { + findUnique: vi.fn().mockResolvedValue({ id: "client_1", name: "Acme", code: "ACME", _count: { projects: 0, children: 0 } }), + findFirst: vi.fn().mockResolvedValue(null), + }, + webhook: { + findMany: vi.fn().mockResolvedValue([]), + }, + auditLog: { + create: auditCreate, + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "create_project", + JSON.stringify({ + shortCode: "PROJ-1", + name: "Project One", + orderType: "CHARGEABLE", + budgetCents: 150000, + startDate: "2026-05-01", + endDate: "2026-06-30", + responsiblePerson: "Peter Parker", + blueprintName: "Consulting Blueprint", + clientName: "ACME", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + success: true, + message: expect.stringContaining("Created project: Project One (PROJ-1), budget "), + projectId: "project_1", + })); + expect(projectCreate).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + shortCode: "PROJ-1", + blueprintId: "bp_1", + clientId: "client_1", + responsiblePerson: "Peter Parker", + }), + })); + expect(auditCreate).toHaveBeenCalledTimes(1); + }); + + it("returns a stable assistant error when the blueprint cannot be resolved", async () => { + const projectCreate = vi.fn(); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue({ + displayName: "Peter Parker", + }), + findMany: vi.fn().mockResolvedValue([]), + }, + project: { + findUnique: vi.fn().mockResolvedValue(null), + create: projectCreate, + }, + blueprint: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + }, + client: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + }, + auditLog: { + create: vi.fn(), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "create_project", + JSON.stringify({ + shortCode: "PROJ-404", + name: "Missing Blueprint Project", + orderType: "CHARGEABLE", + budgetCents: 150000, + startDate: "2026-05-01", + endDate: "2026-06-30", + responsiblePerson: "Peter Parker", + blueprintName: "Missing Blueprint", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: 'Blueprint not found: "Missing Blueprint"', + }); + expect(projectCreate).not.toHaveBeenCalled(); + }); + + it("routes resource updates through the real resource router path and writes an audit log", async () => { + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const resourceFindUnique = vi.fn() + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Carol Danvers", + }) + .mockResolvedValueOnce({ + id: "res_1", + eid: "EMP-001", + displayName: "Carol Danvers", + dynamicFields: {}, + blueprintId: null, + }); + const resourceUpdate = vi.fn().mockResolvedValue({ + id: "res_1", + eid: "EMP-001", + displayName: "Captain Marvel", + resourceRoles: [], + }); + const ctx = createToolContext( + { + resource: { + findUnique: resourceFindUnique, + update: resourceUpdate, + }, + auditLog: { + create: auditCreate, + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_RESOURCES], + }, + ); + + const result = await executeTool( + "update_resource", + JSON.stringify({ + id: "res_1", + displayName: "Captain Marvel", + chapter: "Delivery", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + success: true, + message: "Updated resource Captain Marvel (EMP-001)", + updatedFields: ["displayName", "chapter"], + })); + expect(resourceUpdate).toHaveBeenCalled(); + expect(auditCreate).toHaveBeenCalledTimes(1); + }); + + it("returns a stable assistant error when the role cannot be resolved during resource creation", async () => { + const resourceCreate = vi.fn(); + const ctx = createToolContext( + { + resource: { + findFirst: vi.fn().mockResolvedValue(null), + create: resourceCreate, + }, + role: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_RESOURCES], + }, + ); + + const result = await executeTool( + "create_resource", + JSON.stringify({ + eid: "EMP-404", + displayName: "Role Missing", + email: "missing@example.com", + lcrCents: 8000, + roleName: "Missing Role", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: 'Role not found: "Missing Role"', + }); + expect(resourceCreate).not.toHaveBeenCalled(); + }); + + it("routes project updates through the real project router path and resolves short codes before updating", async () => { + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const projectFindUnique = vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "project_1", + shortCode: "PROJ-1", + name: "Project One", + status: "DRAFT", + responsiblePerson: "Peter Parker", + }) + .mockResolvedValueOnce({ + id: "project_1", + shortCode: "PROJ-1", + name: "Project One", + status: "DRAFT", + responsiblePerson: "Peter Parker", + dynamicFields: {}, + blueprintId: null, + }); + const projectUpdate = vi.fn().mockResolvedValue({ + id: "project_1", + shortCode: "PROJ-1", + name: "Project One Reloaded", + }); + const ctx = createToolContext( + { + project: { + findUnique: projectFindUnique, + findFirst: vi.fn().mockResolvedValue(null), + update: projectUpdate, + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + resource: { + findFirst: vi.fn().mockResolvedValue({ + displayName: "Peter Parker", + }), + findMany: vi.fn().mockResolvedValue([]), + }, + auditLog: { + create: auditCreate, + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "update_project", + JSON.stringify({ + id: "PROJ-1", + name: "Project One Reloaded", + responsiblePerson: "Peter Parker", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + success: true, + message: "Updated project Project One Reloaded (PROJ-1)", + updatedFields: ["name", "responsiblePerson"], + })); + expect(projectUpdate).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: "project_1" }, + data: expect.objectContaining({ + name: "Project One Reloaded", + responsiblePerson: "Peter Parker", + }), + })); + expect(auditCreate).toHaveBeenCalledTimes(1); + }); + + it("enforces admin-only project deletion through the real project router path", async () => { + const ctx = createToolContext( + { + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "project_1", + shortCode: "PROJ-1", + name: "Project One", + status: "ACTIVE", + responsiblePerson: "Peter Parker", + }), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + userRole: SystemRole.MANAGER, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "delete_project", + JSON.stringify({ projectId: "project_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: expect.stringContaining("Admin role required"), + })); + }); + + it("routes project deletion through the real project router path for admins", async () => { + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const tx = { + assignment: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, + demandRequirement: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, + calculationRule: { updateMany: vi.fn().mockResolvedValue({ count: 0 }) }, + project: { delete: vi.fn().mockResolvedValue({ id: "project_1" }) }, + auditLog: { create: auditCreate }, + }; + const transaction = vi.fn().mockImplementation(async (callback: (inner: typeof tx) => Promise) => callback(tx)); + const projectFindUnique = vi.fn() + .mockResolvedValueOnce({ + id: "project_1", + shortCode: "PROJ-1", + name: "Project One", + status: "ACTIVE", + responsiblePerson: "Peter Parker", + }) + .mockResolvedValueOnce({ + id: "project_1", + shortCode: "PROJ-1", + name: "Project One", + }); + const ctx = createToolContext( + { + project: { + findUnique: projectFindUnique, + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + $transaction: transaction, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "delete_project", + JSON.stringify({ projectId: "project_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + success: true, + message: "Deleted project: Project One (PROJ-1)", + })); + expect(transaction).toHaveBeenCalledTimes(1); + expect(auditCreate).toHaveBeenCalledTimes(1); + }); + + it("routes demand creation through the real allocation router path and writes an audit log", async () => { + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const notificationCreate = vi.fn().mockResolvedValue({ id: "task_1" }); + const demandCreate = vi.fn().mockResolvedValue({ + id: "demand_1", + projectId: "project_1", + roleId: "role_1", + role: null, + headcount: 2, + hoursPerDay: 6, + percentage: 75, + budgetCents: 0, + status: "PROPOSED", + metadata: {}, + startDate: new Date("2026-05-01T00:00:00.000Z"), + endDate: new Date("2026-05-15T00:00:00.000Z"), + project: { id: "project_1", name: "Project One", shortCode: "PROJ-1" }, + roleEntity: { id: "role_1", name: "Designer", color: "#00AAFF" }, + }); + const tx = { + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + budgetCents: 0, + }), + }, + demandRequirement: { create: demandCreate }, + auditLog: { create: auditCreate }, + }; + const transaction = vi.fn().mockImplementation(async (callback: (inner: typeof tx) => Promise) => callback(tx)); + const projectFindFirst = vi.fn().mockResolvedValue({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + }); + const projectFindUnique = vi.fn().mockResolvedValue({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + budgetCents: 0, + }); + const roleFindFirst = vi.fn().mockResolvedValue({ id: "role_1", name: "Designer" }); + const roleFindUnique = vi.fn().mockResolvedValue({ id: "role_1", name: "Designer" }); + const ctx = createToolContext( + { + project: { + findFirst: projectFindFirst, + findUnique: projectFindUnique, + }, + role: { + findFirst: roleFindFirst, + findUnique: roleFindUnique, + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([]), + }, + user: { + findMany: vi.fn().mockResolvedValue([{ id: "manager_1" }]), + }, + notification: { + findFirst: vi.fn().mockResolvedValue(null), + create: notificationCreate, + }, + $transaction: transaction, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "create_demand", + JSON.stringify({ + projectId: "PROJ-1", + roleName: "Designer", + headcount: 2, + hoursPerDay: 6, + startDate: "2026-05-01", + endDate: "2026-05-15", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + success: true, + message: "Created demand: Designer × 2 for Project One (PROJ-1), 6h/day, 2026-05-01 to 2026-05-15", + demandId: "demand_1", + })); + expect(demandCreate).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + projectId: "project_1", + roleId: "role_1", + headcount: 2, + hoursPerDay: 6, + percentage: 75, + }), + })); + expect(auditCreate).toHaveBeenCalledTimes(1); + expect(notificationCreate).toHaveBeenCalledTimes(1); + }); + + it("routes demand filling through the real allocation router path", async () => { + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const assignmentCreate = vi.fn().mockResolvedValue({ + 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: "PROPOSED", + metadata: {}, + createdAt: new Date("2026-03-29T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + resource: { id: "resource_1", displayName: "Carol Danvers", eid: "EMP-001", lcrCents: 7000 }, + project: { id: "project_1", name: "Project One", shortCode: "PROJ-1" }, + roleEntity: { id: "role_1", name: "Designer", color: "#00AAFF" }, + demandRequirement: { + 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: "PROPOSED", + }, + }); + const txDemandFindUnique = vi.fn().mockResolvedValue({ + id: "demand_1", + projectId: "project_1", + }); + const txDemandUpdate = vi.fn().mockResolvedValue({ + id: "demand_1", + projectId: "project_1", + headcount: 1, + status: "COMPLETED", + }); + const tx = { + 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: txDemandFindUnique, + update: txDemandUpdate, + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + create: assignmentCreate, + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + auditLog: { + create: auditCreate, + }, + }; + const transaction = vi.fn().mockImplementation(async (callback: (inner: typeof tx) => Promise) => callback(tx)); + const demandRecord = { + 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: "PROPOSED", + metadata: {}, + roleEntity: { name: "Designer" }, + project: { id: "project_1", name: "Project One", shortCode: "PROJ-1" }, + }; + const demandFindUnique = vi.fn() + .mockResolvedValueOnce(demandRecord) + .mockResolvedValueOnce(demandRecord); + const resourceFindUnique = vi.fn().mockResolvedValue({ + id: "resource_1", + displayName: "Carol Danvers", + lcrCents: 7000, + availability: { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + saturday: 0, + sunday: 0, + }, + }); + const ctx = createToolContext( + { + demandRequirement: { + findUnique: demandFindUnique, + }, + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + budgetCents: 100000, + }), + }, + user: { + findMany: vi.fn().mockResolvedValue([]), + }, + notification: { + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn(), + }, + resource: { + findUnique: resourceFindUnique, + findFirst: vi.fn().mockResolvedValue(null), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + $transaction: transaction, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "fill_demand", + JSON.stringify({ demandId: "demand_1", resourceId: "resource_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + success: true, + message: "Assigned Carol Danvers to Designer on Project One (PROJ-1)", + assignmentId: "assignment_1", + })); + expect(assignmentCreate).toHaveBeenCalledTimes(1); + expect(txDemandUpdate).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: "demand_1" }, + data: { status: "COMPLETED" }, + })); + expect(auditCreate).toHaveBeenCalledTimes(1); + }); + + it("returns a stable assistant error when the role cannot be resolved during demand creation", async () => { + const ctx = createToolContext( + { + project: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + status: "ACTIVE", + responsiblePerson: "Peter Parker", + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + role: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "create_demand", + JSON.stringify({ + projectId: "PROJ-1", + roleName: "Missing Role", + headcount: 2, + hoursPerDay: 6, + startDate: "2026-05-01", + endDate: "2026-05-15", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Role not found: Missing Role", + }); + }); + + it("routes allocation creation through the real allocation router path", async () => { + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const assignmentCreate = vi.fn().mockResolvedValue({ + id: "assignment_1", + demandRequirementId: null, + resourceId: "resource_1", + projectId: "project_1", + startDate: new Date("2026-06-01T00:00:00.000Z"), + endDate: new Date("2026-06-05T00:00:00.000Z"), + hoursPerDay: 6, + percentage: 75, + role: "Designer", + roleId: null, + dailyCostCents: 42000, + status: "PROPOSED", + metadata: {}, + createdAt: new Date("2026-03-29T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + resource: { id: "resource_1", displayName: "Carol Danvers", eid: "EMP-001", lcrCents: 7000 }, + project: { id: "project_1", name: "Project One", shortCode: "PROJ-1" }, + roleEntity: null, + demandRequirement: null, + }); + const tx = { + 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, + }, + }), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + create: assignmentCreate, + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + auditLog: { + create: auditCreate, + }, + }; + const transaction = vi.fn().mockImplementation(async (callback: (inner: typeof tx) => Promise) => callback(tx)); + const ctx = createToolContext( + { + resource: { + findUnique: vi.fn() + .mockResolvedValue({ + id: "resource_1", + displayName: "Carol Danvers", + eid: "EMP-001", + lcrCents: 7000, + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + status: "ACTIVE", + responsiblePerson: "Peter Parker", + budgetCents: 0, + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + findUnique: vi.fn().mockResolvedValue(null), + }, + $transaction: transaction, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "create_allocation", + JSON.stringify({ + resourceId: "resource_1", + projectId: "project_1", + startDate: "2026-06-01", + endDate: "2026-06-05", + hoursPerDay: 6, + role: "Designer", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + success: true, + message: "Created allocation: Carol Danvers → Project One (PROJ-1), 6h/day, 2026-06-01 to 2026-06-05", + allocationId: "assignment_1", + status: "PROPOSED", + })); + expect(ctx.db.resource.findUnique).toHaveBeenCalledWith({ + where: { id: "resource_1" }, + select: { + id: true, + displayName: true, + eid: true, + chapter: true, + isActive: true, + }, + }); + expect(ctx.db.project.findUnique).toHaveBeenCalledWith({ + where: { id: "project_1" }, + select: expect.objectContaining({ + id: true, + shortCode: true, + name: true, + }), + }); + expect(ctx.db.assignment.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { + resourceId: "resource_1", + projectId: "project_1", + }, + orderBy: { startDate: "asc" }, + include: expect.objectContaining({ + resource: { + select: expect.objectContaining({ + id: true, + displayName: true, + eid: true, + chapter: true, + lcrCents: true, + }), + }, + project: { + select: expect.objectContaining({ + id: true, + name: true, + shortCode: true, + status: true, + }), + }, + }), + })); + expect(assignmentCreate).toHaveBeenCalledTimes(1); + expect(auditCreate).toHaveBeenCalledTimes(1); + }); + + it("routes allocation cancellation through the real allocation router path", async () => { + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const assignmentUpdate = vi.fn().mockResolvedValue({ + id: "assignment_1", + resourceId: "resource_1", + projectId: "project_1", + demandRequirementId: null, + startDate: new Date("2026-06-01T00:00:00.000Z"), + endDate: new Date("2026-06-05T00:00:00.000Z"), + hoursPerDay: 6, + percentage: 75, + role: "Designer", + roleId: null, + dailyCostCents: 42000, + status: "CANCELLED", + metadata: {}, + createdAt: new Date("2026-03-29T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + resource: { id: "resource_1", displayName: "Carol Danvers", eid: "EMP-001", lcrCents: 7000 }, + project: { id: "project_1", name: "Project One", shortCode: "PROJ-1" }, + roleEntity: null, + demandRequirement: null, + }); + const tx = { + assignment: { + findUnique: vi.fn().mockResolvedValue({ + id: "assignment_1", + resourceId: "resource_1", + projectId: "project_1", + demandRequirementId: null, + startDate: new Date("2026-06-01T00:00:00.000Z"), + endDate: new Date("2026-06-05T00:00:00.000Z"), + hoursPerDay: 6, + percentage: 75, + role: "Designer", + roleId: null, + dailyCostCents: 42000, + status: "PROPOSED", + metadata: {}, + createdAt: new Date("2026-03-29T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + resource: { id: "resource_1", displayName: "Carol Danvers", eid: "EMP-001", lcrCents: 7000 }, + project: { id: "project_1", name: "Project One", shortCode: "PROJ-1" }, + roleEntity: null, + demandRequirement: null, + }), + update: assignmentUpdate, + }, + auditLog: { + create: auditCreate, + }, + }; + const transaction = vi.fn().mockImplementation(async (callback: (inner: typeof tx) => Promise) => callback(tx)); + const ctx = createToolContext( + { + assignment: { + findUnique: vi.fn().mockResolvedValue({ + id: "assignment_1", + resourceId: "resource_1", + projectId: "project_1", + demandRequirementId: null, + startDate: new Date("2026-06-01T00:00:00.000Z"), + endDate: new Date("2026-06-05T00:00:00.000Z"), + hoursPerDay: 6, + percentage: 75, + role: "Designer", + roleId: null, + dailyCostCents: 42000, + status: "PROPOSED", + metadata: {}, + createdAt: new Date("2026-03-29T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + resource: { id: "resource_1", displayName: "Carol Danvers", eid: "EMP-001", lcrCents: 7000 }, + project: { id: "project_1", name: "Project One", shortCode: "PROJ-1" }, + roleEntity: null, + demandRequirement: null, + }), + }, + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + budgetCents: 0, + }), + }, + webhook: { + findMany: vi.fn().mockResolvedValue([]), + }, + $transaction: transaction, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "cancel_allocation", + JSON.stringify({ allocationId: "assignment_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + success: true, + message: "Cancelled allocation: Carol Danvers → Project One (PROJ-1), 2026-06-01 to 2026-06-05", + })); + expect(ctx.db.assignment.findUnique).toHaveBeenCalledWith({ + where: { id: "assignment_1" }, + include: expect.objectContaining({ + resource: expect.any(Object), + project: expect.any(Object), + }), + }); + expect(assignmentUpdate).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: "assignment_1" }, + data: expect.objectContaining({ + status: "CANCELLED", + }), + })); + expect(auditCreate).toHaveBeenCalledTimes(1); + }); + + it("returns a stable assistant error for duplicate allocations", async () => { + const ctx = createToolContext( + { + resource: { + findUnique: vi.fn() + .mockResolvedValueOnce({ + id: "resource_1", + eid: "EMP-001", + displayName: "Carol Danvers", + }) + .mockResolvedValueOnce({ + id: "resource_1", + eid: "EMP-001", + displayName: "Carol Danvers", + lcrCents: 7000, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + project: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + status: "ACTIVE", + responsiblePerson: "Peter Parker", + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([ + { + id: "assignment_existing", + resourceId: "resource_1", + projectId: "project_1", + startDate: new Date("2026-06-01T00:00:00.000Z"), + endDate: new Date("2026-06-05T00:00:00.000Z"), + status: "PROPOSED", + resource: { id: "resource_1", displayName: "Carol Danvers", eid: "EMP-001" }, + project: { id: "project_1", name: "Project One", shortCode: "PROJ-1" }, + }, + ]), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "create_allocation", + JSON.stringify({ + resourceId: "resource_1", + projectId: "project_1", + startDate: "2026-06-01", + endDate: "2026-06-05", + hoursPerDay: 6, + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Allocation already exists for this resource/project/dates. No new allocation created.", + }); + }); + + it("routes allocation status updates through the real allocation router path", async () => { + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const assignmentUpdate = vi.fn().mockResolvedValue({ + id: "assignment_1", + resourceId: "resource_1", + projectId: "project_1", + demandRequirementId: null, + startDate: new Date("2026-06-01T00:00:00.000Z"), + endDate: new Date("2026-06-05T00:00:00.000Z"), + hoursPerDay: 6, + percentage: 75, + role: "Designer", + roleId: null, + dailyCostCents: 42000, + status: "ACTIVE", + metadata: {}, + createdAt: new Date("2026-03-29T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + resource: { id: "resource_1", displayName: "Carol Danvers", eid: "EMP-001", lcrCents: 7000 }, + project: { id: "project_1", name: "Project One", shortCode: "PROJ-1" }, + roleEntity: null, + demandRequirement: null, + }); + const tx = { + assignment: { + findUnique: vi.fn().mockResolvedValue({ + id: "assignment_1", + resourceId: "resource_1", + projectId: "project_1", + demandRequirementId: null, + startDate: new Date("2026-06-01T00:00:00.000Z"), + endDate: new Date("2026-06-05T00:00:00.000Z"), + hoursPerDay: 6, + percentage: 75, + role: "Designer", + roleId: null, + dailyCostCents: 42000, + status: "PROPOSED", + metadata: {}, + createdAt: new Date("2026-03-29T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + resource: { id: "resource_1", displayName: "Carol Danvers", eid: "EMP-001", lcrCents: 7000 }, + project: { id: "project_1", name: "Project One", shortCode: "PROJ-1" }, + roleEntity: null, + demandRequirement: null, + }), + update: assignmentUpdate, + }, + auditLog: { + create: auditCreate, + }, + }; + const transaction = vi.fn().mockImplementation(async (callback: (inner: typeof tx) => Promise) => callback(tx)); + const ctx = createToolContext( + { + assignment: { + findUnique: vi.fn().mockResolvedValue({ + id: "assignment_1", + resourceId: "resource_1", + projectId: "project_1", + demandRequirementId: null, + startDate: new Date("2026-06-01T00:00:00.000Z"), + endDate: new Date("2026-06-05T00:00:00.000Z"), + hoursPerDay: 6, + percentage: 75, + role: "Designer", + roleId: null, + dailyCostCents: 42000, + status: "PROPOSED", + metadata: {}, + createdAt: new Date("2026-03-29T00:00:00.000Z"), + updatedAt: new Date("2026-03-29T00:00:00.000Z"), + resource: { id: "resource_1", displayName: "Carol Danvers", eid: "EMP-001", lcrCents: 7000 }, + project: { id: "project_1", name: "Project One", shortCode: "PROJ-1" }, + roleEntity: null, + demandRequirement: null, + }), + }, + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + budgetCents: 0, + }), + }, + webhook: { + findMany: vi.fn().mockResolvedValue([]), + }, + $transaction: transaction, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_ALLOCATIONS], + }, + ); + + const result = await executeTool( + "update_allocation_status", + JSON.stringify({ allocationId: "assignment_1", newStatus: "ACTIVE" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + success: true, + message: "Updated allocation status: Carol Danvers → Project One (PROJ-1), 2026-06-01 to 2026-06-05: PROPOSED → ACTIVE", + })); + expect(ctx.db.assignment.findUnique).toHaveBeenCalledWith({ + where: { id: "assignment_1" }, + include: expect.objectContaining({ + resource: expect.any(Object), + project: expect.any(Object), + }), + }); + expect(assignmentUpdate).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: "assignment_1" }, + data: expect.objectContaining({ + status: "ACTIVE", + }), + })); + expect(auditCreate).toHaveBeenCalledTimes(1); + }); + + it("routes project cover generation through the real project router path", async () => { + const projectFindUnique = vi.fn() + .mockResolvedValueOnce({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + status: "ACTIVE", + orderType: "CHARGEABLE", + allocationType: "INT", + budgetCents: 0, + winProbability: 100, + startDate: new Date("2026-05-01T00:00:00.000Z"), + endDate: new Date("2026-06-30T00:00:00.000Z"), + responsiblePerson: "Peter Parker", + client: null, + utilizationCategory: null, + _count: { assignments: 0, estimates: 0 }, + }) + .mockResolvedValueOnce({ + id: "project_1", + name: "Project One", + client: { name: "Wayne Enterprises" }, + }); + const projectUpdate = vi.fn().mockResolvedValue({ + id: "project_1", + coverImageUrl: "data:image/png;base64,ZmFrZQ==", + }); + const ctx = createToolContext( + { + project: { + findUnique: projectFindUnique, + update: projectUpdate, + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + id: "singleton", + imageProvider: "dalle", + aiProvider: "openai", + azureOpenAiApiKey: "sk-test", + azureOpenAiDeployment: "gpt-4o-mini", + }), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "generate_project_cover", + JSON.stringify({ projectId: "project_1", prompt: "Blue studio lighting" }), + ctx, + ); + + expect(projectUpdate).toHaveBeenCalledWith({ + where: { id: "project_1" }, + data: { coverImageUrl: "data:image/png;base64,ZmFrZQ==" }, + }); + expect(projectFindUnique).toHaveBeenCalledWith({ + where: { id: "project_1" }, + select: expect.objectContaining({ + id: true, + shortCode: true, + name: true, + status: true, + responsiblePerson: true, + startDate: true, + endDate: true, + }), + }); + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + success: true, + message: 'Generated cover art for project "Project One"', + })); + }); + + it("routes project cover removal through the real project router path", async () => { + const projectFindUnique = vi.fn() + .mockResolvedValueOnce({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + status: "ACTIVE", + orderType: "CHARGEABLE", + allocationType: "INT", + budgetCents: 0, + winProbability: 100, + startDate: new Date("2026-05-01T00:00:00.000Z"), + endDate: new Date("2026-06-30T00:00:00.000Z"), + responsiblePerson: "Peter Parker", + client: null, + utilizationCategory: null, + _count: { assignments: 0, estimates: 0 }, + }) + .mockResolvedValueOnce({ + id: "project_1", + name: "Project One", + }); + const projectUpdate = vi.fn().mockResolvedValue({ + id: "project_1", + coverImageUrl: null, + }); + const ctx = createToolContext( + { + project: { + findUnique: projectFindUnique, + update: projectUpdate, + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + userRole: SystemRole.ADMIN, + permissions: [PermissionKey.MANAGE_PROJECTS], + }, + ); + + const result = await executeTool( + "remove_project_cover", + JSON.stringify({ projectId: "project_1" }), + ctx, + ); + + expect(projectUpdate).toHaveBeenCalledWith({ + where: { id: "project_1" }, + data: { coverImageUrl: null }, + }); + expect(projectFindUnique).toHaveBeenCalledWith({ + where: { id: "project_1" }, + select: expect.objectContaining({ + id: true, + shortCode: true, + name: true, + status: true, + responsiblePerson: true, + startDate: true, + endDate: true, + }), + }); + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + success: true, + message: 'Removed cover art from project "Project One"', + })); + }); + + it("routes project narrative generation through the real insights router path", async () => { + const projectFindUnique = vi.fn().mockResolvedValue({ + id: "project_1", + name: "Project One", + shortCode: "PROJ-1", + status: "ACTIVE", + startDate: new Date("2026-03-01T00:00:00.000Z"), + endDate: new Date("2026-06-30T00:00:00.000Z"), + budgetCents: 250_000_00, + dynamicFields: { existingFlag: true }, + demandRequirements: [ + { + headcount: 2, + _count: { assignments: 1 }, + }, + ], + assignments: [ + { + status: "ACTIVE", + dailyCostCents: 42_000, + startDate: new Date("2026-03-10T00:00:00.000Z"), + endDate: new Date("2026-03-20T00:00:00.000Z"), + resource: { displayName: "Carol Danvers" }, + }, + ], + }); + const projectUpdate = vi.fn().mockResolvedValue({ + id: "project_1", + dynamicFields: { + existingFlag: true, + aiNarrative: "Project is on track overall, but staffing remains partially open.", + }, + }); + const ctx = createToolContext( + { + project: { + findUnique: projectFindUnique, + update: projectUpdate, + }, + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + id: "singleton", + aiProvider: "openai", + azureOpenAiApiKey: "sk-test", + azureOpenAiDeployment: "gpt-4o-mini", + }), + }, + }, + { + userRole: SystemRole.CONTROLLER, + }, + ); + + const result = await executeTool( + "generate_project_narrative", + JSON.stringify({ projectId: "project_1" }), + ctx, + ); + + expect(projectFindUnique).toHaveBeenCalledWith({ + where: { id: "project_1" }, + include: { + demandRequirements: { + select: { + id: true, + role: true, + headcount: true, + hoursPerDay: true, + startDate: true, + endDate: true, + status: true, + _count: { select: { assignments: true } }, + }, + }, + assignments: { + select: { + id: true, + role: true, + hoursPerDay: true, + startDate: true, + endDate: true, + status: true, + dailyCostCents: true, + resource: { select: { displayName: true } }, + }, + }, + }, + }); + expect(projectUpdate).toHaveBeenCalledWith({ + where: { id: "project_1" }, + data: { + dynamicFields: expect.objectContaining({ + existingFlag: true, + aiNarrative: "Project is on track overall, but staffing remains partially open.", + aiNarrativeGeneratedAt: expect.any(String), + }), + }, + }); + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + narrative: "Project is on track overall, but staffing remains partially open.", + generatedAt: expect.any(String), + })); + }); }); diff --git a/packages/api/src/__tests__/audit-log-router.test.ts b/packages/api/src/__tests__/audit-log-router.test.ts new file mode 100644 index 0000000..0e8f9f8 --- /dev/null +++ b/packages/api/src/__tests__/audit-log-router.test.ts @@ -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) { + 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", + }, + }, + ], + }); + }); +}); diff --git a/packages/api/src/__tests__/chargeability-report-router.test.ts b/packages/api/src/__tests__/chargeability-report-router.test.ts index ba1e31d..7e28888 100644 --- a/packages/api/src/__tests__/chargeability-report-router.test.ts +++ b/packages/api/src/__tests__/chargeability-report-router.test.ts @@ -425,4 +425,111 @@ describe("chargeability report router", () => { expect(month).toBeDefined(); 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), + }), + ], + }), + ]); + }); }); diff --git a/packages/api/src/__tests__/computation-graph-router.test.ts b/packages/api/src/__tests__/computation-graph-router.test.ts index 3e9ee0f..92032ec 100644 --- a/packages/api/src/__tests__/computation-graph-router.test.ts +++ b/packages/api/src/__tests__/computation-graph-router.test.ts @@ -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) { return createCaller({ session: { @@ -99,6 +125,47 @@ function buildResource(overrides: Record = {}) { }; } +function createProjectDb(projectFindImpl: ReturnType) { + 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 = {}) { + 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", () => { beforeEach(() => { vi.clearAllMocks(); @@ -192,4 +259,60 @@ describe("computation graph router", () => { 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); + }); }); diff --git a/packages/api/src/__tests__/dashboard-router.test.ts b/packages/api/src/__tests__/dashboard-router.test.ts index e50f90f..48ef13d 100644 --- a/packages/api/src/__tests__/dashboard-router.test.ts +++ b/packages/api/src/__tests__/dashboard-router.test.ts @@ -11,6 +11,7 @@ vi.mock("@capakraken/application", async (importOriginal) => { getDashboardTopValueResources: vi.fn(), getDashboardChargeabilityOverview: vi.fn(), getDashboardBudgetForecast: vi.fn(), + getDashboardProjectHealth: vi.fn(), }; }); @@ -31,6 +32,7 @@ import { getDashboardTopValueResources, getDashboardChargeabilityOverview, getDashboardBudgetForecast, + getDashboardProjectHealth, } from "@capakraken/application"; import { dashboardRouter } from "../router/dashboard.js"; import { createCallerFactory } from "../trpc.js"; @@ -97,7 +99,7 @@ describe("dashboard router", () => { vi.mocked(getDashboardOverview).mockResolvedValue(overview); - const caller = createProtectedCaller({}); + const caller = createControllerCaller({}); const result = await caller.getOverview(); 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 ───────────────────────────────────────────────────────── describe("getPeakTimes", () => { @@ -126,7 +194,7 @@ describe("dashboard router", () => { vi.mocked(getDashboardPeakTimes).mockResolvedValue(peakData); - const caller = createProtectedCaller({}); + const caller = createControllerCaller({}); const result = await caller.getPeakTimes({ startDate: "2026-03-01T00: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 () => { vi.mocked(getDashboardPeakTimes).mockResolvedValue([]); - const caller = createProtectedCaller({}); + const caller = createControllerCaller({}); await caller.getPeakTimes({ startDate: "2026-03-01T00:00:00.000Z", endDate: "2026-03-31T00:00:00.000Z", @@ -177,7 +245,7 @@ describe("dashboard router", () => { vi.mocked(getDashboardDemand).mockResolvedValue(demandData); - const caller = createProtectedCaller({}); + const caller = createControllerCaller({}); const result = await caller.getDemand({ startDate: "2026-01-01T00:00:00.000Z", endDate: "2026-12-31T00:00:00.000Z", @@ -194,7 +262,7 @@ describe("dashboard router", () => { it("supports grouping by chapter", async () => { vi.mocked(getDashboardDemand).mockResolvedValue([]); - const caller = createProtectedCaller({}); + const caller = createControllerCaller({}); await caller.getDemand({ startDate: "2026-06-01T00: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 ───────────────────────────────────────────────── describe("getTopValueResources", () => { @@ -219,7 +354,7 @@ describe("dashboard router", () => { vi.mocked(getDashboardTopValueResources).mockResolvedValue(resources); - const caller = createProtectedCaller({}); + const caller = createControllerCaller({}); const result = await caller.getTopValueResources({ limit: 10 }); expect(result).toHaveLength(2); @@ -232,7 +367,7 @@ describe("dashboard router", () => { it("respects custom limit", async () => { vi.mocked(getDashboardTopValueResources).mockResolvedValue([]); - const caller = createProtectedCaller({}); + const caller = createControllerCaller({}); await caller.getTopValueResources({ limit: 5 }); expect(getDashboardTopValueResources).toHaveBeenCalledWith( @@ -334,7 +469,7 @@ describe("dashboard router", () => { }, ]); - const caller = createProtectedCaller({}); + const caller = createControllerCaller({}); const result = await caller.getBudgetForecast(); expect(result).toHaveLength(1); @@ -351,5 +486,177 @@ describe("dashboard router", () => { }); 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", + }), + ); + }); }); }); diff --git a/packages/api/src/__tests__/entitlement-router.test.ts b/packages/api/src/__tests__/entitlement-router.test.ts index d97b482..fba64ca 100644 --- a/packages/api/src/__tests__/entitlement-router.test.ts +++ b/packages/api/src/__tests__/entitlement-router.test.ts @@ -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 } = {}) => ({ + ...(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 ───────────────────────────────────────────────────────────────────── 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; 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, + }, + ]); + }); +}); diff --git a/packages/api/src/__tests__/estimate-router.test.ts b/packages/api/src/__tests__/estimate-router.test.ts index ccbd9ec..5131471 100644 --- a/packages/api/src/__tests__/estimate-router.test.ts +++ b/packages/api/src/__tests__/estimate-router.test.ts @@ -1168,6 +1168,65 @@ describe("estimate router", () => { 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 ────────────────────────────────────────────────────────────────── diff --git a/packages/api/src/__tests__/event-bus-debounce.test.ts b/packages/api/src/__tests__/event-bus-debounce.test.ts index 16a122c..d53d7ff 100644 --- a/packages/api/src/__tests__/event-bus-debounce.test.ts +++ b/packages/api/src/__tests__/event-bus-debounce.test.ts @@ -4,7 +4,9 @@ import { cancelPendingEvents, eventBus, flushPendingEvents, + permissionAudience, type SseEvent, + userAudience, } from "../sse/event-bus.js"; // 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) 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(); + }); }); diff --git a/packages/api/src/__tests__/holiday-calendar-router.test.ts b/packages/api/src/__tests__/holiday-calendar-router.test.ts index 7da141c..f9e0f09 100644 --- a/packages/api/src/__tests__/holiday-calendar-router.test.ts +++ b/packages/api/src/__tests__/holiday-calendar-router.test.ts @@ -40,6 +40,114 @@ function createAdminCaller(db: Record) { } 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 () => { const db = { 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 () => { const db = { country: { diff --git a/packages/api/src/__tests__/identifier-resolvers.test.ts b/packages/api/src/__tests__/identifier-resolvers.test.ts new file mode 100644 index 0000000..df46dd3 --- /dev/null +++ b/packages/api/src/__tests__/identifier-resolvers.test.ts @@ -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) { + 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, + }), + })); + }); +}); diff --git a/packages/api/src/__tests__/insights-router.test.ts b/packages/api/src/__tests__/insights-router.test.ts new file mode 100644 index 0000000..1325a21 --- /dev/null +++ b/packages/api/src/__tests__/insights-router.test.ts @@ -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) { + 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(); + } + }); +}); diff --git a/packages/api/src/__tests__/project-router.test.ts b/packages/api/src/__tests__/project-router.test.ts index fb334a8..61af0b3 100644 --- a/packages/api/src/__tests__/project-router.test.ts +++ b/packages/api/src/__tests__/project-router.test.ts @@ -1,6 +1,9 @@ import { OrderType, AllocationType, ProjectStatus, SystemRole } from "@capakraken/shared"; 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 { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; import { createCallerFactory } from "../trpc.js"; vi.mock("@capakraken/application", async (importOriginal) => { @@ -26,6 +29,19 @@ vi.mock("../lib/cache.js", () => ({ 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", () => ({ isDalleConfigured: vi.fn().mockReturnValue(false), createDalleClient: vi.fn(), @@ -155,6 +171,47 @@ describe("project router", () => { 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 () => { const db = { project: { @@ -208,7 +265,7 @@ describe("project router", () => { // ─── 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 = { project: { findUnique: vi.fn().mockResolvedValue({ ...sampleProject, blueprint: null }), @@ -218,7 +275,7 @@ describe("project router", () => { assignment: { findMany: vi.fn().mockResolvedValue([]) }, }; - const caller = createProtectedCaller(db); + const caller = createControllerCaller(db); const result = await caller.getById({ id: "project_1" }); expect(result.id).toBe("project_1"); @@ -236,11 +293,22 @@ describe("project router", () => { assignment: { findMany: vi.fn().mockResolvedValue([]) }, }; - const caller = createProtectedCaller(db); + const caller = createControllerCaller(db); await expect(caller.getById({ id: "missing" })).rejects.toThrow( 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", () => { @@ -292,7 +360,7 @@ describe("project router", () => { }, }; - const caller = createProtectedCaller(db); + const caller = createControllerCaller(db); const result = await caller.getShoringRatio({ projectId: "project_1" }); 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 ──────────────────────────────────────────────────── @@ -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" })); + }); + }); }); diff --git a/packages/api/src/__tests__/rate-card-router.test.ts b/packages/api/src/__tests__/rate-card-router.test.ts index bf03136..56f1341 100644 --- a/packages/api/src/__tests__/rate-card-router.test.ts +++ b/packages/api/src/__tests__/rate-card-router.test.ts @@ -1,15 +1,23 @@ import { describe, expect, it, vi } from "vitest"; -import type { inferProcedureInput } from "@trpc/server"; -import type { AppRouter } from "../router/index.js"; +import { SystemRole } from "@capakraken/shared"; +import { rateCardRouter } from "../router/rate-card.js"; +import { createCallerFactory } from "../trpc.js"; -// Minimal mock helpers -function mockCtx(overrides: Record = {}) { - return { - ctx: { - session: { user: { id: "user_1", systemRole: "MANAGER" } }, - db: overrides, +const createCaller = createCallerFactory(rateCardRouter); + +function createControllerCaller(db: Record) { + 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("rateCard router", () => { @@ -60,6 +68,52 @@ describe("rateCard router", () => { }); 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", () => { const lines = [ { id: "rcl_1", roleId: null, chapter: "Digital Content Production", costRateCents: 7000, billRateCents: 12000 }, diff --git a/packages/api/src/__tests__/report-router.test.ts b/packages/api/src/__tests__/report-router.test.ts index 2390a87..e34f507 100644 --- a/packages/api/src/__tests__/report-router.test.ts +++ b/packages/api/src/__tests__/report-router.test.ts @@ -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("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"), + }); + }); }); diff --git a/packages/api/src/__tests__/resource-router-crud.test.ts b/packages/api/src/__tests__/resource-router-crud.test.ts index aa5b32f..5b65440 100644 --- a/packages/api/src/__tests__/resource-router-crud.test.ts +++ b/packages/api/src/__tests__/resource-router-crud.test.ts @@ -112,10 +112,10 @@ describe("resource router CRUD", () => { vi.clearAllMocks(); }); - // ─── list ───────────────────────────────────────────────────────────────── + // ─── listStaff ──────────────────────────────────────────────────────────── - describe("list", () => { - it("returns paginated results with total count", async () => { + describe("listStaff", () => { + it("returns paginated results with total count for staff callers", async () => { const db = { resource: { findMany: vi.fn().mockResolvedValue([sampleResource]), @@ -123,15 +123,15 @@ describe("resource router CRUD", () => { }, }; - const caller = createProtectedCaller(db); - const result = await caller.list({ limit: 50 }); + const caller = createManagerCaller(db); + const result = await caller.listStaff({ limit: 50 }); expect(result.resources).toHaveLength(1); expect(result.resources[0]?.displayName).toBe("Alice"); expect(db.resource.findMany).toHaveBeenCalled(); }); - it("applies search filter", async () => { + it("applies search filter for staff callers", async () => { const db = { resource: { findMany: vi.fn().mockResolvedValue([]), @@ -139,8 +139,8 @@ describe("resource router CRUD", () => { }, }; - const caller = createProtectedCaller(db); - await caller.list({ search: "Alice", limit: 50 }); + const caller = createManagerCaller(db); + await caller.listStaff({ search: "Alice", limit: 50 }); expect(db.resource.findMany).toHaveBeenCalled(); }); @@ -152,7 +152,8 @@ describe("resource router CRUD", () => { it("returns correct resource", async () => { const db = { 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([]), }, systemSettings: { @@ -170,6 +171,7 @@ describe("resource router CRUD", () => { it("throws NOT_FOUND when resource does not exist", async () => { const db = { resource: { + findFirst: vi.fn().mockResolvedValue({ id: "res_1" }), findUnique: vi.fn().mockResolvedValue(null), findMany: vi.fn().mockResolvedValue([]), }, @@ -188,6 +190,7 @@ describe("resource router CRUD", () => { const ownedResource = { ...sampleResource, userId: "user_1" }; const db = { resource: { + findFirst: vi.fn().mockResolvedValue({ id: "res_1" }), findUnique: vi.fn().mockResolvedValue(ownedResource), findMany: vi.fn().mockResolvedValue([]), }, @@ -201,6 +204,21 @@ describe("resource router CRUD", () => { 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 ─────────────────────────────────────────────────────────────── @@ -349,6 +367,7 @@ describe("resource router CRUD", () => { it("returns expected shape with key fields", async () => { const db = { resource: { + findFirst: vi.fn().mockResolvedValue({ id: "res_1" }), findUnique: vi.fn().mockResolvedValue({ id: "res_1", displayName: "Alice", @@ -387,7 +406,10 @@ describe("resource router CRUD", () => { it("throws NOT_FOUND for missing resource", async () => { 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) }, }; @@ -396,6 +418,37 @@ describe("resource router CRUD", () => { 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 ──────────────────────────────────────────────────── diff --git a/packages/api/src/__tests__/resource-router.test.ts b/packages/api/src/__tests__/resource-router.test.ts index d7d84b3..3490f33 100644 --- a/packages/api/src/__tests__/resource-router.test.ts +++ b/packages/api/src/__tests__/resource-router.test.ts @@ -1,4 +1,4 @@ -import { SystemRole } from "@capakraken/shared"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; import { ResourceType } from "@capakraken/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -49,6 +49,24 @@ function createProtectedCaller(db: Record) { }); } +function createProtectedCallerWithOverrides( + db: Record, + overrides: { granted?: PermissionKey[]; denied?: PermissionKey[] } | null, +) { + return createCaller({ + session: { + user: { email: "user@example.com", name: "User", image: null, role: "USER" }, + expires: "2026-03-14T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_1", + systemRole: SystemRole.USER, + permissionOverrides: overrides, + }, + }); +} + describe("resource router", () => { beforeEach(() => { vi.clearAllMocks(); @@ -332,8 +350,8 @@ describe("resource router", () => { }, }; - const caller = createProtectedCaller(db); - const result = await caller.list({ + const caller = createControllerCaller(db); + const result = await caller.listStaff({ limit: 1, cursor: JSON.stringify({ displayName: "Alex", id: "1" }), }); @@ -360,6 +378,7 @@ describe("resource router", () => { it("resolves resource ownership server-side without exposing linked user email", async () => { const db = { resource: { + findFirst: vi.fn().mockResolvedValue({ id: "resource_1" }), findUnique: vi.fn().mockResolvedValue({ id: "resource_1", eid: "E-001", @@ -555,7 +574,371 @@ describe("resource router", () => { } }); - it("applies country filters including explicit no-country toggle", async () => { + it("returns a holiday-aware chargeability summary readmodel", 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 = { + resource: { + findUniqueOrThrow: vi.fn().mockResolvedValue(resourceRecord), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([ + { + id: "assign_1", + hoursPerDay: 8, + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-06T00:00:00.000Z"), + dailyCostCents: 40000, + status: "CONFIRMED", + project: { + id: "project_gamma", + name: "Gamma", + shortCode: "GAM", + orderType: "CLIENT", + utilizationCategory: { code: "Chg" }, + }, + }, + ]), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + holidayCalendar: { + findMany: vi.fn().mockResolvedValue([]), + }, + calculationRule: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + + const caller = createControllerCaller(db); + const result = await caller.getChargeabilitySummary({ resourceId: "res_1", month: "2026-01" }); + + expect(result.bookedHours).toBe(8); + expect(result.allocations).toEqual([expect.objectContaining({ hours: 8, project: "Gamma", code: "GAM" })]); + expect(result.baseWorkingDays).toBe(22); + expect(result.baseAvailableHours).toBe(176); + expect(result.availableHours).toBe(160); + expect(result.workingDays).toBe(20); + expect(result.targetHours).toBe(128); + expect(result.unassignedHours).toBe(152); + expect(result.locationContext.federalState).toBe("BY"); + expect(result.holidaySummary).toEqual( + expect.objectContaining({ + count: 2, + workdayCount: 2, + hoursDeduction: 16, + }), + ); + expect(result.capacityBreakdown).toEqual( + expect.objectContaining({ + formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours", + holidayHoursDeduction: 16, + absenceHoursDeduction: 0, + }), + ); + }); + + it("allows regular users to read their own chargeability summary", async () => { + const resourceRecord = { + id: "res_own", + 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 = { + resource: { + findFirst: vi.fn().mockResolvedValue({ id: "res_own" }), + findUniqueOrThrow: vi.fn().mockResolvedValue(resourceRecord), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + holidayCalendar: { + findMany: vi.fn().mockResolvedValue([]), + }, + calculationRule: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + + const caller = createProtectedCaller(db); + const result = await caller.getChargeabilitySummary({ resourceId: "res_own", month: "2026-01" }); + + expect(result.resource).toBe("Bruce Banner"); + expect(db.resource.findFirst).toHaveBeenCalledWith({ + where: { userId: "user_1" }, + select: { id: true }, + }); + }); + + it("rejects chargeability summary access for foreign resources", async () => { + const db = { + resource: { + findFirst: vi.fn().mockResolvedValue({ id: "res_own" }), + findUniqueOrThrow: vi.fn(), + }, + }; + + const caller = createProtectedCaller(db); + + await expect( + caller.getChargeabilitySummary({ resourceId: "res_other", month: "2026-01" }), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "You can only view chargeability details for your own resource unless you have staff access", + }); + expect(db.resource.findUniqueOrThrow).not.toHaveBeenCalled(); + }); + + it("requires explicit score permission for value scores", async () => { + const db = { + resource: { + findMany: vi.fn(), + }, + }; + + const caller = createProtectedCaller(db); + + await expect(caller.getValueScores({})).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Permission required: viewScores", + }); + expect(db.resource.findMany).not.toHaveBeenCalled(); + }); + + it("returns value scores when the caller has explicit score permission", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + eid: "E-001", + displayName: "Alice", + chapter: "CGI", + lcrCents: 5000, + valueScore: 93, + valueScoreBreakdown: { delivery: 50, scarcity: 43 }, + valueScoreUpdatedAt: new Date("2026-03-01T00:00:00.000Z"), + }, + ]), + }, + }; + + const caller = createProtectedCallerWithOverrides(db, { granted: [PermissionKey.VIEW_SCORES] }); + const result = await caller.getValueScores({ limit: 25 }); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + id: "res_1", + displayName: "Alice", + valueScore: 93, + }); + expect(db.resource.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { isActive: true }, + take: 25, + }), + ); + }); + + it("rejects resource summary searches for regular users", async () => { + const db = { + resource: { + findMany: vi.fn(), + }, + }; + + const caller = createProtectedCaller(db); + + await expect( + caller.listSummaries({ search: "Alice", limit: 10 }), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "You need resource overview access to search resource summaries", + }); + expect(db.resource.findMany).not.toHaveBeenCalled(); + }); + + it("allows resource summary searches with broad resource read permission", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + eid: "E-001", + displayName: "Alice", + chapter: "CGI", + isActive: true, + areaRole: { name: "Developer" }, + country: { code: "DE", name: "Germany" }, + metroCity: { name: "Munich" }, + orgUnit: { name: "Studio A" }, + }, + ]), + }, + }; + + const caller = createProtectedCallerWithOverrides(db, { granted: [PermissionKey.VIEW_ALL_RESOURCES] }); + const result = await caller.listSummaries({ search: "Ali", limit: 10 }); + + expect(result).toEqual([ + { + id: "res_1", + eid: "E-001", + name: "Alice", + chapter: "CGI", + role: "Developer", + country: "Germany", + countryCode: "DE", + metroCity: "Munich", + orgUnit: "Studio A", + active: true, + }, + ]); + expect(db.resource.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: expect.arrayContaining([ + { displayName: { contains: "Ali", mode: "insensitive" } }, + { eid: { contains: "Ali", mode: "insensitive" } }, + ]), + }), + select: { + id: true, + eid: true, + displayName: true, + chapter: true, + isActive: true, + areaRole: { select: { name: true } }, + country: { select: { code: true, name: true } }, + metroCity: { select: { name: true } }, + orgUnit: { select: { name: true } }, + }, + take: 10, + }), + ); + }); + + it("returns assistant-facing resource summary details from the canonical router", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + eid: "E-001", + displayName: "Alice", + chapter: "CGI", + fte: 1, + lcrCents: 5000, + chargeabilityTarget: 80, + isActive: true, + areaRole: { name: "Developer" }, + country: { code: "DE", name: "Germany" }, + metroCity: { name: "Munich" }, + orgUnit: { name: "Studio A" }, + }, + ]), + }, + }; + + const caller = createProtectedCallerWithOverrides(db, { granted: [PermissionKey.VIEW_ALL_RESOURCES] }); + const result = await caller.listSummariesDetail({ search: "Ali", limit: 10 }); + + expect(result).toEqual([ + { + id: "res_1", + eid: "E-001", + name: "Alice", + chapter: "CGI", + role: "Developer", + country: "Germany", + countryCode: "DE", + metroCity: "Munich", + orgUnit: "Studio A", + fte: 1, + lcr: "50,00 EUR", + chargeabilityTarget: "80%", + active: true, + }, + ]); + expect(db.resource.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: expect.objectContaining({ + fte: true, + lcrCents: true, + chargeabilityTarget: true, + }), + }), + ); + }); + + it("returns only safe directory fields for generic resource lookups", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + eid: "E-001", + displayName: "Alice", + chapter: "CGI", + isActive: true, + }, + ]), + count: vi.fn().mockResolvedValue(1), + }, + }; + + const caller = createProtectedCaller(db); + const result = await caller.directory({ limit: 10 }); + + expect(result.resources).toHaveLength(1); + expect(result.resources[0]).toEqual({ + id: "res_1", + eid: "E-001", + displayName: "Alice", + chapter: "CGI", + isActive: true, + }); + expect(db.resource.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: { + id: true, + eid: true, + displayName: true, + chapter: true, + isActive: true, + }, + }), + ); + }); + + it("does not include email search in the safe resource directory", async () => { const db = { resource: { findMany: vi.fn().mockResolvedValue([]), @@ -564,7 +947,379 @@ describe("resource router", () => { }; const caller = createProtectedCaller(db); - await caller.list({ + await caller.directory({ search: "alice@example.com", limit: 25 }); + + expect(db.resource.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + AND: expect.arrayContaining([ + { isActive: true }, + { + OR: [ + { displayName: { contains: "alice@example.com", mode: "insensitive" } }, + { eid: { contains: "alice@example.com", mode: "insensitive" } }, + ], + }, + ]), + }, + }), + ); + }); + + it("rejects staff resource lists for regular users", async () => { + const db = { + resource: { + findMany: vi.fn(), + count: vi.fn(), + }, + }; + + const caller = createProtectedCaller(db); + + await expect( + caller.listStaff({ limit: 10 }), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "You need resource overview access to list staff resource data", + }); + expect(db.resource.findMany).not.toHaveBeenCalled(); + expect(db.resource.count).not.toHaveBeenCalled(); + }); + + it("includes email search in explicit staff resource lists", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + }, + }; + + const caller = createControllerCaller(db); + await caller.listStaff({ search: "alice@example.com", limit: 25 }); + + expect(db.resource.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + AND: expect.arrayContaining([ + { isActive: true }, + { + OR: [ + { displayName: { contains: "alice@example.com", mode: "insensitive" } }, + { eid: { contains: "alice@example.com", mode: "insensitive" } }, + { email: { contains: "alice@example.com", mode: "insensitive" } }, + ], + }, + ]), + }, + }), + ); + }); + + it("returns sensitive fields and optional roles in explicit staff resource lists", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_other", + eid: "E-OTHER", + displayName: "Bob", + email: "bob@example.com", + chapter: "CGI", + lcrCents: 7000, + ucrCents: 12000, + currency: "EUR", + roleId: "role_artist", + federalState: "BY", + dynamicFields: { level: "senior" }, + resourceRoles: [ + { + roleId: "role_artist", + isPrimary: true, + role: { id: "role_artist", name: "Artist", color: "#123456" }, + }, + ], + }, + ]), + count: vi.fn().mockResolvedValue(1), + }, + }; + + const caller = createControllerCaller(db); + const result = await caller.listStaff({ limit: 10, includeRoles: true }); + + expect(db.resource.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + include: expect.objectContaining({ + resourceRoles: expect.objectContaining({ + include: expect.any(Object), + }), + }), + }), + ); + expect(result.resources[0]).toMatchObject({ + id: "res_other", + email: "bob@example.com", + lcrCents: 7000, + ucrCents: 12000, + currency: "EUR", + roleId: "role_artist", + federalState: "BY", + dynamicFields: { level: "senior" }, + resourceRoles: [ + expect.objectContaining({ + roleId: "role_artist", + isPrimary: true, + role: expect.objectContaining({ id: "role_artist", name: "Artist" }), + }), + ], + }); + }); + + it("allows exact self lookup via getByIdentifier without broad search", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(null), + findFirst: vi.fn() + .mockResolvedValueOnce({ + id: "res_own", + eid: "E-OWN", + displayName: "Alice Example", + chapter: "CGI", + isActive: true, + }) + .mockResolvedValueOnce({ id: "res_own" }), + findMany: vi.fn(), + }, + }; + + const caller = createProtectedCaller(db); + const result = await caller.getByIdentifier({ identifier: "Alice Example" }); + + expect(result).toEqual({ + id: "res_own", + eid: "E-OWN", + displayName: "Alice Example", + chapter: "CGI", + isActive: true, + }); + expect(db.resource.findMany).not.toHaveBeenCalled(); + }); + + it("returns assistant-facing resource details from the canonical router", async () => { + const db = { + resource: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "res_own", + eid: "E-OWN", + displayName: "Alice Example", + chapter: "CGI", + isActive: true, + }) + .mockResolvedValueOnce({ + id: "res_own", + eid: "E-OWN", + displayName: "Alice Example", + email: "alice@example.com", + chapter: "CGI", + fte: 1, + lcrCents: 5000, + ucrCents: 9000, + chargeabilityTarget: 80, + isActive: true, + availability: {}, + skills: [{ name: "Houdini", level: 5 }], + postalCode: "80331", + federalState: "BY", + areaRole: { name: "Developer", color: "#123456" }, + country: { code: "DE", name: "Germany", dailyWorkingHours: 8 }, + metroCity: { name: "Munich" }, + managementLevelGroup: { name: "Senior", targetPercentage: 75 }, + orgUnit: { name: "Studio A", level: 5 }, + _count: { assignments: 4, vacations: 2 }, + }), + findFirst: vi.fn().mockResolvedValueOnce({ id: "res_own" }), + }, + }; + + const caller = createProtectedCaller(db); + const result = await caller.getByIdentifierDetail({ identifier: "E-OWN" }); + + expect(result).toEqual({ + id: "res_own", + eid: "E-OWN", + name: "Alice Example", + email: "alice@example.com", + chapter: "CGI", + role: "Developer", + country: "Germany", + countryCode: "DE", + countryHours: 8, + metroCity: "Munich", + fte: 1, + lcr: "50,00 EUR", + ucr: "90,00 EUR", + chargeabilityTarget: "80%", + managementLevel: "Senior", + orgUnit: "Studio A", + postalCode: "80331", + federalState: "BY", + active: true, + totalAssignments: 4, + totalVacations: 2, + skillCount: 1, + topSkills: ["Houdini (5)"], + }); + }); + + it("rejects foreign identifier lookups for regular users", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(null), + findFirst: vi.fn() + .mockResolvedValueOnce({ + id: "res_other", + eid: "E-OTHER", + displayName: "Bob Other", + chapter: "CGI", + isActive: true, + }) + .mockResolvedValueOnce({ id: "res_own" }), + }, + }; + + const caller = createProtectedCaller(db); + + await expect( + caller.getByIdentifier({ identifier: "Bob Other" }), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "You can only view your own resource unless you have staff access", + }); + }); + + it("does not return fuzzy identifier suggestions to regular users", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(null), + findFirst: vi.fn().mockResolvedValueOnce(null), + findMany: vi.fn(), + }, + }; + + const caller = createProtectedCaller(db); + const result = await caller.getByIdentifier({ identifier: "Ali" }); + + expect(result).toEqual({ error: "Resource not found: Ali" }); + expect(db.resource.findMany).not.toHaveBeenCalled(); + }); + + it("rejects foreign EID lookups for regular users", async () => { + const db = { + resource: { + findFirst: vi.fn().mockResolvedValue({ id: "res_own" }), + findUnique: vi.fn().mockResolvedValue({ + id: "res_other", + eid: "E-OTHER", + displayName: "Bob Other", + }), + }, + }; + + const caller = createProtectedCaller(db); + + await expect( + caller.getByEid({ eid: "E-OTHER" }), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "You can only view your own resource unless you have staff access", + }); + }); + + it("rejects foreign resolveByIdentifier lookups for regular users", async () => { + const db = { + resource: { + findFirst: vi.fn() + .mockResolvedValueOnce({ + id: "res_other", + eid: "E-OTHER", + displayName: "Bob Other", + chapter: "CGI", + isActive: true, + }) + .mockResolvedValueOnce({ id: "res_own" }), + findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(null), + }, + }; + + const caller = createProtectedCaller(db); + + await expect( + caller.resolveByIdentifier({ identifier: "Bob Other" }), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "You can only resolve your own resource unless you have staff access", + }); + }); + + it("returns only identity-safe fields from resolveByIdentifier", async () => { + const db = { + resource: { + findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({ + id: "res_own", + eid: "E-OWN", + displayName: "Alice Example", + chapter: "CGI", + isActive: true, + }), + findFirst: vi.fn().mockResolvedValueOnce({ id: "res_own" }), + findMany: vi.fn(), + }, + }; + + const caller = createProtectedCaller(db); + const result = await caller.resolveByIdentifier({ identifier: "E-OWN" }); + + expect(result).toEqual({ + id: "res_own", + eid: "E-OWN", + displayName: "Alice Example", + chapter: "CGI", + isActive: true, + }); + expect(db.resource.findMany).not.toHaveBeenCalled(); + }); + + it("resolves responsible person names through the canonical resource search boundary", async () => { + const db = { + resource: { + findFirst: vi.fn().mockResolvedValueOnce(null), + findMany: vi.fn().mockResolvedValue([ + { displayName: "Peter Parker", eid: "EMP-001" }, + ]), + }, + }; + + const caller = createProtectedCaller(db); + const result = await caller.resolveResponsiblePersonName({ name: "Peter" }); + + expect(result).toEqual({ + status: "resolved", + displayName: "Peter Parker", + }); + }); + + it("applies country filters on the staff list including explicit no-country toggle", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + }, + }; + + const caller = createControllerCaller(db); + await caller.listStaff({ countryIds: ["country_de", "country_us"], includeWithoutCountry: false, }); @@ -581,7 +1336,7 @@ describe("resource router", () => { ); }); - it("excludes disabled countries while leaving all others visible", async () => { + it("excludes disabled countries on the staff list while leaving all others visible", async () => { const db = { resource: { findMany: vi.fn().mockResolvedValue([]), @@ -589,8 +1344,8 @@ describe("resource router", () => { }, }; - const caller = createProtectedCaller(db); - await caller.list({ + const caller = createControllerCaller(db); + await caller.listStaff({ excludedCountryIds: ["country_fr"], includeWithoutCountry: true, }); @@ -607,7 +1362,7 @@ describe("resource router", () => { ); }); - it("applies resource type filters while keeping unspecified rows when requested", async () => { + it("applies resource type filters on the staff list while keeping unspecified rows when requested", async () => { const db = { resource: { findMany: vi.fn().mockResolvedValue([]), @@ -615,8 +1370,8 @@ describe("resource router", () => { }, }; - const caller = createProtectedCaller(db); - await caller.list({ + const caller = createControllerCaller(db); + await caller.listStaff({ resourceTypes: [ResourceType.EMPLOYEE, ResourceType.INTERN], includeWithoutResourceType: true, }); @@ -638,7 +1393,7 @@ describe("resource router", () => { ); }); - it("excludes disabled resource types while leaving all others visible", async () => { + it("excludes disabled resource types on the staff list while leaving all others visible", async () => { const db = { resource: { findMany: vi.fn().mockResolvedValue([]), @@ -646,8 +1401,8 @@ describe("resource router", () => { }, }; - const caller = createProtectedCaller(db); - await caller.list({ + const caller = createControllerCaller(db); + await caller.listStaff({ excludedResourceTypes: [ResourceType.FREELANCER], includeWithoutResourceType: true, }); @@ -664,7 +1419,7 @@ describe("resource router", () => { ); }); - it("applies rolled-off and departed filters", async () => { + it("applies rolled-off and departed filters on the staff list", async () => { const db = { resource: { findMany: vi.fn().mockResolvedValue([]), @@ -672,8 +1427,8 @@ describe("resource router", () => { }, }; - const caller = createProtectedCaller(db); - await caller.list({ + const caller = createControllerCaller(db); + await caller.listStaff({ rolledOff: true, departed: false, }); @@ -691,7 +1446,7 @@ describe("resource router", () => { ); }); - it("applies multi-select chapter filters", async () => { + it("applies multi-select chapter filters on the staff list", async () => { const db = { resource: { findMany: vi.fn().mockResolvedValue([]), @@ -699,8 +1454,8 @@ describe("resource router", () => { }, }; - const caller = createProtectedCaller(db); - await caller.list({ + const caller = createControllerCaller(db); + await caller.listStaff({ chapters: ["Art Direction", "Project Management"], }); @@ -716,7 +1471,7 @@ describe("resource router", () => { ); }); - it("supports stable anonymized identities and alias-based filtering", async () => { + it("supports stable anonymized identities and alias-based filtering on the staff list", async () => { const resource = { id: "resource_anon_1", eid: "h.noerenberg", @@ -744,8 +1499,8 @@ describe("resource router", () => { }, }; - const caller = createProtectedCaller(db); - const first = await caller.list({ limit: 10 }); + const caller = createControllerCaller(db); + const first = await caller.listStaff({ limit: 10 }); const alias = first.resources[0]; expect(alias).toBeDefined(); @@ -753,12 +1508,99 @@ describe("resource router", () => { expect(alias?.eid).not.toBe(resource.eid); expect(alias?.email).toBe(`${alias?.eid}@superhartmut.de`); - const byAlias = await caller.list({ eids: [alias!.eid], limit: 10 }); - const byAliasSearch = await caller.list({ search: alias!.displayName.slice(0, 4), limit: 10 }); + const byAlias = await caller.listStaff({ eids: [alias!.eid], limit: 10 }); + const byAliasSearch = await caller.listStaff({ search: alias!.displayName.slice(0, 4), limit: 10 }); expect(byAlias.resources).toHaveLength(1); expect(byAlias.resources[0]?.id).toBe(resource.id); expect(byAliasSearch.resources).toHaveLength(1); expect(byAliasSearch.resources[0]?.id).toBe(resource.id); }); + + it("includes email search for staff resource list lookups", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + }, + }; + + const caller = createControllerCaller(db); + await caller.listStaff({ search: "alice@example.com", limit: 25 }); + + expect(db.resource.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + AND: expect.arrayContaining([ + { isActive: true }, + { + OR: [ + { displayName: { contains: "alice@example.com", mode: "insensitive" } }, + { eid: { contains: "alice@example.com", mode: "insensitive" } }, + { email: { contains: "alice@example.com", mode: "insensitive" } }, + ], + }, + ]), + }, + }), + ); + }); + + it("keeps contact and cost fields intact in staff resource lists", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_other", + eid: "E-OTHER", + displayName: "Bob", + email: "bob@example.com", + lcrCents: 7000, + ucrCents: 12000, + chargeabilityTarget: 85, + valueScore: 88, + valueScoreBreakdown: { scarcity: 40 }, + valueScoreUpdatedAt: new Date("2026-03-02T00:00:00.000Z"), + }, + ]), + count: vi.fn().mockResolvedValue(1), + }, + }; + + const caller = createControllerCaller(db); + const result = await caller.listStaff({ limit: 10 }); + + expect(result.resources[0]).toMatchObject({ + id: "res_other", + email: "bob@example.com", + lcrCents: 7000, + ucrCents: 12000, + chargeabilityTarget: 85, + valueScore: 88, + valueScoreBreakdown: { scarcity: 40 }, + }); + expect(result.resources[0]?.valueScoreUpdatedAt).toBeInstanceOf(Date); + }); + + it("keeps includeRoles available for the staff route", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + }, + }; + + const caller = createControllerCaller(db); + await caller.listStaff({ limit: 10, includeRoles: true }); + + expect(db.resource.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + include: expect.objectContaining({ + resourceRoles: expect.objectContaining({ + include: expect.any(Object), + }), + }), + }), + ); + }); }); diff --git a/packages/api/src/__tests__/staffing-router.test.ts b/packages/api/src/__tests__/staffing-router.test.ts index 9ec2d83..759aa4d 100644 --- a/packages/api/src/__tests__/staffing-router.test.ts +++ b/packages/api/src/__tests__/staffing-router.test.ts @@ -1,3 +1,4 @@ +import { listAssignmentBookings } from "@capakraken/application"; import { SystemRole } from "@capakraken/shared"; import { describe, expect, it, vi } from "vitest"; 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 ────────────────────────────────────────────────────── describe("staffing.analyzeUtilization", () => { diff --git a/packages/api/src/__tests__/timeline-router.test.ts b/packages/api/src/__tests__/timeline-router.test.ts new file mode 100644 index 0000000..61ab2e2 --- /dev/null +++ b/packages/api/src/__tests__/timeline-router.test.ts @@ -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(); + return { + ...actual, + listAssignmentBookings: vi.fn(), + }; +}); + +vi.mock("../lib/anonymization.js", async (importOriginal) => { + const actual = await importOriginal(); + 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) { + 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) { + 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 }) => { + 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" })); + }); +}); diff --git a/packages/api/src/__tests__/vacation-router.test.ts b/packages/api/src/__tests__/vacation-router.test.ts index 741f262..4341037 100644 --- a/packages/api/src/__tests__/vacation-router.test.ts +++ b/packages/api/src/__tests__/vacation-router.test.ts @@ -1,6 +1,9 @@ import { SystemRole } from "@capakraken/shared"; import { VacationStatus, VacationType } from "@capakraken/db"; 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 { createCallerFactory } from "../trpc.js"; @@ -33,6 +36,15 @@ vi.mock("../lib/audit.js", () => ({ 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); function createProtectedCaller(db: Record) { @@ -163,6 +175,9 @@ describe("vacation router", () => { describe("list", () => { it("returns vacations with default filters", async () => { const db = { + resource: { + findFirst: vi.fn().mockResolvedValue({ id: "res_1" }), + }, vacation: { findMany: vi.fn().mockResolvedValue([sampleVacation]), }, @@ -183,6 +198,9 @@ describe("vacation router", () => { it("applies resourceId filter", async () => { const db = { + resource: { + findFirst: vi.fn().mockResolvedValue({ id: "res_1" }), + }, vacation: { 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 () => { const db = { + resource: { + findFirst: vi.fn().mockResolvedValue({ id: "res_1" }), + }, vacation: { 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 }) => { + 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 }) => { + 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", () => { it("returns vacation by id", async () => { const db = { + resource: { + findFirst: vi.fn(), + }, 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 () => { const db = { vacation: { @@ -890,6 +1064,9 @@ describe("vacation router", () => { describe("getForResource", () => { it("returns approved vacations in date range", async () => { const db = { + resource: { + findFirst: vi.fn().mockResolvedValue({ id: "res_1" }), + }, vacation: { 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", () => { @@ -952,6 +1150,7 @@ describe("vacation router", () => { it("returns overlapping vacations for the same chapter", async () => { const db = { resource: { + findFirst: vi.fn().mockResolvedValue({ id: "res_1" }), findUnique: vi.fn().mockResolvedValue({ chapter: "Animation" }), }, vacation: { @@ -987,6 +1186,7 @@ describe("vacation router", () => { it("returns empty array when resource has no chapter", async () => { const db = { resource: { + findFirst: vi.fn().mockResolvedValue({ id: "res_1" }), findUnique: vi.fn().mockResolvedValue({ chapter: null }), }, }; @@ -1000,6 +1200,76 @@ describe("vacation router", () => { 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", () => { diff --git a/packages/api/src/db/selects.ts b/packages/api/src/db/selects.ts index bf8745f..3bfce25 100644 --- a/packages/api/src/db/selects.ts +++ b/packages/api/src/db/selects.ts @@ -1,3 +1,3 @@ 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 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; diff --git a/packages/api/src/lib/audit.ts b/packages/api/src/lib/audit.ts index 77fbc67..84bd8d2 100644 --- a/packages/api/src/lib/audit.ts +++ b/packages/api/src/lib/audit.ts @@ -92,6 +92,11 @@ export function generateSummary( export async function createAuditEntry(params: CreateAuditEntryParams): Promise { try { const { db, entityType, entityId, entityName, action, userId, before, after, source, metadata } = params; + const auditLog = (db as Partial).auditLog; + + if (!auditLog || typeof auditLog.create !== "function") { + return; + } // Compute diff if both snapshots are available 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 (metadata) changes.metadata = metadata; - await db.auditLog.create({ + await auditLog.create({ data: { entityType, entityId, diff --git a/packages/api/src/middleware/rate-limit.ts b/packages/api/src/middleware/rate-limit.ts index 5b2c61d..e6c8a4f 100644 --- a/packages/api/src/middleware/rate-limit.ts +++ b/packages/api/src/middleware/rate-limit.ts @@ -15,12 +15,17 @@ interface RateLimitResult { resetAt: Date; } +export interface RateLimiter { + (key: string): RateLimitResult; + reset(): void; +} + /** * Creates a sliding-window rate limiter. * @param windowMs - Time window in milliseconds * @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(); // Periodically clean up expired entries to prevent memory leaks @@ -38,7 +43,7 @@ export function createRateLimiter(windowMs: number, maxRequests: number) { cleanupInterval.unref(); } - return function check(key: string): RateLimitResult { + const check = function check(key: string): RateLimitResult { const now = Date.now(); const existing = store.get(key); @@ -61,7 +66,13 @@ export function createRateLimiter(windowMs: number, maxRequests: number) { remaining: Math.max(0, maxRequests - existing.count), resetAt: new Date(existing.resetAt), }; + } as RateLimiter; + + check.reset = () => { + store.clear(); }; + + return check; } /** General API rate limiter: 100 requests per 15 minutes per key */ diff --git a/packages/api/src/router/allocation.ts b/packages/api/src/router/allocation.ts index c649d23..e21b454 100644 --- a/packages/api/src/router/allocation.ts +++ b/packages/api/src/router/allocation.ts @@ -35,6 +35,7 @@ import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js"; import { generateAutoSuggestions } from "../lib/auto-staffing.js"; import { invalidateDashboardCache } from "../lib/cache.js"; +import { logger } from "../lib/logger.js"; import { calculateEffectiveAvailableHours, calculateEffectiveBookedHours, @@ -85,6 +86,83 @@ type AllocationListFilters = { type AllocationEntryUpdateInput = z.infer; +type AssignmentResolutionInput = { + assignmentId?: string | undefined; + resourceId?: string | undefined; + projectId?: string | undefined; + startDate?: Date | undefined; + endDate?: Date | undefined; + selectionMode?: "WINDOW" | "EXACT_START" | undefined; + excludeCancelled?: boolean | undefined; +}; + +type CreateDemandDraftInput = { + projectId: string; + role?: string | undefined; + roleId?: string | undefined; + headcount?: number | undefined; + hoursPerDay: number; + startDate: Date; + endDate: Date; + budgetCents?: number | undefined; + metadata?: Record | undefined; +}; + +function runAllocationBackgroundEffect( + effectName: string, + execute: () => unknown, + metadata: Record = {}, +): void { + void Promise.resolve() + .then(execute) + .catch((error) => { + logger.error( + { err: error, effectName, ...metadata }, + "Allocation background side effect failed", + ); + }); +} + +function invalidateDashboardCacheInBackground(): void { + runAllocationBackgroundEffect("invalidateDashboardCache", () => invalidateDashboardCache()); +} + +function checkBudgetThresholdsInBackground( + db: import("@capakraken/db").PrismaClient, + projectId: string, +): void { + runAllocationBackgroundEffect( + "checkBudgetThresholds", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + () => checkBudgetThresholds(db as any, projectId), + { projectId }, + ); +} + +function dispatchAllocationWebhookInBackground( + db: import("@capakraken/db").PrismaClient, + event: string, + payload: Record, +): void { + runAllocationBackgroundEffect( + "dispatchWebhooks", + () => dispatchWebhooks(db, event, payload), + { event }, + ); +} + +function generateAutoSuggestionsInBackground( + db: import("@capakraken/db").PrismaClient, + demandRequirementId: string, +): void { + runAllocationBackgroundEffect( + "generateAutoSuggestions", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + () => generateAutoSuggestions(db as any, demandRequirementId), + { demandRequirementId }, + ); +} + function toDemandRequirementUpdateInput(input: AllocationEntryUpdateInput) { return { ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), @@ -171,10 +249,414 @@ async function findAllocationEntryOrNull( return null; } - throw error; + throw error; } } +function toIsoDate(value: Date) { + return value.toISOString().slice(0, 10); +} + +function round1(value: number) { + return Math.round(value * 10) / 10; +} + +function averagePerWorkingDay(totalHours: number, workingDays: number) { + if (workingDays <= 0) { + return 0; + } + return round1(totalHours / workingDays); +} + +function buildCreateDemandRequirementInput(input: CreateDemandDraftInput): z.infer { + return { + projectId: input.projectId, + startDate: input.startDate, + endDate: input.endDate, + hoursPerDay: input.hoursPerDay, + percentage: (input.hoursPerDay / 8) * 100, + status: AllocationStatus.PROPOSED, + headcount: input.headcount ?? 1, + budgetCents: input.budgetCents ?? 0, + metadata: input.metadata ?? {}, + ...(input.role ? { role: input.role } : {}), + ...(input.roleId ? { roleId: input.roleId } : {}), + }; +} + +async function getDemandRequirementByIdOrThrow( + db: Pick, + id: string, +) { + return findUniqueOrThrow( + db.demandRequirement.findUnique({ + where: { id }, + include: DEMAND_INCLUDE, + }), + "Demand requirement", + ); +} + +async function createDemandRequirementWithEffects( + db: import("@capakraken/db").PrismaClient, + input: z.infer, +) { + const demandRequirement = await db.$transaction(async (tx) => { + return createDemandRequirement( + tx as unknown as Parameters[0], + input, + ); + }); + + emitAllocationCreated({ + id: demandRequirement.id, + projectId: demandRequirement.projectId, + resourceId: null, + }); + invalidateDashboardCacheInBackground(); + + const [project, roleEntity, managers] = await Promise.all([ + db.project.findUnique({ + where: { id: demandRequirement.projectId }, + select: { name: true }, + }), + demandRequirement.roleId + ? db.role.findUnique({ + where: { id: demandRequirement.roleId }, + select: { name: true }, + }) + : Promise.resolve(null), + db.user.findMany({ + where: { systemRole: { in: ["ADMIN", "MANAGER"] } }, + select: { id: true }, + }), + ]); + const roleName = roleEntity?.name ?? demandRequirement.role ?? "Unspecified role"; + const projectName = project?.name ?? "Unknown project"; + const headcount = demandRequirement.headcount ?? 1; + + for (const manager of managers) { + const task = await db.notification.create({ + data: { + userId: manager.id, + category: "TASK", + type: "DEMAND_FILL", + priority: "NORMAL", + title: `Staff demand: ${roleName} for ${projectName}`, + body: `${headcount} ${roleName} needed for project ${projectName}`, + taskStatus: "OPEN", + taskAction: buildTaskAction("fill_demand", demandRequirement.id), + entityId: demandRequirement.id, + entityType: "demand", + link: `/projects/${demandRequirement.projectId}`, + channel: "in_app", + }, + }); + emitNotificationCreated(manager.id, task.id); + } + + checkBudgetThresholdsInBackground(db, demandRequirement.projectId); + generateAutoSuggestionsInBackground(db, demandRequirement.id); + + return demandRequirement; +} + +async function fillDemandRequirementWithEffects( + db: import("@capakraken/db").PrismaClient, + input: z.infer, +) { + const result = await fillDemandRequirement(db, input); + + emitAllocationCreated({ + id: result.assignment.id, + projectId: result.assignment.projectId, + resourceId: result.assignment.resourceId, + }); + + emitAllocationUpdated({ + id: result.updatedDemandRequirement.id, + projectId: result.updatedDemandRequirement.projectId, + resourceId: null, + }); + invalidateDashboardCacheInBackground(); + checkBudgetThresholdsInBackground(db, result.assignment.projectId); + + if (result.updatedDemandRequirement.headcount > 0 + && result.updatedDemandRequirement.status !== "COMPLETED") { + generateAutoSuggestionsInBackground(db, result.updatedDemandRequirement.id); + } + + return result; +} + +async function resolveAssignmentBySelection( + db: Pick, + input: AssignmentResolutionInput, +) { + if (input.assignmentId) { + return findUniqueOrThrow( + db.assignment.findUnique({ + where: { id: input.assignmentId }, + include: ASSIGNMENT_INCLUDE, + }), + "Assignment", + ); + } + + if (!input.resourceId || !input.projectId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "resourceId and projectId are required when assignmentId is not provided", + }); + } + + const assignments = await db.assignment.findMany({ + where: { + resourceId: input.resourceId, + projectId: input.projectId, + ...(input.excludeCancelled ? { status: { not: AllocationStatus.CANCELLED } } : {}), + }, + include: ASSIGNMENT_INCLUDE, + orderBy: { startDate: "asc" }, + }); + + const matchingAssignment = assignments + .filter((assignment) => { + if (input.selectionMode === "WINDOW") { + return (!input.startDate || assignment.startDate >= input.startDate) + && (!input.endDate || assignment.endDate <= input.endDate); + } + + return !input.startDate || toIsoDate(assignment.startDate) === toIsoDate(input.startDate); + }) + .sort((left, right) => right.startDate.getTime() - left.startDate.getTime())[0] ?? null; + + if (!matchingAssignment) { + throw new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" }); + } + + return matchingAssignment; +} + +async function buildResourceAvailabilityView( + db: Pick, + input: { + resourceId: string; + startDate: Date; + endDate: Date; + hoursPerDay: number; + }, +) { + const resource = await db.resource.findUnique({ + where: { id: input.resourceId }, + select: { + id: true, displayName: true, eid: true, fte: true, + availability: true, + countryId: true, + federalState: true, + metroCityId: true, + country: { select: { dailyWorkingHours: true, code: true } }, + metroCity: { select: { name: true } }, + }, + }); + if (!resource) throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); + + const fallbackDailyHours = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1); + const availability = (resource.availability as WeekdayAvailability | null) ?? { + monday: fallbackDailyHours, + tuesday: fallbackDailyHours, + wednesday: fallbackDailyHours, + thursday: fallbackDailyHours, + friday: fallbackDailyHours, + saturday: 0, + sunday: 0, + }; + + const [existingAssignments, vacations] = await Promise.all([ + db.assignment.findMany({ + where: { + resourceId: input.resourceId, + status: { not: "CANCELLED" }, + startDate: { lte: input.endDate }, + endDate: { gte: input.startDate }, + }, + select: { + id: true, startDate: true, endDate: true, hoursPerDay: true, status: true, + project: { select: { name: true, shortCode: true } }, + }, + orderBy: { startDate: "asc" }, + }), + db.vacation.findMany({ + where: { + resourceId: input.resourceId, + status: { in: ["APPROVED", "PENDING"] }, + startDate: { lte: input.endDate }, + endDate: { gte: input.startDate }, + }, + select: { + id: true, + type: true, + startDate: true, + endDate: true, + isHalfDay: true, + halfDayPart: true, + status: true, + }, + orderBy: { startDate: "asc" }, + }), + ]); + + const contexts = await loadResourceDailyAvailabilityContexts( + db, + [{ + id: resource.id, + availability, + countryId: resource.countryId, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name, + }], + input.startDate, + input.endDate, + ); + const context = contexts.get(resource.id); + + const totalWorkingDays = countEffectiveWorkingDays({ + availability, + periodStart: input.startDate, + periodEnd: input.endDate, + context, + }); + let availableDays = 0; + let conflictDays = 0; + let partialDays = 0; + let totalAvailableHours = 0; + const requestedHpd = input.hoursPerDay; + + const d = new Date(input.startDate); + const end = new Date(input.endDate); + while (d <= end) { + const effectiveDayCapacity = calculateEffectiveDayAvailability({ + availability, + date: d, + context, + }); + + if (effectiveDayCapacity > 0) { + let bookedHours = 0; + for (const assignment of existingAssignments) { + bookedHours += calculateEffectiveBookedHours({ + availability, + startDate: assignment.startDate, + endDate: assignment.endDate, + hoursPerDay: assignment.hoursPerDay, + periodStart: d, + periodEnd: d, + context, + }); + } + + const remainingCapacity = Math.max(0, effectiveDayCapacity - bookedHours); + if (remainingCapacity >= requestedHpd) { + availableDays++; + totalAvailableHours += requestedHpd; + } else if (remainingCapacity > 0) { + partialDays++; + totalAvailableHours += remainingCapacity; + } else { + conflictDays++; + } + } + d.setDate(d.getDate() + 1); + } + + const totalRequestedHours = totalWorkingDays * requestedHpd; + const totalPeriodCapacity = calculateEffectiveAvailableHours({ + availability, + periodStart: input.startDate, + periodEnd: input.endDate, + context, + }); + const dailyCapacity = totalWorkingDays > 0 + ? Math.round((totalPeriodCapacity / totalWorkingDays) * 10) / 10 + : 0; + + return { + resource: { id: resource.id, name: resource.displayName, eid: resource.eid }, + dailyCapacity, + totalWorkingDays, + availableDays, + partialDays, + conflictDays, + totalAvailableHours: Math.round(totalAvailableHours * 10) / 10, + totalRequestedHours, + coveragePercent: totalRequestedHours > 0 + ? Math.round((totalAvailableHours / totalRequestedHours) * 100) + : 0, + existingAssignments: existingAssignments.map((assignment) => ({ + project: assignment.project.name, + code: assignment.project.shortCode, + hoursPerDay: assignment.hoursPerDay, + start: assignment.startDate.toISOString().slice(0, 10), + end: assignment.endDate.toISOString().slice(0, 10), + status: assignment.status, + })), + vacations: vacations.map((vacation) => ({ + id: vacation.id, + type: vacation.type, + status: vacation.status, + start: vacation.startDate.toISOString().slice(0, 10), + end: vacation.endDate.toISOString().slice(0, 10), + isHalfDay: vacation.isHalfDay, + halfDayPart: vacation.halfDayPart, + })), + }; +} + +function buildResourceAvailabilitySummary( + availability: Awaited>, + period: { startDate: Date; endDate: Date }, +) { + const periodAvailableHours = availability.totalRequestedHours > 0 + ? round1(availability.dailyCapacity * availability.totalWorkingDays) + : 0; + const periodRemainingHours = round1(availability.totalAvailableHours); + const periodBookedHours = round1(Math.max(0, periodAvailableHours - periodRemainingHours)); + + return { + resource: availability.resource.name, + period: `${toIsoDate(period.startDate)} to ${toIsoDate(period.endDate)}`, + fte: null, + workingDays: availability.totalWorkingDays, + periodAvailableHours, + periodBookedHours, + periodRemainingHours, + maxHoursPerDay: availability.dailyCapacity, + currentBookedHoursPerDay: round1( + Math.max( + 0, + availability.dailyCapacity - availability.totalAvailableHours / Math.max(availability.totalWorkingDays, 1), + ), + ), + availableHoursPerDay: averagePerWorkingDay(availability.totalAvailableHours, availability.totalWorkingDays), + isFullyAvailable: availability.existingAssignments.length === 0 && availability.vacations.length === 0, + existingAllocations: availability.existingAssignments.map((assignment) => ({ + project: `${assignment.project} (${assignment.code})`, + hoursPerDay: assignment.hoursPerDay, + status: assignment.status, + start: assignment.start, + end: assignment.end, + })), + vacations: availability.vacations.map((vacation) => ({ + type: vacation.type, + start: vacation.start, + end: vacation.end, + isHalfDay: vacation.isHalfDay, + })), + }; +} + export const allocationRouter = createTRPCRouter({ list: protectedProcedure .input( @@ -254,14 +736,13 @@ export const allocationRouter = createTRPCRouter({ projectId: allocation.projectId, resourceId: allocation.resourceId, }); - void dispatchWebhooks(ctx.db, "allocation.created", { + dispatchAllocationWebhookInBackground(ctx.db, "allocation.created", { id: allocation.id, projectId: allocation.projectId, resourceId: allocation.resourceId, }); - void invalidateDashboardCache(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - void checkBudgetThresholds(ctx.db as any, allocation.projectId); + invalidateDashboardCacheInBackground(); + checkBudgetThresholdsInBackground(ctx.db, allocation.projectId); return allocation; }), @@ -320,6 +801,42 @@ export const allocationRouter = createTRPCRouter({ ); }), + getAssignmentById: protectedProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + const assignment = await findUniqueOrThrow( + ctx.db.assignment.findUnique({ + where: { id: input.id }, + include: ASSIGNMENT_INCLUDE, + }), + "Assignment", + ); + const dir = await getAnonymizationDirectory(ctx.db); + if (!dir || !assignment.resource) { + return assignment; + } + return { + ...assignment, + resource: anonymizeResource(assignment.resource, dir), + }; + }), + + resolveAssignment: protectedProcedure + .input(z.object({ + assignmentId: z.string().optional(), + resourceId: z.string().optional(), + projectId: z.string().optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), + selectionMode: z.enum(["WINDOW", "EXACT_START"]).default("EXACT_START"), + excludeCancelled: z.boolean().default(false), + })) + .query(async ({ ctx, input }) => resolveAssignmentBySelection(ctx.db, input)), + + getDemandRequirementById: protectedProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => getDemandRequirementByIdOrThrow(ctx.db, input.id)), + /** * Check a resource's availability for a date range. * Returns working days, existing allocations, conflict days, and available capacity. @@ -332,212 +849,56 @@ export const allocationRouter = createTRPCRouter({ hoursPerDay: z.number().min(0.5).max(24).default(8), })) .query(async ({ ctx, input }) => { - const resource = await ctx.db.resource.findUnique({ - where: { id: input.resourceId }, - select: { - id: true, displayName: true, eid: true, fte: true, - availability: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { dailyWorkingHours: true, code: true } }, - metroCity: { select: { name: true } }, - }, - }); - if (!resource) throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); + const { vacations: _vacations, ...availability } = await buildResourceAvailabilityView(ctx.db, input); + return availability; + }), - const fallbackDailyHours = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1); - const availability = (resource.availability as WeekdayAvailability | null) ?? { - monday: fallbackDailyHours, - tuesday: fallbackDailyHours, - wednesday: fallbackDailyHours, - thursday: fallbackDailyHours, - friday: fallbackDailyHours, - saturday: 0, - sunday: 0, - }; + getResourceAvailabilityView: protectedProcedure + .input(z.object({ + resourceId: z.string(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + hoursPerDay: z.number().min(0.5).max(24).default(8), + })) + .query(async ({ ctx, input }) => buildResourceAvailabilityView(ctx.db, input)), - // Get existing assignments in the date range - const existingAssignments = await ctx.db.assignment.findMany({ - where: { - resourceId: input.resourceId, - status: { not: "CANCELLED" }, - startDate: { lte: input.endDate }, - endDate: { gte: input.startDate }, - }, - select: { - id: true, startDate: true, endDate: true, hoursPerDay: true, status: true, - project: { select: { name: true, shortCode: true } }, - }, - orderBy: { startDate: "asc" }, - }); - - const contexts = await loadResourceDailyAvailabilityContexts( - ctx.db, - [{ - id: resource.id, - availability, - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - }], - input.startDate, - input.endDate, - ); - const context = contexts.get(resource.id); - - // Calculate day-by-day availability - const totalWorkingDays = countEffectiveWorkingDays({ - availability, - periodStart: input.startDate, - periodEnd: input.endDate, - context, - }); - let availableDays = 0; - let conflictDays = 0; - let partialDays = 0; - let totalAvailableHours = 0; - const requestedHpd = input.hoursPerDay; - - const d = new Date(input.startDate); - const end = new Date(input.endDate); - while (d <= end) { - const effectiveDayCapacity = calculateEffectiveDayAvailability({ - availability, - date: d, - context, - }); - - if (effectiveDayCapacity > 0) { - let bookedHours = 0; - for (const a of existingAssignments) { - bookedHours += calculateEffectiveBookedHours({ - availability, - startDate: a.startDate, - endDate: a.endDate, - hoursPerDay: a.hoursPerDay, - periodStart: d, - periodEnd: d, - context, - }); - } - - const remainingCapacity = Math.max(0, effectiveDayCapacity - bookedHours); - if (remainingCapacity >= requestedHpd) { - availableDays++; - totalAvailableHours += requestedHpd; - } else if (remainingCapacity > 0) { - partialDays++; - totalAvailableHours += remainingCapacity; - } else { - conflictDays++; - } - } - d.setDate(d.getDate() + 1); - } - - const totalRequestedHours = totalWorkingDays * requestedHpd; - const totalPeriodCapacity = calculateEffectiveAvailableHours({ - availability, - periodStart: input.startDate, - periodEnd: input.endDate, - context, - }); - const dailyCapacity = totalWorkingDays > 0 - ? Math.round((totalPeriodCapacity / totalWorkingDays) * 10) / 10 - : 0; - - return { - resource: { id: resource.id, name: resource.displayName, eid: resource.eid }, - dailyCapacity, - totalWorkingDays, - availableDays, - partialDays, - conflictDays, - totalAvailableHours: Math.round(totalAvailableHours * 10) / 10, - totalRequestedHours, - coveragePercent: totalRequestedHours > 0 - ? Math.round((totalAvailableHours / totalRequestedHours) * 100) - : 0, - existingAssignments: existingAssignments.map((a) => ({ - project: a.project.name, - code: a.project.shortCode, - hoursPerDay: a.hoursPerDay, - start: a.startDate.toISOString().slice(0, 10), - end: a.endDate.toISOString().slice(0, 10), - status: a.status, - })), - }; + getResourceAvailabilitySummary: protectedProcedure + .input(z.object({ + resourceId: z.string(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + hoursPerDay: z.number().min(0.5).max(24).default(8), + })) + .query(async ({ ctx, input }) => { + const availability = await buildResourceAvailabilityView(ctx.db, input); + return buildResourceAvailabilitySummary(availability, input); }), createDemandRequirement: managerProcedure .input(CreateDemandRequirementSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); + return createDemandRequirementWithEffects(ctx.db, input); + }), - const demandRequirement = await ctx.db.$transaction(async (tx) => { - return createDemandRequirement( - tx as unknown as Parameters[0], - input, - ); - }); - - emitAllocationCreated({ - id: demandRequirement.id, - projectId: demandRequirement.projectId, - resourceId: null, - }); - void invalidateDashboardCache(); - - // Create staffing tasks for managers - const [project, roleEntity, managers] = await Promise.all([ - ctx.db.project.findUnique({ - where: { id: demandRequirement.projectId }, - select: { name: true }, - }), - demandRequirement.roleId - ? ctx.db.role.findUnique({ - where: { id: demandRequirement.roleId }, - select: { name: true }, - }) - : Promise.resolve(null), - ctx.db.user.findMany({ - where: { systemRole: { in: ["ADMIN", "MANAGER"] } }, - select: { id: true }, - }), - ]); - const roleName = roleEntity?.name ?? demandRequirement.role ?? "Unspecified role"; - const projectName = project?.name ?? "Unknown project"; - const headcount = demandRequirement.headcount ?? 1; - - for (const manager of managers) { - const task = await ctx.db.notification.create({ - data: { - userId: manager.id, - category: "TASK", - type: "DEMAND_FILL", - priority: "NORMAL", - title: `Staff demand: ${roleName} for ${projectName}`, - body: `${headcount} ${roleName} needed for project ${projectName}`, - taskStatus: "OPEN", - taskAction: buildTaskAction("fill_demand", demandRequirement.id), - entityId: demandRequirement.id, - entityType: "demand", - link: `/projects/${demandRequirement.projectId}`, - channel: "in_app", - }, - }); - emitNotificationCreated(manager.id, task.id); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - void checkBudgetThresholds(ctx.db as any, demandRequirement.projectId); - // Fire-and-forget: compute and notify top-3 staffing suggestions - // eslint-disable-next-line @typescript-eslint/no-explicit-any - void generateAutoSuggestions(ctx.db as any, demandRequirement.id); - return demandRequirement; + createDemand: managerProcedure + .input(z.object({ + projectId: z.string(), + role: z.string().optional(), + roleId: z.string().optional(), + headcount: z.number().int().positive().default(1), + hoursPerDay: z.number().min(0.5).max(24), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + budgetCents: z.number().int().min(0).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + })) + .mutation(async ({ ctx, input }) => { + requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); + return createDemandRequirementWithEffects( + ctx.db, + buildCreateDemandRequirementInput(input), + ); }), updateDemandRequirement: managerProcedure @@ -558,9 +919,8 @@ export const allocationRouter = createTRPCRouter({ projectId: updated.projectId, resourceId: null, }); - void invalidateDashboardCache(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - void checkBudgetThresholds(ctx.db as any, updated.projectId); + invalidateDashboardCacheInBackground(); + checkBudgetThresholdsInBackground(ctx.db, updated.projectId); return updated; }), @@ -582,13 +942,106 @@ export const allocationRouter = createTRPCRouter({ projectId: assignment.projectId, resourceId: assignment.resourceId, }); - void invalidateDashboardCache(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - void checkBudgetThresholds(ctx.db as any, assignment.projectId); + invalidateDashboardCacheInBackground(); + checkBudgetThresholdsInBackground(ctx.db, assignment.projectId); return assignment; }), + ensureAssignment: managerProcedure + .input(z.object({ + resourceId: z.string(), + projectId: z.string(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + hoursPerDay: z.number().min(0.5).max(24), + role: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); + + const existing = (await ctx.db.assignment.findMany({ + where: { + resourceId: input.resourceId, + projectId: input.projectId, + }, + include: ASSIGNMENT_INCLUDE, + orderBy: { startDate: "asc" }, + })).find((assignment) => ( + toIsoDate(assignment.startDate) === toIsoDate(input.startDate) + && toIsoDate(assignment.endDate) === toIsoDate(input.endDate) + )); + + if (existing) { + if (existing.status !== AllocationStatus.CANCELLED) { + throw new TRPCError({ + code: "CONFLICT", + message: `An allocation already exists for this resource/project/dates with status ${existing.status}.`, + }); + } + + const resource = await ctx.db.resource.findUnique({ + where: { id: input.resourceId }, + select: { lcrCents: true }, + }); + if (!resource) { + throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); + } + + const updated = await ctx.db.$transaction(async (tx) => updateAssignment( + tx as unknown as Parameters[0], + existing.id, + { + status: AllocationStatus.PROPOSED, + hoursPerDay: input.hoursPerDay, + percentage: (input.hoursPerDay / 8) * 100, + dailyCostCents: Math.round(resource.lcrCents * input.hoursPerDay), + ...(input.role ? { role: input.role } : {}), + }, + )); + + emitAllocationUpdated({ + id: updated.id, + projectId: updated.projectId, + resourceId: updated.resourceId, + }); + dispatchAllocationWebhookInBackground(ctx.db, "allocation.updated", { + id: updated.id, + projectId: updated.projectId, + resourceId: updated.resourceId, + }); + invalidateDashboardCacheInBackground(); + checkBudgetThresholdsInBackground(ctx.db, updated.projectId); + + return { assignment: updated, action: "reactivated" as const }; + } + + const assignment = await ctx.db.$transaction(async (tx) => createAssignment( + tx as unknown as Parameters[0], + { + resourceId: input.resourceId, + projectId: input.projectId, + startDate: input.startDate, + endDate: input.endDate, + hoursPerDay: input.hoursPerDay, + percentage: (input.hoursPerDay / 8) * 100, + status: AllocationStatus.PROPOSED, + metadata: {}, + ...(input.role ? { role: input.role } : {}), + }, + )); + + emitAllocationCreated({ + id: assignment.id, + projectId: assignment.projectId, + resourceId: assignment.resourceId, + }); + invalidateDashboardCacheInBackground(); + checkBudgetThresholdsInBackground(ctx.db, assignment.projectId); + + return { assignment, action: "created" as const }; + }), + updateAssignment: managerProcedure .input(z.object({ id: z.string(), data: UpdateAssignmentSchema })) .mutation(async ({ ctx, input }) => { @@ -607,14 +1060,13 @@ export const allocationRouter = createTRPCRouter({ projectId: updated.projectId, resourceId: updated.resourceId, }); - void dispatchWebhooks(ctx.db, "allocation.updated", { + dispatchAllocationWebhookInBackground(ctx.db, "allocation.updated", { id: updated.id, projectId: updated.projectId, resourceId: updated.resourceId, }); - void invalidateDashboardCache(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - void checkBudgetThresholds(ctx.db as any, updated.projectId); + invalidateDashboardCacheInBackground(); + checkBudgetThresholdsInBackground(ctx.db, updated.projectId); return updated; }), @@ -649,13 +1101,12 @@ export const allocationRouter = createTRPCRouter({ }); emitAllocationDeleted(existing.id, existing.projectId); - void dispatchWebhooks(ctx.db, "allocation.deleted", { + dispatchAllocationWebhookInBackground(ctx.db, "allocation.deleted", { id: existing.id, projectId: existing.projectId, }); - void invalidateDashboardCache(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - void checkBudgetThresholds(ctx.db as any, existing.projectId); + invalidateDashboardCacheInBackground(); + checkBudgetThresholdsInBackground(ctx.db, existing.projectId); return { success: true }; }), @@ -664,32 +1115,26 @@ export const allocationRouter = createTRPCRouter({ .input(FillDemandRequirementSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); + return fillDemandRequirementWithEffects(ctx.db, input); + }), - const result = await fillDemandRequirement(ctx.db, input); + assignResourceToDemand: managerProcedure + .input(z.object({ + demandRequirementId: z.string(), + resourceId: z.string(), + })) + .mutation(async ({ ctx, input }) => { + requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); + const result = await fillDemandRequirementWithEffects(ctx.db, input); + const demandRequirement = await getDemandRequirementByIdOrThrow( + ctx.db, + input.demandRequirementId, + ); - emitAllocationCreated({ - id: result.assignment.id, - projectId: result.assignment.projectId, - resourceId: result.assignment.resourceId, - }); - - emitAllocationUpdated({ - id: result.updatedDemandRequirement.id, - projectId: result.updatedDemandRequirement.projectId, - resourceId: null, - }); - void invalidateDashboardCache(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - void checkBudgetThresholds(ctx.db as any, result.assignment.projectId); - - // If there are still unfilled slots, refresh suggestions for remaining demand - if (result.updatedDemandRequirement.headcount > 0 - && result.updatedDemandRequirement.status !== "COMPLETED") { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - void generateAutoSuggestions(ctx.db as any, result.updatedDemandRequirement.id); - } - - return result; + return { + ...result, + demandRequirement, + }; }), fillOpenDemandByAllocation: managerProcedure @@ -704,9 +1149,8 @@ export const allocationRouter = createTRPCRouter({ if (result.updatedAllocation) { emitAllocationUpdated(result.updatedAllocation); } - void invalidateDashboardCache(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - void checkBudgetThresholds(ctx.db as any, result.createdAllocation.projectId as string); + invalidateDashboardCacheInBackground(); + checkBudgetThresholdsInBackground(ctx.db, result.createdAllocation.projectId as string); return result; }), @@ -749,9 +1193,8 @@ export const allocationRouter = createTRPCRouter({ projectId: updated.projectId, resourceId: updated.resourceId, }); - void invalidateDashboardCache(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - void checkBudgetThresholds(ctx.db as any, updated.projectId); + invalidateDashboardCacheInBackground(); + checkBudgetThresholdsInBackground(ctx.db, updated.projectId); return updated; }), @@ -786,9 +1229,8 @@ export const allocationRouter = createTRPCRouter({ }); emitAllocationDeleted(existing.id, existing.projectId); - void invalidateDashboardCache(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - void checkBudgetThresholds(ctx.db as any, existing.projectId); + invalidateDashboardCacheInBackground(); + checkBudgetThresholdsInBackground(ctx.db, existing.projectId); return { success: true }; }), @@ -816,9 +1258,8 @@ export const allocationRouter = createTRPCRouter({ }); emitAllocationDeleted(existing.entry.id, existing.projectId); - void invalidateDashboardCache(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - void checkBudgetThresholds(ctx.db as any, existing.projectId); + invalidateDashboardCacheInBackground(); + checkBudgetThresholdsInBackground(ctx.db, existing.projectId); return { success: true }; }), @@ -853,12 +1294,11 @@ export const allocationRouter = createTRPCRouter({ for (const a of existing) { emitAllocationDeleted(a.entry.id, a.projectId); } - void invalidateDashboardCache(); + invalidateDashboardCacheInBackground(); // Check budget thresholds for each affected project const affectedProjectIds = [...new Set(existing.map((a) => a.projectId))]; for (const pid of affectedProjectIds) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - void checkBudgetThresholds(ctx.db as any, pid); + checkBudgetThresholdsInBackground(ctx.db, pid); } return { count: existing.length }; @@ -904,12 +1344,11 @@ export const allocationRouter = createTRPCRouter({ for (const a of updated) { emitAllocationUpdated({ id: a.id, projectId: a.projectId, resourceId: a.resourceId }); } - void invalidateDashboardCache(); + invalidateDashboardCacheInBackground(); // Check budget thresholds for each affected project const affectedProjectIds = [...new Set(updated.map((a) => a.projectId))]; for (const pid of affectedProjectIds) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - void checkBudgetThresholds(ctx.db as any, pid); + checkBudgetThresholdsInBackground(ctx.db, pid); } return { count: updated.length }; diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts index b35bed9..51b30dd 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -3,53 +3,41 @@ * Each tool has a JSON schema (for the AI) and an execute function (for the server). */ -import { prisma, Prisma, ImportBatchStatus } from "@capakraken/db"; -import { checkDuplicateAssignment } from "@capakraken/engine/allocation"; -import { computeBudgetStatus } from "@capakraken/engine"; +import { prisma, Prisma, ImportBatchStatus, StagedRecordStatus, DispoStagedRecordType, VacationType } from "@capakraken/db"; import { + CreateAssignmentSchema, + CreateClientSchema, CreateCountrySchema, + type CreateEstimateInput, CreateHolidayCalendarEntrySchema, CreateHolidayCalendarSchema, + CreateProjectSchema, + CreateResourceSchema, CreateMetroCitySchema, + CreateOrgUnitSchema, + CreateRoleSchema, AllocationStatus, + EstimateExportFormat, + EstimateStatus, PermissionKey, PreviewResolvedHolidaysSchema, SystemRole, + UpdateClientSchema, + type UpdateEstimateDraftInput, UpdateCountrySchema, UpdateHolidayCalendarEntrySchema, UpdateHolidayCalendarSchema, + UpdateAssignmentSchema, UpdateMetroCitySchema, - parseTaskAction, + UpdateOrgUnitSchema, + UpdateProjectSchema, + UpdateRoleSchema, + UpdateResourceSchema, } from "@capakraken/shared"; import type { WeekdayAvailability } from "@capakraken/shared"; -import { getDashboardBudgetForecast, getDashboardPeakTimes } from "@capakraken/application"; -import { createAiClient, createDalleClient, isAiConfigured, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js"; -import { getTaskAction } from "../lib/task-actions.js"; +import { TRPCError } from "@trpc/server"; import { fmtEur } from "../lib/format-utils.js"; -import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js"; -import { - countEffectiveWorkingDays, - calculateEffectiveAvailableHours, - calculateEffectiveBookedHours, - getAvailabilityHoursForDate, - loadResourceDailyAvailabilityContexts, -} from "../lib/resource-capacity.js"; -import { - loadTimelineEntriesReadModel, - loadTimelineHolidayOverlays, - loadTimelineProjectContext, - previewTimelineProjectShift, - timelineRouter, - type TimelineEntriesFilters, -} from "./timeline.js"; -import { resolveRecipients } from "../lib/notification-targeting.js"; -import { - emitNotificationCreated, - emitTaskAssigned, - emitTaskCompleted, - emitTaskStatusChanged, - emitBroadcastSent, -} from "../sse/event-bus.js"; +import { timelineRouter } from "./timeline.js"; import { logger } from "../lib/logger.js"; import { createCallerFactory, type TRPCContext } from "../trpc.js"; import { auditLogRouter } from "./audit-log.js"; @@ -57,11 +45,53 @@ import { chargeabilityReportRouter } from "./chargeability-report.js"; import { computationGraphRouter } from "./computation-graph.js"; import { dispoRouter } from "./dispo.js"; import { importExportRouter } from "./import-export.js"; +import { resourceRouter } from "./resource.js"; +import { settingsRouter } from "./settings.js"; +import { systemRoleConfigRouter } from "./system-role-config.js"; +import { userRouter } from "./user.js"; +import { notificationRouter } from "./notification.js"; +import { estimateRouter } from "./estimate.js"; +import { webhookRouter } from "./webhook.js"; +import { countryRouter } from "./country.js"; +import { holidayCalendarRouter } from "./holiday-calendar.js"; +import { blueprintRouter } from "./blueprint.js"; +import { roleRouter } from "./role.js"; +import { clientRouter } from "./client.js"; +import { orgUnitRouter } from "./org-unit.js"; +import { projectRouter } from "./project.js"; +import { rateCardRouter } from "./rate-card.js"; +import { reportRouter } from "./report.js"; +import { vacationRouter } from "./vacation.js"; +import { entitlementRouter } from "./entitlement.js"; +import { commentRouter } from "./comment.js"; +import { managementLevelRouter } from "./management-level.js"; +import { utilizationCategoryRouter } from "./utilization-category.js"; +import { calculationRuleRouter } from "./calculation-rules.js"; +import { effortRuleRouter } from "./effort-rule.js"; +import { experienceMultiplierRouter } from "./experience-multiplier.js"; +import { dashboardRouter } from "./dashboard.js"; +import { insightsRouter } from "./insights.js"; +import { scenarioRouter } from "./scenario.js"; +import { allocationRouter } from "./allocation.js"; +import { staffingRouter } from "./staffing.js"; // ─── Mutation tool set for audit logging (EGAI 4.1.3.1 / IAAI 3.6.26) ────── export const MUTATION_TOOLS = new Set([ "import_csv_data", + "update_system_settings", + "test_ai_connection", + "test_smtp_connection", + "test_gemini_connection", + "update_system_role_config", + "create_webhook", + "update_webhook", + "delete_webhook", + "test_webhook", + "stage_dispo_import_batch", + "cancel_dispo_import_batch", + "resolve_dispo_staged_record", + "commit_dispo_import_batch", "create_allocation", "cancel_allocation", "update_allocation_status", "update_timeline_allocation_inline", "apply_timeline_project_shift", "quick_assign_timeline_resource", "batch_quick_assign_timeline_resources", @@ -81,6 +111,17 @@ export const MUTATION_TOOLS = new Set([ "send_broadcast", "create_task_for_user", "create_reminder", "update_task_status", "execute_task_action", "create_comment", "resolve_comment", "mark_notification_read", + "save_dashboard_layout", "toggle_favorite_project", + "set_column_preferences", "generate_totp_secret", "verify_and_enable_totp", + "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", "disable_user_totp", + "create_notification", "update_reminder", "delete_reminder", + "delete_notification", "assign_task", + "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", ]); export const ADVANCED_ASSISTANT_TOOLS = new Set([ @@ -129,6 +170,35 @@ const createTimelineCaller = createCallerFactory(timelineRouter); const createAuditLogCaller = createCallerFactory(auditLogRouter); const createImportExportCaller = createCallerFactory(importExportRouter); const createDispoCaller = createCallerFactory(dispoRouter); +const createResourceCaller = createCallerFactory(resourceRouter); +const createSettingsCaller = createCallerFactory(settingsRouter); +const createSystemRoleConfigCaller = createCallerFactory(systemRoleConfigRouter); +const createUserCaller = createCallerFactory(userRouter); +const createNotificationCaller = createCallerFactory(notificationRouter); +const createEstimateCaller = createCallerFactory(estimateRouter); +const createWebhookCaller = createCallerFactory(webhookRouter); +const createCountryCaller = createCallerFactory(countryRouter); +const createHolidayCalendarCaller = createCallerFactory(holidayCalendarRouter); +const createBlueprintCaller = createCallerFactory(blueprintRouter); +const createRoleCaller = createCallerFactory(roleRouter); +const createClientCaller = createCallerFactory(clientRouter); +const createOrgUnitCaller = createCallerFactory(orgUnitRouter); +const createProjectCaller = createCallerFactory(projectRouter); +const createRateCardCaller = createCallerFactory(rateCardRouter); +const createReportCaller = createCallerFactory(reportRouter); +const createVacationCaller = createCallerFactory(vacationRouter); +const createEntitlementCaller = createCallerFactory(entitlementRouter); +const createCommentCaller = createCallerFactory(commentRouter); +const createManagementLevelCaller = createCallerFactory(managementLevelRouter); +const createUtilizationCategoryCaller = createCallerFactory(utilizationCategoryRouter); +const createCalculationRuleCaller = createCallerFactory(calculationRuleRouter); +const createEffortRuleCaller = createCallerFactory(effortRuleRouter); +const createExperienceMultiplierCaller = createCallerFactory(experienceMultiplierRouter); +const createDashboardCaller = createCallerFactory(dashboardRouter); +const createInsightsCaller = createCallerFactory(insightsRouter); +const createScenarioCaller = createCallerFactory(scenarioRouter); +const createAllocationCaller = createCallerFactory(allocationRouter); +const createStaffingCaller = createCallerFactory(staffingRouter); // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -148,17 +218,6 @@ function assertAdminRole(ctx: ToolContext): void { } } -function clampHolidayCalendarDate(date: Date): Date { - const value = new Date(date); - value.setUTCHours(0, 0, 0, 0); - return value; -} - -const HOLIDAY_CALENDAR_ENTRY_ORDER_BY: Prisma.HolidayCalendarEntryOrderByWithRelationInput[] = [ - { date: "asc" }, - { name: "asc" }, -]; - function formatHolidayCalendarEntry(entry: { id: string; date: Date; @@ -246,166 +305,6 @@ function formatCountry(country: { }; } -async function findCountryByIdentifier( - db: ToolContext["db"], - identifier: string, -) { - const trimmed = identifier.trim(); - let country = await db.country.findUnique({ - where: { id: trimmed }, - include: { - metroCities: { orderBy: { name: "asc" } }, - _count: { select: { resources: true } }, - }, - }); - if (!country) { - country = await db.country.findFirst({ - where: { code: { equals: trimmed.toUpperCase(), mode: "insensitive" } }, - include: { - metroCities: { orderBy: { name: "asc" } }, - _count: { select: { resources: true } }, - }, - }); - } - if (!country) { - country = await db.country.findFirst({ - where: { name: { equals: trimmed, mode: "insensitive" } }, - include: { - metroCities: { orderBy: { name: "asc" } }, - _count: { select: { resources: true } }, - }, - }); - } - if (!country) { - country = await db.country.findFirst({ - where: { name: { contains: trimmed, mode: "insensitive" } }, - include: { - metroCities: { orderBy: { name: "asc" } }, - _count: { select: { resources: true } }, - }, - }); - } - - return country; -} - -async function findHolidayCalendarByIdentifier( - db: ToolContext["db"], - identifier: string, -) { - let calendar = await db.holidayCalendar.findUnique({ - where: { id: identifier }, - include: { - country: { select: { id: true, code: true, name: true } }, - metroCity: { select: { id: true, name: true } }, - entries: { orderBy: HOLIDAY_CALENDAR_ENTRY_ORDER_BY }, - }, - }); - if (!calendar) { - calendar = await db.holidayCalendar.findFirst({ - where: { name: { equals: identifier, mode: "insensitive" } }, - include: { - country: { select: { id: true, code: true, name: true } }, - metroCity: { select: { id: true, name: true } }, - entries: { orderBy: HOLIDAY_CALENDAR_ENTRY_ORDER_BY }, - }, - }); - } - if (!calendar) { - calendar = await db.holidayCalendar.findFirst({ - where: { name: { contains: identifier, mode: "insensitive" } }, - include: { - country: { select: { id: true, code: true, name: true } }, - metroCity: { select: { id: true, name: true } }, - entries: { orderBy: HOLIDAY_CALENDAR_ENTRY_ORDER_BY }, - }, - }); - } - - return calendar; -} - -async function assertHolidayCalendarEntryDateAvailable( - db: ToolContext["db"], - input: { - holidayCalendarId: string; - date: Date; - }, - ignoreId?: string, -) { - const existing = await db.holidayCalendarEntry.findFirst({ - where: { - holidayCalendarId: input.holidayCalendarId, - date: clampHolidayCalendarDate(input.date), - ...(ignoreId ? { id: { not: ignoreId } } : {}), - }, - select: { id: true }, - }); - - if (existing) { - throw new Error("A holiday entry for this calendar and date already exists"); - } -} - -async function assertHolidayCalendarScopeConsistency( - db: ToolContext["db"], - input: { - scopeType: "COUNTRY" | "STATE" | "CITY"; - countryId: string; - stateCode?: string | null; - metroCityId?: string | null; - }, - ignoreId?: string, -) { - if (input.scopeType === "COUNTRY") { - if (input.stateCode || input.metroCityId) { - throw new Error("Country calendars may not define a state or metro city"); - } - } - - if (input.scopeType === "STATE") { - if (!input.stateCode) { - throw new Error("State calendars require a state code"); - } - if (input.metroCityId) { - throw new Error("State calendars may not define a metro city"); - } - } - - if (input.scopeType === "CITY") { - if (!input.metroCityId) { - throw new Error("City calendars require a metro city"); - } - - const metroCity = await db.metroCity.findUnique({ - where: { id: input.metroCityId }, - select: { id: true, countryId: true }, - }); - - if (!metroCity) { - throw new Error("Metro city not found"); - } - if (metroCity.countryId !== input.countryId) { - throw new Error("Metro city must belong to the selected country"); - } - } - - const existing = await db.holidayCalendar.findFirst({ - where: { - countryId: input.countryId, - scopeType: input.scopeType, - ...(input.scopeType === "STATE" ? { stateCode: input.stateCode ?? null } : {}), - ...(input.scopeType === "CITY" ? { metroCityId: input.metroCityId ?? null } : {}), - ...(ignoreId ? { id: { not: ignoreId } } : {}), - }, - select: { id: true }, - }); - - if (existing) { - throw new Error("A holiday calendar for this exact scope already exists"); - } -} - function createUtcDate(year: number, monthIndex: number, day: number): Date { return new Date(Date.UTC(year, monthIndex, day)); } @@ -443,198 +342,23 @@ function resolveHolidayPeriod(input: { }; } -function formatResolvedHoliday(holiday: { - date: string; - name: string; - scope: string; - calendarName: string; - sourceType: string; -}) { - return { - date: holiday.date, - name: holiday.name, - scope: holiday.scope, - calendarName: holiday.calendarName, - sourceType: holiday.sourceType, - }; -} +const ASSISTANT_VACATION_REQUEST_TYPES = [ + VacationType.ANNUAL, + VacationType.SICK, + VacationType.OTHER, +] as const; -function summarizeResolvedHolidays(holidays: Array<{ - date: string; - name: string; - scope: string; - calendarName: string; - sourceType: string; -}>) { - const byScope = new Map(); - const bySourceType = new Map(); - const byCalendar = new Map(); - - 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); +function parseAssistantVacationRequestType(input: string): VacationType { + const normalized = input.trim().toUpperCase(); + if (normalized === VacationType.PUBLIC_HOLIDAY) { + throw new Error("PUBLIC_HOLIDAY requests cannot be created manually. Manage public holidays through holiday calendars instead."); } - 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 })), - }; -} - -function round1(value: number): number { - return Math.round(value * 10) / 10; -} - -function averagePerWorkingDay(totalHours: number, workingDays: number): number { - return workingDays > 0 ? round1(totalHours / workingDays) : 0; -} - -function createDateRange(input: { - startDate?: string | undefined; - endDate?: string | undefined; - durationDays?: number | undefined; -}): { startDate: Date; endDate: Date } { - const startDate = input.startDate - ? new Date(`${input.startDate}T00:00:00.000Z`) - : createUtcDate(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate()); - - if (Number.isNaN(startDate.getTime())) { - throw new Error(`Invalid startDate: ${input.startDate}`); + if ((ASSISTANT_VACATION_REQUEST_TYPES as readonly string[]).includes(normalized)) { + return normalized as VacationType; } - 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 Error(`Invalid endDate: ${input.endDate}`); - } - if (endDate < startDate) { - throw new Error("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 { - 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 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(); - const resourceIds = new Set(); - - 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, -) { - const resourceIds = new Set(); - const byScope = new Map(); - - 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; + throw new Error(`Invalid vacation type: ${input}. Valid types: ${ASSISTANT_VACATION_REQUEST_TYPES.join(", ")}.`); } function parseIsoDate(value: string, fieldName: string): Date { @@ -649,78 +373,53 @@ function toDate(value: Date | string): Date { return value instanceof Date ? value : new Date(value); } +type AssistantToolErrorResult = { error: string }; + +function toAssistantNotFoundError( + error: unknown, + message: string, +): AssistantToolErrorResult | null { + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + return { error: message }; + } + return null; +} + +async function resolveEntityOrAssistantError( + resolve: () => Promise, + notFoundMessage: string, +): Promise { + try { + return await resolve(); + } catch (error) { + const mapped = toAssistantNotFoundError(error, notFoundMessage); + if (mapped) { + return mapped; + } + throw error; + } +} + async function resolveProjectIdentifier( + ctx: ToolContext, identifier: string, - db: ToolContext["db"], -): Promise<{ - id: string; - name: string; - shortCode: string; - status: string; - responsiblePerson: string | null; -} | { error: string }> { - const select = { - id: true, - name: true, - shortCode: true, - status: true, - responsiblePerson: true, - } as const; - - let project = await db.project.findUnique({ - where: { id: identifier }, - select, - }); - if (!project) { - project = await db.project.findUnique({ - where: { shortCode: identifier }, - select, - }); - } - if (!project) { - project = await db.project.findFirst({ - where: { name: { contains: identifier, mode: "insensitive" } }, - select, - }); - } - if (!project) { - return { error: `Project not found: ${identifier}` }; - } - - return project; +) { + const caller = createProjectCaller(createScopedCallerContext(ctx)); + return resolveEntityOrAssistantError( + () => caller.resolveByIdentifier({ identifier }), + `Project not found: ${identifier}`, + ); } async function resolveResourceIdentifier( + ctx: ToolContext, identifier: string, - db: ToolContext["db"], -): Promise<{ id: string; eid: string; displayName: string } | { error: string }> { - const select = { - id: true, - eid: true, - displayName: true, - } as const; - - let resource = await db.resource.findUnique({ - where: { id: identifier }, - select, - }); - if (!resource) { - resource = await db.resource.findUnique({ - where: { eid: identifier }, - select, - }); - } - if (!resource) { - resource = await db.resource.findFirst({ - where: { displayName: { contains: identifier, mode: "insensitive" } }, - select, - }); - } - if (!resource) { - return { error: `Resource not found: ${identifier}` }; - } - - return resource; +) { + const caller = createResourceCaller(createScopedCallerContext(ctx)); + return resolveEntityOrAssistantError( + () => caller.resolveByIdentifier({ identifier }), + `Resource not found: ${identifier}`, + ); } function createScopedCallerContext(ctx: ToolContext): TRPCContext { @@ -736,83 +435,16 @@ function createScopedCallerContext(ctx: ToolContext): TRPCContext { }; } -function formatAuditListEntry(entry: { - id: string; - entityType: string; - entityId: string; - entityName?: string | null; - action: string; - userId?: string | null; - source?: string | null; - summary?: string | null; - createdAt: Date; - user?: { id: string; name: string | null; email: string | null } | null; -}) { +function sanitizeWebhook(webhook: T) { + const { secret: _secret, ...rest } = webhook; 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, + ...rest, + hasSecret: Boolean(webhook.secret), }; } -function formatAuditDetailEntry(entry: { - id: string; - entityType: string; - entityId: string; - entityName?: string | null; - action: string; - userId?: string | null; - source?: string | null; - summary?: string | null; - createdAt: Date; - changes?: unknown; - user?: { id: string; name: string | null; email: string | null } | null; -}) { - return { - ...formatAuditListEntry(entry), - changes: entry.changes ?? null, - }; -} - -function filterGraphData< - TNode extends { id: string; domain: string }, - TLink extends { source: string; target: string }, ->(input: { - nodes: TNode[]; - links: TLink[]; - domain?: string; - includeLinks?: boolean; -}) { - const requestedDomain = input.domain?.trim().toUpperCase(); - const nodes = requestedDomain - ? input.nodes.filter((node) => node.domain === requestedDomain) - : input.nodes; - const selectedNodeIds = new Set(nodes.map((node) => node.id)); - const links = input.includeLinks - ? input.links.filter((link) => selectedNodeIds.has(link.source) && selectedNodeIds.has(link.target)) - : []; - - return { - requestedDomain: requestedDomain ?? null, - includedLinks: input.includeLinks ?? false, - selectedNodeCount: nodes.length, - selectedLinkCount: links.length, - nodes, - ...(input.includeLinks ? { links } : {}), - }; +function sanitizeWebhookList(webhooks: T[]) { + return webhooks.map((webhook) => sanitizeWebhook(webhook)); } // ─── Tool Definitions ─────────────────────────────────────────────────────── @@ -1117,11 +749,11 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "get_vacation_balance", - description: "Get vacation/leave balance for a resource: entitlement, taken, remaining days.", + description: "Get the holiday-aware vacation balance for a resource via the real entitlement workflow. Authenticated users can read their own balance; manager/admin/controller can read broader balances.", parameters: { type: "object", properties: { - resourceId: { type: "string", description: "Resource ID or name" }, + resourceId: { type: "string", description: "Resource ID, EID, or display name" }, year: { type: "integer", description: "Year. Default: current year" }, }, required: ["resourceId"], @@ -1450,7 +1082,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ properties: { projectCode: { type: "string", description: "Project short code to filter by" }, query: { type: "string", description: "Search term (matches estimate name)" }, - status: { type: "string", description: "Filter by status: DRAFT, SUBMITTED, APPROVED, REJECTED" }, + status: { type: "string", description: "Filter by status: DRAFT, IN_REVIEW, APPROVED, ARCHIVED" }, limit: { type: "integer", description: "Max results. Default: 20" }, }, }, @@ -1555,7 +1187,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ parameters: { type: "object", properties: { - id: { type: "string", description: "Resource ID" }, + id: { type: "string", description: "Resource ID, EID, or display name" }, displayName: { type: "string", description: "New display name" }, fte: { type: "number", description: "New FTE (0.0-1.0)" }, lcrCents: { type: "integer", description: "New LCR in cents (e.g. 8500 = 85.00 EUR/h)" }, @@ -1592,7 +1224,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ parameters: { type: "object", properties: { - id: { type: "string", description: "Project ID" }, + id: { type: "string", description: "Project ID, short code, or project name" }, name: { type: "string", description: "New project name" }, budgetCents: { type: "integer", description: "New budget in cents (e.g. 10000000 = 100,000 EUR)" }, winProbability: { type: "integer", description: "Win probability 0-100" }, @@ -1625,7 +1257,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ blueprintName: { type: "string", description: "Blueprint name to look up and attach (partial match)" }, clientName: { type: "string", description: "Client name to look up and attach (partial match)" }, }, - required: ["shortCode", "name", "orderType", "budgetCents", "startDate", "endDate"], + required: ["shortCode", "name", "orderType", "budgetCents", "startDate", "endDate", "responsiblePerson"], }, }, }, @@ -1652,7 +1284,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ orgUnitName: { type: "string", description: "Org unit name (partial match)" }, postalCode: { type: "string", description: "Postal code" }, }, - required: ["eid", "displayName", "lcrCents"], + required: ["eid", "displayName", "email", "lcrCents"], }, }, }, @@ -1676,12 +1308,16 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "create_vacation", - description: "Create a vacation/leave request. Requires manageVacations permission (or self-service). Always confirm with the user.", + description: "Create a vacation/leave request through the real vacation workflow. Any authenticated user can request leave for their own resource; manager/admin can create requests for others. Always confirm with the user.", parameters: { type: "object", properties: { - resourceId: { type: "string", description: "Resource ID or name" }, - type: { type: "string", description: "Type: VACATION, SICK, PARENTAL, SPECIAL, PUBLIC_HOLIDAY" }, + resourceId: { type: "string", description: "Resource ID, EID, or display name" }, + type: { + type: "string", + enum: ["ANNUAL", "SICK", "OTHER"], + description: "Vacation type. PUBLIC_HOLIDAY requests are managed through holiday calendars, not manual vacation requests.", + }, startDate: { type: "string", description: "Start date YYYY-MM-DD" }, endDate: { type: "string", description: "End date YYYY-MM-DD" }, isHalfDay: { type: "boolean", description: "Half day? Default: false" }, @@ -1696,7 +1332,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "approve_vacation", - description: "Approve a pending vacation request. Requires manageVacations permission. Always confirm first.", + description: "Approve a vacation request through the real vacation workflow. Manager or admin role required. Always confirm first.", parameters: { type: "object", properties: { @@ -1710,7 +1346,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "reject_vacation", - description: "Reject a pending vacation request. Requires manageVacations permission. Always confirm first.", + description: "Reject a pending vacation request through the real vacation workflow. Manager or admin role required. Always confirm first.", parameters: { type: "object", properties: { @@ -1725,7 +1361,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "cancel_vacation", - description: "Cancel a vacation. Requires manageVacations permission. Always confirm first.", + description: "Cancel a vacation request through the real vacation workflow. Users can cancel their own requests; manager/admin can cancel any request. Always confirm first.", parameters: { type: "object", properties: { @@ -1784,14 +1420,13 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "set_entitlement", - description: "Set vacation entitlement for a resource for a year. Requires manageVacations permission. Always confirm first.", + description: "Set the annual vacation entitlement for a resource/year through the real entitlement workflow. Manager or admin role required. Carryover is computed automatically. Always confirm first.", parameters: { type: "object", properties: { - resourceId: { type: "string", description: "Resource ID or name" }, + resourceId: { type: "string", description: "Resource ID, EID, or display name" }, year: { type: "integer", description: "Year" }, entitledDays: { type: "number", description: "Number of entitled vacation days" }, - carryoverDays: { type: "number", description: "Carryover days from previous year" }, }, required: ["resourceId", "year", "entitledDays"], }, @@ -1962,7 +1597,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "get_estimate_detail", - description: "Get detailed estimate with versions, line items, totals, and commercial terms.", + description: "Get one estimate via the real estimate router, including versions, demand lines, metrics, assumptions, and linked project data. Controller/manager/admin access and viewCosts required.", parameters: { type: "object", properties: { @@ -1975,15 +1610,239 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ { type: "function", function: { - name: "create_estimate", - description: "Create a new estimate for a project. Requires manageProjects permission. Always confirm first.", + name: "list_estimate_versions", + description: "List estimate versions via the real estimate router, including status, timestamps, and artifact counts. Controller/manager/admin access required.", parameters: { type: "object", properties: { - name: { type: "string", description: "Estimate name" }, - projectId: { type: "string", description: "Project ID or short code" }, + estimateId: { type: "string", description: "Estimate ID" }, }, - required: ["name", "projectId"], + required: ["estimateId"], + }, + }, + }, + { + type: "function", + function: { + name: "get_estimate_version_snapshot", + description: "Get an estimate version snapshot via the real estimate router, including totals, breakdowns, exports, and resource snapshots. Controller/manager/admin access and viewCosts required.", + parameters: { + type: "object", + properties: { + estimateId: { type: "string", description: "Estimate ID" }, + versionId: { type: "string", description: "Optional explicit version ID. Defaults to the latest version." }, + }, + required: ["estimateId"], + }, + }, + }, + { + type: "function", + function: { + name: "create_estimate", + description: "Create a new estimate via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.", + parameters: { + type: "object", + properties: { + projectId: { type: "string", description: "Optional project ID." }, + projectCode: { type: "string", description: "Optional project short code convenience alias." }, + name: { type: "string", description: "Estimate name." }, + opportunityId: { type: "string", description: "Optional opportunity/reference ID." }, + baseCurrency: { type: "string", description: "Base currency, e.g. EUR." }, + status: { type: "string", enum: ["DRAFT", "IN_REVIEW", "APPROVED", "ARCHIVED"] }, + versionLabel: { type: "string", description: "Optional working version label." }, + versionNotes: { type: "string", description: "Optional working version notes." }, + assumptions: { type: "array", items: { type: "object" }, description: "Estimate assumptions." }, + scopeItems: { type: "array", items: { type: "object" }, description: "Estimate scope items." }, + demandLines: { type: "array", items: { type: "object" }, description: "Estimate demand lines." }, + resourceSnapshots: { type: "array", items: { type: "object" }, description: "Resource cost snapshots." }, + metrics: { type: "array", items: { type: "object" }, description: "Optional metric overrides." }, + }, + required: ["name"], + }, + }, + }, + { + type: "function", + function: { + name: "clone_estimate", + description: "Clone an existing estimate via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.", + parameters: { + type: "object", + properties: { + sourceEstimateId: { type: "string", description: "Source estimate ID." }, + name: { type: "string", description: "Optional cloned estimate name." }, + projectId: { type: "string", description: "Optional target project ID." }, + projectCode: { type: "string", description: "Optional target project short code convenience alias." }, + }, + required: ["sourceEstimateId"], + }, + }, + }, + { + type: "function", + function: { + name: "update_estimate_draft", + description: "Update the working draft of an estimate via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Estimate ID." }, + projectId: { type: "string", description: "Optional linked project ID." }, + projectCode: { type: "string", description: "Optional linked project short code convenience alias." }, + name: { type: "string" }, + opportunityId: { type: "string" }, + baseCurrency: { type: "string" }, + status: { type: "string", enum: ["DRAFT", "IN_REVIEW", "APPROVED", "ARCHIVED"] }, + versionLabel: { type: "string" }, + versionNotes: { type: "string" }, + assumptions: { type: "array", items: { type: "object" } }, + scopeItems: { type: "array", items: { type: "object" } }, + demandLines: { type: "array", items: { type: "object" } }, + resourceSnapshots: { type: "array", items: { type: "object" } }, + metrics: { type: "array", items: { type: "object" } }, + }, + required: ["id"], + }, + }, + }, + { + type: "function", + function: { + name: "submit_estimate_version", + description: "Submit an estimate working version for review via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.", + parameters: { + type: "object", + properties: { + estimateId: { type: "string", description: "Estimate ID." }, + versionId: { type: "string", description: "Optional explicit version ID." }, + }, + required: ["estimateId"], + }, + }, + }, + { + type: "function", + function: { + name: "approve_estimate_version", + description: "Approve a submitted estimate version via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.", + parameters: { + type: "object", + properties: { + estimateId: { type: "string", description: "Estimate ID." }, + versionId: { type: "string", description: "Optional explicit version ID." }, + }, + required: ["estimateId"], + }, + }, + }, + { + type: "function", + function: { + name: "create_estimate_revision", + description: "Create a new working revision from the latest locked estimate version via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.", + parameters: { + type: "object", + properties: { + estimateId: { type: "string", description: "Estimate ID." }, + sourceVersionId: { type: "string", description: "Optional source version ID." }, + label: { type: "string", description: "Optional revision label." }, + notes: { type: "string", description: "Optional revision notes." }, + }, + required: ["estimateId"], + }, + }, + }, + { + type: "function", + function: { + name: "create_estimate_export", + description: "Create an estimate export artifact via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.", + parameters: { + type: "object", + properties: { + estimateId: { type: "string", description: "Estimate ID." }, + versionId: { type: "string", description: "Optional explicit version ID." }, + format: { type: "string", enum: ["XLSX", "CSV", "JSON", "SAP", "MMP"], description: "Export format." }, + }, + required: ["estimateId", "format"], + }, + }, + }, + { + type: "function", + function: { + name: "create_estimate_planning_handoff", + description: "Create planning allocations from an approved estimate version via the real estimate router. Manager/admin role and manageAllocations permission required. Always confirm first.", + parameters: { + type: "object", + properties: { + estimateId: { type: "string", description: "Estimate ID." }, + versionId: { type: "string", description: "Optional explicit approved version ID." }, + }, + required: ["estimateId"], + }, + }, + }, + { + type: "function", + function: { + name: "generate_estimate_weekly_phasing", + description: "Generate weekly phasing for the working estimate version via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.", + parameters: { + type: "object", + properties: { + estimateId: { type: "string", description: "Estimate ID." }, + startDate: { type: "string", description: "Start date in YYYY-MM-DD." }, + endDate: { type: "string", description: "End date in YYYY-MM-DD." }, + pattern: { type: "string", enum: ["even", "front_loaded", "back_loaded", "custom"], description: "Distribution pattern." }, + }, + required: ["estimateId", "startDate", "endDate"], + }, + }, + }, + { + type: "function", + function: { + name: "get_estimate_weekly_phasing", + description: "Get generated weekly phasing for an estimate via the real estimate router. Controller/manager/admin access required.", + parameters: { + type: "object", + properties: { + estimateId: { type: "string", description: "Estimate ID." }, + }, + required: ["estimateId"], + }, + }, + }, + { + type: "function", + function: { + name: "get_estimate_commercial_terms", + description: "Get estimate commercial terms via the real estimate router. Controller/manager/admin access required.", + parameters: { + type: "object", + properties: { + estimateId: { type: "string", description: "Estimate ID." }, + versionId: { type: "string", description: "Optional explicit version ID." }, + }, + required: ["estimateId"], + }, + }, + }, + { + type: "function", + function: { + name: "update_estimate_commercial_terms", + description: "Update estimate commercial terms on a working version via the real estimate router. Manager/admin role and manageProjects permission required. Always confirm first.", + parameters: { + type: "object", + properties: { + estimateId: { type: "string", description: "Estimate ID." }, + versionId: { type: "string", description: "Optional explicit version ID." }, + terms: { type: "object", description: "Commercial terms payload." }, + }, + required: ["estimateId", "terms"], }, }, }, @@ -1993,11 +1852,12 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "create_role", - description: "Create a new role. Requires manageResources permission. Always confirm first.", + description: "Create a new role. Requires manager or admin role plus manageRoles permission. Always confirm first.", parameters: { type: "object", properties: { name: { type: "string", description: "Role name" }, + description: { type: "string", description: "Optional role description" }, color: { type: "string", description: "Hex color (e.g. #3b82f6). Default: #6b7280" }, }, required: ["name"], @@ -2008,13 +1868,15 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "update_role", - description: "Update a role's name or color. Requires manageResources permission. Always confirm first.", + description: "Update a role. Requires manager or admin role plus manageRoles permission. Always confirm first.", parameters: { type: "object", properties: { id: { type: "string", description: "Role ID" }, name: { type: "string", description: "New name" }, + description: { type: "string", description: "New description" }, color: { type: "string", description: "New hex color" }, + isActive: { type: "boolean", description: "Set active state" }, }, required: ["id"], }, @@ -2024,7 +1886,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "delete_role", - description: "Delete a role. Requires manageResources permission. Always confirm first.", + description: "Delete a role. Requires manager or admin role plus manageRoles permission. Always confirm first.", parameters: { type: "object", properties: { @@ -2040,12 +1902,15 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "create_client", - description: "Create a new client. Requires manageProjects permission. Always confirm first.", + description: "Create a new client. Requires manager or admin role. Always confirm first.", parameters: { type: "object", properties: { name: { type: "string", description: "Client name" }, code: { type: "string", description: "Client code" }, + parentId: { type: "string", description: "Optional parent client ID" }, + sortOrder: { type: "integer", description: "Sort order. Default: 0" }, + tags: { type: "array", items: { type: "string" }, description: "Optional client tags" }, }, required: ["name"], }, @@ -2055,13 +1920,17 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "update_client", - description: "Update a client. Requires manageProjects permission. Always confirm first.", + description: "Update a client. Requires manager or admin role. Always confirm first.", parameters: { type: "object", properties: { id: { type: "string", description: "Client ID" }, name: { type: "string", description: "New name" }, code: { type: "string", description: "New code" }, + sortOrder: { type: "integer", description: "New sort order" }, + isActive: { type: "boolean", description: "Set active state" }, + parentId: { type: "string", description: "Parent client ID; use null to clear" }, + tags: { type: "array", items: { type: "string" }, description: "Replacement client tags" }, }, required: ["id"], }, @@ -2234,7 +2103,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "list_users", - description: "List system users with their roles and linked resources. Requires manageUsers permission.", + description: "List all system users via the admin user router, including role and MFA state. Admin role required.", parameters: { type: "object", properties: { @@ -2243,6 +2112,302 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, }, }, + { + type: "function", + function: { + name: "list_assignable_users", + description: "List lightweight users available for assignment workflows. Manager or admin role required.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "get_current_user", + description: "Get the authenticated user's own profile, role, and permission overrides.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "get_dashboard_layout", + description: "Get the authenticated user's saved dashboard widget layout and last update timestamp.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "save_dashboard_layout", + description: "Save the authenticated user's dashboard layout. Always confirm first.", + parameters: { + type: "object", + properties: { + layout: { + type: "array", + description: "Dashboard layout items as stored by the user router.", + items: { type: "object" }, + }, + }, + required: ["layout"], + }, + }, + }, + { + type: "function", + function: { + name: "get_favorite_project_ids", + description: "Get the authenticated user's favorite project IDs.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "toggle_favorite_project", + description: "Add or remove a project from the authenticated user's favorites. Always confirm first.", + parameters: { + type: "object", + properties: { + projectId: { type: "string", description: "Project ID." }, + }, + required: ["projectId"], + }, + }, + }, + { + type: "function", + function: { + name: "get_column_preferences", + description: "Get the authenticated user's saved table column preferences for all supported views.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "set_column_preferences", + description: "Update the authenticated user's table column preferences for one view. Always confirm first.", + parameters: { + type: "object", + properties: { + view: { + type: "string", + enum: ["resources", "projects", "allocations", "vacations", "roles", "users", "blueprints"], + description: "View key to update.", + }, + visible: { + type: "array", + items: { type: "string" }, + description: "Visible column IDs.", + }, + sort: { + type: ["object", "null"], + properties: { + field: { type: "string" }, + dir: { type: "string", enum: ["asc", "desc"] }, + }, + description: "Sort state. Use null to clear it.", + }, + rowOrder: { + type: ["array", "null"], + items: { type: "string" }, + description: "Optional row order. Use null to clear it.", + }, + }, + required: ["view"], + }, + }, + }, + { + type: "function", + function: { + name: "generate_totp_secret", + description: "Generate a new MFA TOTP secret and provisioning URI for the authenticated user. Always confirm first. The secret is sensitive.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "verify_and_enable_totp", + description: "Verify a 6-digit MFA TOTP token and enable MFA for the authenticated user. Always confirm first.", + parameters: { + type: "object", + properties: { + token: { type: "string", description: "6-digit TOTP token." }, + }, + required: ["token"], + }, + }, + }, + { + type: "function", + function: { + name: "get_mfa_status", + description: "Get the authenticated user's MFA status.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "get_active_user_count", + description: "Get the number of users active in the last five minutes. Admin role required.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "create_user", + description: "Create a new system user and auto-link a matching resource by email when possible. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + email: { type: "string", description: "User email address." }, + name: { type: "string", description: "Display name." }, + systemRole: { type: "string", enum: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"], description: "Initial system role." }, + password: { type: "string", description: "Initial password, minimum 8 characters." }, + }, + required: ["email", "name", "password"], + }, + }, + }, + { + type: "function", + function: { + name: "set_user_password", + description: "Reset a user's password. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + userId: { type: "string", description: "User ID." }, + password: { type: "string", description: "New password, minimum 8 characters." }, + }, + required: ["userId", "password"], + }, + }, + }, + { + type: "function", + function: { + name: "update_user_role", + description: "Change a user's system role. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "User ID." }, + systemRole: { type: "string", enum: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, + }, + required: ["id", "systemRole"], + }, + }, + }, + { + type: "function", + function: { + name: "update_user_name", + description: "Rename a user. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "User ID." }, + name: { type: "string", description: "New display name." }, + }, + required: ["id", "name"], + }, + }, + }, + { + type: "function", + function: { + name: "link_user_resource", + description: "Link or unlink a user to a resource. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + userId: { type: "string", description: "User ID." }, + resourceId: { type: ["string", "null"], description: "Resource ID or null to unlink." }, + }, + required: ["userId"], + }, + }, + }, + { + type: "function", + function: { + name: "auto_link_users_by_email", + description: "Auto-link all users without a resource to matching resources by email. Admin role required. Always confirm first.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "set_user_permissions", + description: "Set explicit permission overrides for a user. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + userId: { type: "string", description: "User ID." }, + overrides: { + type: ["object", "null"], + properties: { + granted: { type: "array", items: { type: "string" } }, + denied: { type: "array", items: { type: "string" } }, + chapterIds: { type: "array", items: { type: "string" } }, + }, + description: "Permission override object or null to clear.", + }, + }, + required: ["userId"], + }, + }, + }, + { + type: "function", + function: { + name: "reset_user_permissions", + description: "Reset a user's permission overrides back to role defaults. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + userId: { type: "string", description: "User ID." }, + }, + required: ["userId"], + }, + }, + }, + { + type: "function", + function: { + name: "get_effective_user_permissions", + description: "Get a user's resolved permissions, role, and explicit overrides. Admin role required.", + parameters: { + type: "object", + properties: { + userId: { type: "string", description: "User ID." }, + }, + required: ["userId"], + }, + }, + }, + { + type: "function", + function: { + name: "disable_user_totp", + description: "Disable MFA TOTP for a user as an admin override. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + userId: { type: "string", description: "User ID." }, + }, + required: ["userId"], + }, + }, + }, { type: "function", function: { @@ -2261,13 +2426,48 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "mark_notification_read", - description: "Mark a notification as read.", + description: "Mark one notification as read, or all unread notifications when no notificationId is provided. Always confirm first.", parameters: { type: "object", properties: { - notificationId: { type: "string", description: "Notification ID" }, + notificationId: { type: "string", description: "Notification ID. Omit to mark all unread notifications as read." }, }, - required: ["notificationId"], + }, + }, + }, + { + type: "function", + function: { + name: "get_unread_notification_count", + description: "Count unread notifications for the current user.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "create_notification", + description: "Create a notification or task-style notification for a specific user. Manager or admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + userId: { type: "string", description: "Target user ID." }, + type: { type: "string", description: "Notification type code." }, + title: { type: "string", description: "Title." }, + body: { type: "string", description: "Optional body text." }, + entityId: { type: "string", description: "Optional linked entity ID." }, + entityType: { type: "string", description: "Optional linked entity type." }, + category: { type: "string", enum: ["NOTIFICATION", "REMINDER", "TASK", "APPROVAL"] }, + priority: { type: "string", enum: ["LOW", "NORMAL", "HIGH", "URGENT"] }, + link: { type: "string", description: "Optional deep link." }, + taskStatus: { type: "string", enum: ["OPEN", "IN_PROGRESS", "DONE", "DISMISSED"] }, + taskAction: { type: "string", description: "Optional machine-readable task action." }, + assigneeId: { type: "string", description: "Optional assignee user ID." }, + dueDate: { type: "string", format: "date-time", description: "Optional due date." }, + channel: { type: "string", enum: ["in_app", "email", "both"] }, + senderId: { type: "string", description: "Optional sender override." }, + }, + required: ["userId", "type", "title"], }, }, }, @@ -2311,7 +2511,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "create_org_unit", - description: "Create a new organizational unit. Requires manageResources permission. Always confirm first.", + description: "Create a new organizational unit. Admin role required. Always confirm first.", parameters: { type: "object", properties: { @@ -2319,6 +2519,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ shortName: { type: "string", description: "Short name/code" }, level: { type: "integer", description: "Level (5, 6, or 7)" }, parentId: { type: "string", description: "Parent org unit ID (optional)" }, + sortOrder: { type: "integer", description: "Sort order. Default: 0" }, }, required: ["name", "level"], }, @@ -2328,13 +2529,16 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "update_org_unit", - description: "Update an organizational unit. Requires manageResources permission. Always confirm first.", + description: "Update an organizational unit. Admin role required. Always confirm first.", parameters: { type: "object", properties: { id: { type: "string", description: "Org unit ID" }, name: { type: "string", description: "New name" }, shortName: { type: "string", description: "New short name" }, + sortOrder: { type: "integer", description: "New sort order" }, + isActive: { type: "boolean", description: "Set active state" }, + parentId: { type: "string", description: "Parent org unit ID; use null to clear" }, }, required: ["id"], }, @@ -2377,16 +2581,25 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "list_tasks", - description: "List open/pending tasks and approvals for the current user. Returns actionable items that need attention.", + description: "List tasks and approvals for the current user via the real notification router, optionally including tasks assigned to them.", parameters: { type: "object", properties: { - status: { type: "string", enum: ["OPEN", "IN_PROGRESS", "DONE", "DISMISSED"], description: "Filter by status. Default: OPEN" }, - limit: { type: "integer", description: "Max results (default 10)" }, + status: { type: "string", enum: ["OPEN", "IN_PROGRESS", "DONE", "DISMISSED"], description: "Optional status filter." }, + includeAssigned: { type: "boolean", description: "Include tasks where the current user is assignee as well as owner. Default: true." }, + limit: { type: "integer", description: "Max results. Default: 20." }, }, }, }, }, + { + type: "function", + function: { + name: "get_task_counts", + description: "Get open, in-progress, done, dismissed, and overdue task counts for the current user.", + parameters: { type: "object", properties: {} }, + }, + }, { type: "function", function: { @@ -2434,7 +2647,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "create_reminder", - description: "Create a personal reminder for the current user. Can be one-shot or recurring.", + description: "Create a personal reminder for the current user via the real notification router. Always confirm first.", parameters: { type: "object", properties: { @@ -2444,16 +2657,62 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ recurrence: { type: "string", enum: ["daily", "weekly", "monthly"], description: "Optional recurrence pattern" }, entityId: { type: "string", description: "Optional: linked entity ID (project, resource, etc.)" }, entityType: { type: "string", description: "Optional: entity type (project, resource, vacation, etc.)" }, + link: { type: "string", description: "Optional deep link." }, }, required: ["title", "remindAt"], }, }, }, + { + type: "function", + function: { + name: "list_reminders", + description: "List personal reminders for the current user via the real notification router.", + parameters: { + type: "object", + properties: { + limit: { type: "integer", description: "Max results. Default: 20." }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "update_reminder", + description: "Update a personal reminder via the real notification router. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Reminder notification ID." }, + title: { type: "string", description: "Optional reminder title." }, + body: { type: "string", description: "Optional reminder body." }, + remindAt: { type: "string", format: "date-time", description: "Optional reminder timestamp." }, + recurrence: { type: ["string", "null"], enum: ["daily", "weekly", "monthly", null], description: "Optional recurrence update. Use null to clear recurrence." }, + }, + required: ["id"], + }, + }, + }, + { + type: "function", + function: { + name: "delete_reminder", + description: "Delete a personal reminder via the real notification router. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Reminder notification ID." }, + }, + required: ["id"], + }, + }, + }, { type: "function", function: { name: "create_task_for_user", - description: "Create a task for a specific user. Requires manageProjects permission. The task appears in their task list.", + description: "Create a task for a specific user via the real notification router. Manager or admin role required. Always confirm first.", parameters: { type: "object", properties: { @@ -2465,16 +2724,33 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ taskAction: { type: "string", description: "Optional machine-readable action (format: action_name:entity_id)" }, entityId: { type: "string", description: "Optional linked entity ID" }, entityType: { type: "string", description: "Optional entity type" }, + link: { type: "string", description: "Optional deep link." }, + channel: { type: "string", enum: ["in_app", "email", "both"], description: "Delivery channel. Default: in_app." }, }, required: ["userId", "title"], }, }, }, + { + type: "function", + function: { + name: "assign_task", + description: "Assign or reassign a task to another user via the real notification router. Manager or admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Task notification ID." }, + assigneeId: { type: "string", description: "User ID to assign." }, + }, + required: ["id", "assigneeId"], + }, + }, + }, { type: "function", function: { name: "send_broadcast", - description: "Send a notification to a group of users (by role, project members, org unit, or all). Requires manageProjects permission.", + description: "Create and send a broadcast notification via the real notification router. Manager or admin role required. Always confirm first.", parameters: { type: "object", properties: { @@ -2482,14 +2758,59 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ body: { type: "string", description: "Notification body" }, targetType: { type: "string", enum: ["user", "role", "project", "orgUnit", "all"], description: "Target audience type" }, targetValue: { type: "string", description: "Target value: user ID, role name (ADMIN/MANAGER/CONTROLLER/USER/VIEWER), project ID, or org unit ID" }, + category: { type: "string", enum: ["NOTIFICATION", "REMINDER", "TASK", "APPROVAL"], description: "Broadcast category. Default: NOTIFICATION." }, priority: { type: "string", enum: ["LOW", "NORMAL", "HIGH", "URGENT"], description: "Priority (default NORMAL)" }, channel: { type: "string", enum: ["in_app", "email", "both"], description: "Delivery channel (default in_app)" }, link: { type: "string", description: "Optional deep-link URL" }, + scheduledAt: { type: "string", format: "date-time", description: "Optional scheduled send timestamp." }, + taskAction: { type: "string", description: "Optional machine-readable task action for task-like broadcasts." }, + dueDate: { type: "string", format: "date-time", description: "Optional due date for task-like broadcasts." }, }, required: ["title", "targetType"], }, }, }, + { + type: "function", + function: { + name: "list_broadcasts", + description: "List notification broadcasts via the real notification router. Manager or admin role required.", + parameters: { + type: "object", + properties: { + limit: { type: "integer", description: "Max results. Default: 20." }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "get_broadcast_detail", + description: "Get one notification broadcast via the real notification router. Manager or admin role required.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Broadcast ID." }, + }, + required: ["id"], + }, + }, + }, + { + type: "function", + function: { + name: "delete_notification", + description: "Delete one of the current user's own notifications via the real notification router. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Notification ID." }, + }, + required: ["id"], + }, + }, + }, // ── INSIGHTS & ANOMALIES ── { @@ -2538,11 +2859,15 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ type: "function", function: { name: "run_report", - description: "Run a dynamic report query on resources, projects, or assignments with flexible column selection and filtering.", + description: "Run a dynamic report query on resources, projects, assignments, or resource-month rows with flexible column selection and filtering.", parameters: { type: "object", properties: { - entity: { type: "string", enum: ["resource", "project", "assignment"], description: "Entity type to query" }, + entity: { + type: "string", + enum: ["resource", "project", "assignment", "resource_month"], + description: "Entity type to query", + }, columns: { type: "array", items: { type: "string" }, @@ -2561,6 +2886,10 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, description: "Filters to apply", }, + periodMonth: { + type: "string", + description: "Required for resource_month reports. Format: YYYY-MM", + }, limit: { type: "integer", description: "Max results. Default: 50" }, }, required: ["entity", "columns"], @@ -2785,6 +3114,397 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, }, }, + { + type: "function", + function: { + name: "stage_dispo_import_batch", + description: "Stage a Dispo import batch via the real dispo router. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + planningWorkbookPath: { type: "string", description: "Filesystem path to the planning workbook." }, + referenceWorkbookPath: { type: "string", description: "Filesystem path to the reference workbook." }, + chargeabilityWorkbookPath: { type: "string", description: "Filesystem path to the chargeability workbook." }, + costWorkbookPath: { type: "string", description: "Optional filesystem path to the cost workbook." }, + rosterWorkbookPath: { type: "string", description: "Optional filesystem path to the roster workbook." }, + notes: { type: "string", description: "Optional import notes." }, + }, + required: ["planningWorkbookPath", "referenceWorkbookPath", "chargeabilityWorkbookPath"], + }, + }, + }, + { + type: "function", + function: { + name: "validate_dispo_import_batch", + description: "Validate a Dispo import batch readiness check via the real dispo router without committing anything. Admin role required.", + parameters: { + type: "object", + properties: { + planningWorkbookPath: { type: "string", description: "Filesystem path to the planning workbook." }, + referenceWorkbookPath: { type: "string", description: "Filesystem path to the reference workbook." }, + chargeabilityWorkbookPath: { type: "string", description: "Filesystem path to the chargeability workbook." }, + costWorkbookPath: { type: "string", description: "Optional filesystem path to the cost workbook." }, + rosterWorkbookPath: { type: "string", description: "Optional filesystem path to the roster workbook." }, + importBatchId: { type: "string", description: "Optional existing staged import batch ID." }, + notes: { type: "string", description: "Optional import notes." }, + }, + required: ["planningWorkbookPath", "referenceWorkbookPath", "chargeabilityWorkbookPath"], + }, + }, + }, + { + type: "function", + function: { + name: "cancel_dispo_import_batch", + description: "Cancel a staged Dispo import batch via the real dispo router. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Import batch ID." }, + }, + required: ["id"], + }, + }, + }, + { + type: "function", + function: { + name: "list_dispo_staged_resources", + description: "List staged Dispo resources for one import batch via the real dispo router. Admin role required.", + parameters: { + type: "object", + properties: { + importBatchId: { type: "string", description: "Import batch ID." }, + status: { type: "string", description: "Optional staged record status filter." }, + limit: { type: "integer", description: "Max results. Default: 50, max: 200." }, + cursor: { type: "string", description: "Optional pagination cursor." }, + }, + required: ["importBatchId"], + }, + }, + }, + { + type: "function", + function: { + name: "list_dispo_staged_projects", + description: "List staged Dispo projects for one import batch via the real dispo router. Admin role required.", + parameters: { + type: "object", + properties: { + importBatchId: { type: "string", description: "Import batch ID." }, + status: { type: "string", description: "Optional staged record status filter." }, + isTbd: { type: "boolean", description: "Optional TBD-project filter." }, + limit: { type: "integer", description: "Max results. Default: 50, max: 200." }, + cursor: { type: "string", description: "Optional pagination cursor." }, + }, + required: ["importBatchId"], + }, + }, + }, + { + type: "function", + function: { + name: "list_dispo_staged_assignments", + description: "List staged Dispo assignments for one import batch via the real dispo router. Admin role required.", + parameters: { + type: "object", + properties: { + importBatchId: { type: "string", description: "Import batch ID." }, + status: { type: "string", description: "Optional staged record status filter." }, + resourceExternalId: { type: "string", description: "Optional resource external ID filter." }, + limit: { type: "integer", description: "Max results. Default: 50, max: 200." }, + cursor: { type: "string", description: "Optional pagination cursor." }, + }, + required: ["importBatchId"], + }, + }, + }, + { + type: "function", + function: { + name: "list_dispo_staged_vacations", + description: "List staged Dispo vacations for one import batch via the real dispo router. Admin role required.", + parameters: { + type: "object", + properties: { + importBatchId: { type: "string", description: "Import batch ID." }, + resourceExternalId: { type: "string", description: "Optional resource external ID filter." }, + limit: { type: "integer", description: "Max results. Default: 50, max: 200." }, + cursor: { type: "string", description: "Optional pagination cursor." }, + }, + required: ["importBatchId"], + }, + }, + }, + { + type: "function", + function: { + name: "list_dispo_staged_unresolved_records", + description: "List staged unresolved Dispo records for one import batch via the real dispo router. Admin role required.", + parameters: { + type: "object", + properties: { + importBatchId: { type: "string", description: "Import batch ID." }, + recordType: { type: "string", description: "Optional unresolved record type filter." }, + limit: { type: "integer", description: "Max results. Default: 50, max: 200." }, + cursor: { type: "string", description: "Optional pagination cursor." }, + }, + required: ["importBatchId"], + }, + }, + }, + { + type: "function", + function: { + name: "resolve_dispo_staged_record", + description: "Resolve one staged Dispo record via the real dispo router. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Staged record ID." }, + recordType: { type: "string", description: "Staged record type." }, + action: { type: "string", enum: ["APPROVE", "REJECT", "SKIP"], description: "Resolution action." }, + }, + required: ["id", "recordType", "action"], + }, + }, + }, + { + type: "function", + function: { + name: "commit_dispo_import_batch", + description: "Commit a staged Dispo import batch via the real dispo router. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + importBatchId: { type: "string", description: "Import batch ID." }, + allowTbdUnresolved: { type: "boolean", description: "Allow unresolved TBD projects during commit." }, + importTbdProjects: { type: "boolean", description: "Whether TBD projects should be imported." }, + }, + required: ["importBatchId"], + }, + }, + }, + { + type: "function", + function: { + name: "get_system_settings", + description: "Get sanitized system settings through the real settings router. Admin role required.", + parameters: { + type: "object", + properties: {}, + }, + }, + }, + { + type: "function", + function: { + name: "update_system_settings", + description: "Update system settings through the real settings router. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + aiProvider: { type: "string", enum: ["openai", "azure"] }, + azureOpenAiEndpoint: { type: "string" }, + azureOpenAiDeployment: { type: "string" }, + azureOpenAiApiKey: { type: "string" }, + azureApiVersion: { type: "string" }, + aiMaxCompletionTokens: { type: "integer" }, + aiTemperature: { type: "number" }, + aiSummaryPrompt: { type: "string" }, + scoreWeights: { type: "object" }, + scoreVisibleRoles: { type: "array", items: { type: "string" } }, + smtpHost: { type: "string" }, + smtpPort: { type: "integer" }, + smtpUser: { type: "string" }, + smtpPassword: { type: "string" }, + smtpFrom: { type: "string" }, + smtpTls: { type: "boolean" }, + anonymizationEnabled: { type: "boolean" }, + anonymizationDomain: { type: "string" }, + anonymizationSeed: { type: "string" }, + anonymizationMode: { type: "string", enum: ["global"] }, + azureDalleDeployment: { type: "string" }, + azureDalleEndpoint: { type: "string" }, + azureDalleApiKey: { type: "string" }, + geminiApiKey: { type: "string" }, + geminiModel: { type: "string" }, + imageProvider: { type: "string", enum: ["dalle", "gemini"] }, + vacationDefaultDays: { type: "integer" }, + timelineUndoMaxSteps: { type: "integer" }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "test_ai_connection", + description: "Run the real AI connection test from system settings. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: {}, + }, + }, + }, + { + type: "function", + function: { + name: "test_smtp_connection", + description: "Run the real SMTP connection test from system settings. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: {}, + }, + }, + }, + { + type: "function", + function: { + name: "test_gemini_connection", + description: "Run the real Gemini connection test from system settings. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: {}, + }, + }, + }, + { + type: "function", + function: { + name: "get_ai_configured", + description: "Get whether AI is configured for the current system via the real settings router. Available to any authenticated user.", + parameters: { + type: "object", + properties: {}, + }, + }, + }, + { + type: "function", + function: { + name: "list_system_role_configs", + description: "List system role configuration defaults via the real system-role-config router. Available to any authenticated user.", + parameters: { + type: "object", + properties: {}, + }, + }, + }, + { + type: "function", + function: { + name: "update_system_role_config", + description: "Update one system role configuration via the real system-role-config router. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + role: { type: "string", description: "System role key." }, + label: { type: "string", description: "Optional role label." }, + description: { type: "string", description: "Optional role description." }, + color: { type: "string", description: "Optional role color." }, + defaultPermissions: { type: "array", items: { type: "string" }, description: "Optional default permission set." }, + }, + required: ["role"], + }, + }, + }, + { + type: "function", + function: { + name: "list_webhooks", + description: "List webhooks via the real webhook router. Secrets are masked in assistant responses. Admin role required.", + parameters: { + type: "object", + properties: {}, + }, + }, + }, + { + type: "function", + function: { + name: "get_webhook", + description: "Get one webhook via the real webhook router. Secrets are masked in assistant responses. Admin role required.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Webhook ID." }, + }, + required: ["id"], + }, + }, + }, + { + type: "function", + function: { + name: "create_webhook", + description: "Create a webhook via the real webhook router. Secrets are masked in assistant responses. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + name: { type: "string", description: "Webhook name." }, + url: { type: "string", description: "Webhook target URL." }, + secret: { type: "string", description: "Optional webhook signing secret." }, + events: { type: "array", items: { type: "string" }, description: "Subscribed webhook events." }, + isActive: { type: "boolean", description: "Whether the webhook is active. Default: true." }, + }, + required: ["name", "url", "events"], + }, + }, + }, + { + type: "function", + function: { + name: "update_webhook", + description: "Update a webhook via the real webhook router. Secrets are masked in assistant responses. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Webhook ID." }, + data: { + type: "object", + properties: { + name: { type: "string" }, + url: { type: "string" }, + secret: { type: "string" }, + events: { type: "array", items: { type: "string" } }, + isActive: { type: "boolean" }, + }, + }, + }, + required: ["id", "data"], + }, + }, + }, + { + type: "function", + function: { + name: "delete_webhook", + description: "Delete a webhook via the real webhook router. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Webhook ID." }, + }, + required: ["id"], + }, + }, + }, + { + type: "function", + function: { + name: "test_webhook", + description: "Send a real test payload to a webhook via the real webhook router. Admin role required. Always confirm first.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "Webhook ID." }, + }, + required: ["id"], + }, + }, + }, { type: "function", function: { @@ -2871,27 +3591,20 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ /** Resolve a responsible person name against existing resources. Returns the exact displayName or an error object. */ async function resolveResponsiblePerson( name: string, - db: ToolContext["db"], + ctx: ToolContext, ): Promise<{ displayName: string } | { error: string }> { - // Exact match first (case-insensitive) - const exact = await db.resource.findFirst({ - where: { displayName: { equals: name, mode: "insensitive" }, isActive: true }, - select: { displayName: true }, - }); - if (exact) return { displayName: exact.displayName }; + const caller = createResourceCaller(createScopedCallerContext(ctx)); + const result = await caller.resolveResponsiblePersonName({ name }); - // Fuzzy: contains search - const candidates = await db.resource.findMany({ - where: { displayName: { contains: name, mode: "insensitive" }, isActive: true }, - select: { displayName: true, eid: true }, - take: 5, - }); - if (candidates.length === 1) return { displayName: candidates[0]!.displayName }; - if (candidates.length > 1) { - const list = candidates.map((c) => `${c.displayName} (${c.eid})`).join(", "); - return { error: `Multiple resources match "${name}": ${list}. Please specify the exact name.` }; + if (result.status === "resolved") { + return { displayName: result.displayName }; } - return { error: `No active resource found matching "${name}". The responsible person must be an existing resource.` }; + + if (result.status === "ambiguous" || result.status === "missing") { + return { error: result.message }; + } + + return { error: `Unable to resolve responsible person: ${name}` }; } // ─── Tool Executors ───────────────────────────────────────────────────────── @@ -2902,273 +3615,41 @@ const executors = { orgUnit?: string; roleName?: string; isActive?: boolean; limit?: number; }, ctx: ToolContext) { - const limit = Math.min(params.limit ?? 50, 100); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const where: Record = {}; - if (params.isActive !== false) where.isActive = true; - if (params.query) { - where.OR = [ - { displayName: { contains: params.query, mode: "insensitive" } }, - { eid: { contains: params.query, mode: "insensitive" } }, - { chapter: { contains: params.query, mode: "insensitive" } }, - ]; - } - if (params.country) { - where.country = { - OR: [ - { code: { equals: params.country, mode: "insensitive" } }, - { name: { contains: params.country, mode: "insensitive" } }, - ], - }; - } - if (params.metroCity) { - where.metroCity = { name: { contains: params.metroCity, mode: "insensitive" } }; - } - if (params.orgUnit) { - where.orgUnit = { name: { contains: params.orgUnit, mode: "insensitive" } }; - } - if (params.roleName) { - where.areaRole = { name: { contains: params.roleName, mode: "insensitive" } }; - } - const resources = await ctx.db.resource.findMany({ - where, - select: { - id: true, eid: true, displayName: true, chapter: true, fte: true, - lcrCents: true, chargeabilityTarget: true, isActive: true, - areaRole: { select: { name: true } }, - country: { select: { code: true, name: true } }, - metroCity: { select: { name: true } }, - orgUnit: { select: { name: true } }, - }, - take: limit, - orderBy: { displayName: "asc" }, + const caller = createResourceCaller(createScopedCallerContext(ctx)); + return caller.listSummariesDetail({ + search: params.query, + country: params.country, + metroCity: params.metroCity, + orgUnit: params.orgUnit, + roleName: params.roleName, + isActive: params.isActive ?? true, + limit: Math.min(params.limit ?? 50, 100), }); - return resources.map((r) => ({ - id: r.id, - eid: r.eid, - name: r.displayName, - chapter: r.chapter, - role: r.areaRole?.name ?? null, - country: r.country?.name ?? r.country?.code ?? null, - countryCode: r.country?.code ?? null, - metroCity: r.metroCity?.name ?? null, - orgUnit: r.orgUnit?.name ?? null, - fte: r.fte, - lcr: fmtEur(r.lcrCents), - chargeabilityTarget: `${r.chargeabilityTarget}%`, - active: r.isActive, - })); }, async get_resource(params: { identifier: string }, ctx: ToolContext) { - const sel = { - id: true, eid: true, displayName: true, email: true, chapter: true, - fte: true, lcrCents: true, ucrCents: true, chargeabilityTarget: true, - isActive: true, availability: true, skills: true, postalCode: true, federalState: true, - areaRole: { select: { name: true, color: true } }, - country: { select: { code: true, name: true, dailyWorkingHours: true } }, - metroCity: { select: { name: true } }, - managementLevelGroup: { select: { name: true, targetPercentage: true } }, - orgUnit: { select: { name: true, level: true } }, - _count: { select: { assignments: true, vacations: true } }, - } as const; - - let resource = await ctx.db.resource.findUnique({ where: { id: params.identifier }, select: sel }); - if (!resource) { - resource = await ctx.db.resource.findUnique({ where: { eid: params.identifier }, select: sel }); - } - if (!resource) { - resource = await ctx.db.resource.findFirst({ - where: { displayName: { contains: params.identifier, mode: "insensitive" } }, - select: sel, - }); - } - // Try word-level matching if exact substring fails - if (!resource) { - const words = params.identifier.split(/[\s,._\-/]+/).filter((w) => w.length >= 2); - if (words.length > 0) { - const candidates = await ctx.db.resource.findMany({ - where: { OR: words.map((w) => ({ displayName: { contains: w, mode: "insensitive" as const } })) }, - select: sel, - take: 5, - }); - if (candidates.length === 1) { - resource = candidates[0]!; - } else if (candidates.length > 1) { - return { - error: `Resource not found: "${params.identifier}". Did you mean one of these?`, - suggestions: candidates.map((r) => ({ id: r.id, eid: r.eid, name: r.displayName })), - }; - } - } - } - if (!resource) return { error: `Resource not found: ${params.identifier}` }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const skills = (resource.skills as any[]) ?? []; - return { - id: resource.id, - eid: resource.eid, - name: resource.displayName, - email: resource.email, - chapter: resource.chapter, - role: resource.areaRole?.name ?? null, - country: resource.country?.name ?? resource.country?.code ?? null, - countryCode: resource.country?.code ?? null, - countryHours: resource.country?.dailyWorkingHours ?? 8, - metroCity: resource.metroCity?.name ?? null, - fte: resource.fte, - lcr: fmtEur(resource.lcrCents), - ucr: fmtEur(resource.ucrCents), - chargeabilityTarget: `${resource.chargeabilityTarget}%`, - managementLevel: resource.managementLevelGroup?.name ?? null, - orgUnit: resource.orgUnit?.name ?? null, - postalCode: resource.postalCode, - federalState: resource.federalState, - active: resource.isActive, - totalAssignments: resource._count.assignments, - totalVacations: resource._count.vacations, - skillCount: skills.length, - topSkills: skills.slice(0, 10).map((s: { name?: string; level?: number }) => `${s.name ?? "?"} (${s.level ?? "?"})`), - }; + const caller = createResourceCaller(createScopedCallerContext(ctx)); + return caller.getByIdentifierDetail({ identifier: params.identifier }); }, async search_projects(params: { query?: string; status?: string; limit?: number }, ctx: ToolContext) { - const limit = Math.min(params.limit ?? 20, 50); - const sel = { - id: true, shortCode: true, name: true, status: true, - budgetCents: true, winProbability: true, startDate: true, endDate: true, - client: { select: { name: true } }, - _count: { select: { assignments: true, estimates: true } }, - } as const; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const where: Record = {}; - if (params.status) where.status = params.status; - - if (params.query) { - // First try exact substring match - where.OR = [ - { name: { contains: params.query, mode: "insensitive" } }, - { shortCode: { contains: params.query, mode: "insensitive" } }, - ]; - } - - let projects = await ctx.db.project.findMany({ where, select: sel, take: limit, orderBy: { name: "asc" } }); - - // If no results and query has multiple words, try word-level fuzzy matching - if (projects.length === 0 && params.query) { - const words = params.query.split(/[\s,._\-/]+/).filter((w) => w.length >= 2); - if (words.length > 1) { - // Search for any word matching in name or shortCode - const wordConditions = words.flatMap((word) => [ - { name: { contains: word, mode: "insensitive" } }, - { shortCode: { contains: word, mode: "insensitive" } }, - ]); - const fuzzyWhere: Record = { OR: wordConditions }; - if (params.status) fuzzyWhere.status = params.status; - - const candidates = await ctx.db.project.findMany({ - where: fuzzyWhere, - select: sel, - take: limit * 2, - orderBy: { name: "asc" }, - }); - - // Rank by number of matching words - const ranked = candidates.map((p) => { - const lower = `${p.name} ${p.shortCode}`.toLowerCase(); - const matchCount = words.filter((w) => lower.includes(w.toLowerCase())).length; - return { project: p, matchCount }; - }); - ranked.sort((a, b) => b.matchCount - a.matchCount); - projects = ranked.slice(0, limit).map((r) => r.project); - } - } - - const formatted = projects.map((p) => ({ - id: p.id, - code: p.shortCode, - name: p.name, - status: p.status, - budget: p.budgetCents > 0 ? fmtEur(p.budgetCents) : "Not set", - winProbability: `${p.winProbability}%`, - start: fmtDate(p.startDate), - end: fmtDate(p.endDate), - client: p.client?.name ?? null, - assignmentCount: p._count.assignments, - estimateCount: p._count.estimates, - })); - - // If fuzzy results, indicate that these are suggestions - if (projects.length > 0 && params.query) { - const exactMatch = projects.some((p) => - p.name.toLowerCase().includes(params.query!.toLowerCase()) || - p.shortCode.toLowerCase().includes(params.query!.toLowerCase()), - ); - if (!exactMatch) { - return { suggestions: formatted, note: `No exact match for "${params.query}". These projects match some of the search terms:` }; - } - } - - return formatted; + const caller = createProjectCaller(createScopedCallerContext(ctx)); + return caller.searchSummariesDetail({ + search: params.query, + status: params.status as import("@capakraken/shared").ProjectStatus | undefined, + limit: Math.min(params.limit ?? 20, 50), + }); }, async get_project(params: { identifier: string }, ctx: ToolContext) { - const sel = { - 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; - - let project = await ctx.db.project.findUnique({ where: { id: params.identifier }, select: sel }); - if (!project) { - project = await ctx.db.project.findUnique({ where: { shortCode: params.identifier }, select: sel }); + const caller = createProjectCaller(createScopedCallerContext(ctx)); + let project; + try { + project = await caller.getByIdentifierDetail({ identifier: params.identifier }); + } catch { + return { error: `Project not found: ${params.identifier}` }; } - if (!project) return { error: `Project not found: ${params.identifier}` }; - - // Fetch top allocations for context - const topAllocs = 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 { - id: project.id, - code: project.shortCode, - name: project.name, - status: project.status, - orderType: project.orderType, - allocationType: project.allocationType, - budget: project.budgetCents > 0 ? fmtEur(project.budgetCents) : "Not set", - budgetCents: project.budgetCents, - winProbability: `${project.winProbability}%`, - start: fmtDate(project.startDate), - end: fmtDate(project.endDate), - responsible: project.responsiblePerson, - client: project.client?.name ?? null, - category: project.utilizationCategory?.name ?? null, - assignmentCount: project._count.assignments, - estimateCount: project._count.estimates, - topAllocations: topAllocs.map((a) => ({ - resource: a.resource.displayName, - eid: a.resource.eid, - role: a.role ?? null, - status: a.status, - hoursPerDay: a.hoursPerDay, - start: fmtDate(a.startDate), - end: fmtDate(a.endDate), - })), - }; + return project; }, async find_best_project_resource(params: { @@ -3184,285 +3665,22 @@ const executors = { assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); assertPermission(ctx, PermissionKey.VIEW_COSTS); - const project = await resolveProjectIdentifier(params.projectIdentifier, ctx.db); + const project = await resolveProjectIdentifier(ctx, params.projectIdentifier); if ("error" in project) { return project; } - const { startDate, endDate } = createDateRange({ - startDate: params.startDate, - endDate: params.endDate, - durationDays: params.durationDays, + const caller = createStaffingCaller(createScopedCallerContext(ctx)); + return caller.getBestProjectResourceDetail({ + projectId: project.id, + ...(params.startDate ? { startDate: parseIsoDate(params.startDate, "startDate") } : {}), + ...(params.endDate ? { endDate: parseIsoDate(params.endDate, "endDate") } : {}), + ...(params.durationDays !== undefined ? { durationDays: params.durationDays } : {}), + ...(params.minHoursPerDay !== undefined ? { minHoursPerDay: params.minHoursPerDay } : {}), + ...(params.rankingMode ? { rankingMode: params.rankingMode } : {}), + ...(params.chapter ? { chapter: params.chapter } : {}), + ...(params.roleName ? { roleName: params.roleName } : {}), }); - const minHoursPerDay = Math.max(params.minHoursPerDay ?? 3, 0); - const rankingMode = params.rankingMode ?? "lowest_lcr"; - - const projectAssignments = await ctx.db.assignment.findMany({ - where: { - projectId: project.id, - status: { not: "CANCELLED" }, - startDate: { lte: endDate }, - endDate: { gte: startDate }, - resource: { - isActive: true, - ...(params.chapter ? { chapter: { contains: params.chapter, mode: "insensitive" } } : {}), - ...(params.roleName ? { areaRole: { name: { contains: params.roleName, mode: "insensitive" } } } : {}), - }, - }, - select: { - resourceId: true, - hoursPerDay: true, - startDate: true, - endDate: true, - status: true, - resource: { - select: { - id: true, - eid: true, - displayName: true, - chapter: true, - lcrCents: true, - availability: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { code: true, name: true } }, - metroCity: { select: { name: true } }, - areaRole: { select: { name: true } }, - }, - }, - }, - orderBy: [{ resourceId: "asc" }, { startDate: "asc" }], - }); - - if (projectAssignments.length === 0) { - return { - project, - period: { - startDate: fmtDate(startDate), - endDate: fmtDate(endDate), - minHoursPerDay, - rankingMode, - }, - candidateCount: 0, - candidates: [], - bestMatch: null, - note: "No active project resources matched the requested filters in the selected period.", - }; - } - - const resourcesById = new Map(); - const assignmentsOnProjectByResourceId = new Map(); - for (const assignment of projectAssignments) { - resourcesById.set(assignment.resourceId, assignment.resource); - const items = assignmentsOnProjectByResourceId.get(assignment.resourceId) ?? []; - items.push(assignment); - assignmentsOnProjectByResourceId.set(assignment.resourceId, items); - } - - const resourceIds = [...resourcesById.keys()]; - const overlappingAssignments = await ctx.db.assignment.findMany({ - where: { - resourceId: { in: resourceIds }, - status: { not: "CANCELLED" }, - startDate: { lte: endDate }, - endDate: { gte: startDate }, - }, - select: { - resourceId: true, - projectId: true, - hoursPerDay: true, - startDate: true, - endDate: true, - status: true, - project: { select: { name: true, shortCode: true } }, - }, - orderBy: [{ resourceId: "asc" }, { startDate: "asc" }], - }); - - const assignmentsByResourceId = new Map(); - for (const assignment of overlappingAssignments) { - const items = assignmentsByResourceId.get(assignment.resourceId) ?? []; - items.push(assignment); - assignmentsByResourceId.set(assignment.resourceId, items); - } - - const resources = [...resourcesById.values()]; - const contexts = 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, - })), - startDate, - endDate, - ); - - const candidates = resources.map((resource) => { - const availability = resource.availability as unknown as WeekdayAvailability; - const context = contexts.get(resource.id); - const baseWorkingDays = countEffectiveWorkingDays({ - availability, - periodStart: startDate, - periodEnd: endDate, - context: undefined, - }); - const workingDays = countEffectiveWorkingDays({ - availability, - periodStart: startDate, - periodEnd: endDate, - context, - }); - const baseAvailableHours = calculateEffectiveAvailableHours({ - availability, - periodStart: startDate, - periodEnd: endDate, - context: undefined, - }); - const availableHours = calculateEffectiveAvailableHours({ - availability, - periodStart: startDate, - periodEnd: endDate, - context, - }); - const assignments = assignmentsByResourceId.get(resource.id) ?? []; - const bookedHours = assignments.reduce( - (sum, assignment) => - sum + calculateEffectiveBookedHours({ - availability, - startDate: assignment.startDate, - endDate: assignment.endDate, - hoursPerDay: assignment.hoursPerDay, - periodStart: startDate, - periodEnd: endDate, - context, - }), - 0, - ); - const projectHours = (assignmentsOnProjectByResourceId.get(resource.id) ?? []).reduce( - (sum, assignment) => - sum + calculateEffectiveBookedHours({ - availability, - startDate: assignment.startDate, - endDate: assignment.endDate, - hoursPerDay: assignment.hoursPerDay, - periodStart: startDate, - periodEnd: endDate, - context, - }), - 0, - ); - let excludedCapacityDays = 0; - for (const fraction of context?.absenceFractionsByDate.values() ?? []) { - excludedCapacityDays += fraction; - } - const holidayWorkdayCount = [...(context?.holidayDates ?? new Set())].reduce((count, isoDate) => ( - count + (getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0) - ), 0); - const holidayHoursDeduction = [...(context?.holidayDates ?? new Set())].reduce((sum, isoDate) => ( - sum + getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)) - ), 0); - let absenceDayEquivalent = 0; - let absenceHoursDeduction = 0; - for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) { - const dayHours = getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)); - if (dayHours <= 0 || context?.holidayDates.has(isoDate)) { - continue; - } - absenceDayEquivalent += fraction; - absenceHoursDeduction += dayHours * fraction; - } - - const remainingHours = Math.max(0, availableHours - bookedHours); - const remainingHoursPerDay = averagePerWorkingDay(remainingHours, workingDays); - - return { - id: resource.id, - eid: resource.eid, - name: resource.displayName, - role: resource.areaRole?.name ?? null, - chapter: resource.chapter ?? null, - country: resource.country?.name ?? resource.country?.code ?? null, - countryCode: resource.country?.code ?? null, - federalState: resource.federalState ?? null, - metroCity: resource.metroCity?.name ?? null, - lcrCents: resource.lcrCents ?? null, - lcr: resource.lcrCents != null ? fmtEur(resource.lcrCents) : null, - baseWorkingDays: round1(baseWorkingDays), - workingDays, - excludedCapacityDays: round1(excludedCapacityDays), - baseAvailableHours: round1(baseAvailableHours), - availableHours: round1(availableHours), - bookedHours: round1(bookedHours), - remainingHours: round1(remainingHours), - remainingHoursPerDay, - projectHours: round1(projectHours), - assignmentCount: assignments.length, - holidaySummary: { - count: context?.holidayDates.size ?? 0, - workdayCount: holidayWorkdayCount, - hoursDeduction: round1(holidayHoursDeduction), - holidayDates: [...(context?.holidayDates ?? new Set())].sort(), - }, - absenceSummary: { - dayEquivalent: round1(absenceDayEquivalent), - hoursDeduction: round1(absenceHoursDeduction), - }, - capacityBreakdown: { - formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours", - baseAvailableHours: round1(baseAvailableHours), - holidayHoursDeduction: round1(holidayHoursDeduction), - absenceHoursDeduction: round1(absenceHoursDeduction), - availableHours: round1(availableHours), - }, - }; - }).filter((candidate) => candidate.remainingHoursPerDay >= minHoursPerDay); - - const compareCandidates = (left: (typeof candidates)[number], right: (typeof candidates)[number]): number => { - if (rankingMode === "highest_remaining_hours_per_day") { - return right.remainingHoursPerDay - left.remainingHoursPerDay - || right.remainingHours - left.remainingHours - || (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER); - } - if (rankingMode === "highest_remaining_hours") { - return right.remainingHours - left.remainingHours - || right.remainingHoursPerDay - left.remainingHoursPerDay - || (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER); - } - return (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER) - || right.remainingHoursPerDay - left.remainingHoursPerDay - || right.remainingHours - left.remainingHours; - }; - - candidates.sort(compareCandidates); - - return { - project: { - id: project.id, - name: project.name, - shortCode: project.shortCode, - status: project.status, - responsiblePerson: project.responsiblePerson, - }, - period: { - startDate: fmtDate(startDate), - endDate: fmtDate(endDate), - minHoursPerDay, - rankingMode, - }, - filters: { - chapter: params.chapter ?? null, - roleName: params.roleName ?? null, - }, - candidateCount: candidates.length, - bestMatch: candidates[0] ?? null, - candidates, - }; }, async get_timeline_entries_view(params: { @@ -3478,31 +3696,8 @@ const executors = { }, ctx: ToolContext) { assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - const { startDate, endDate } = createDateRange(params); - const filters = createTimelineFilters(params); - const input = { ...filters, startDate, endDate }; - - const [readModel, holidayOverlays] = await Promise.all([ - loadTimelineEntriesReadModel(ctx.db, input), - loadTimelineHolidayOverlays(ctx.db, input), - ]); - const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays); - - return { - period: { - startDate: fmtDate(startDate), - endDate: fmtDate(endDate), - }, - filters, - summary: { - ...summarizeTimelineEntries(readModel), - ...summarizeHolidayOverlays(formattedHolidayOverlays), - }, - allocations: readModel.allocations, - demands: readModel.demands, - assignments: readModel.assignments, - holidayOverlays: formattedHolidayOverlays, - }; + const caller = createTimelineCaller(createScopedCallerContext(ctx)); + return caller.getEntriesDetail({ ...params }); }, async get_timeline_holiday_overlays(params: { @@ -3518,24 +3713,8 @@ const executors = { }, ctx: ToolContext) { assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - const { startDate, endDate } = createDateRange(params); - const filters = createTimelineFilters(params); - 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, - }; + const caller = createTimelineCaller(createScopedCallerContext(ctx)); + return caller.getHolidayOverlayDetail({ ...params }); }, async get_project_timeline_context(params: { @@ -3546,105 +3725,18 @@ const executors = { }, ctx: ToolContext) { assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - const project = await resolveProjectIdentifier(params.projectIdentifier, ctx.db); + const project = await resolveProjectIdentifier(ctx, params.projectIdentifier); if ("error" in project) { return project; } - const projectContext = await loadTimelineProjectContext(ctx.db, project.id); - - const derivedStartDate = params.startDate - ? parseIsoDate(params.startDate, "startDate") - : projectContext.project.startDate - ?? projectContext.assignments[0]?.startDate - ?? projectContext.demands[0]?.startDate - ?? createDateRange({ durationDays: 1 }).startDate; - const derivedEndDate = params.endDate - ? parseIsoDate(params.endDate, "endDate") - : projectContext.project.endDate - ?? createDateRange({ - startDate: fmtDate(derivedStartDate) ?? undefined, - durationDays: params.durationDays ?? 21, - }).endDate; - - if (derivedEndDate < derivedStartDate) { - throw new Error("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: [project.id], - }) - : []; - - 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 === project.id, - })); - - 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, - demands: projectContext.demands, - assignments: projectContext.assignments, - allResourceAllocations: projectContext.allResourceAllocations, - assignmentConflicts, - holidayOverlays: formattedHolidayOverlays, - resourceIds: projectContext.resourceIds, - }; + const caller = createTimelineCaller(createScopedCallerContext(ctx)); + return caller.getProjectContextDetail({ + projectId: project.id, + ...(params.startDate ? { startDate: params.startDate } : {}), + ...(params.endDate ? { endDate: params.endDate } : {}), + ...(params.durationDays !== undefined ? { durationDays: params.durationDays } : {}), + }); }, async preview_project_shift(params: { @@ -3654,31 +3746,17 @@ const executors = { }, ctx: ToolContext) { assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - const project = await resolveProjectIdentifier(params.projectIdentifier, ctx.db); + const project = await resolveProjectIdentifier(ctx, params.projectIdentifier); if ("error" in project) { return project; } - const newStartDate = parseIsoDate(params.newStartDate, "newStartDate"); - const newEndDate = parseIsoDate(params.newEndDate, "newEndDate"); - if (newEndDate < newStartDate) { - throw new Error("newEndDate must be on or after newStartDate."); - } - - const preview = await previewTimelineProjectShift(ctx.db, { + const caller = createTimelineCaller(createScopedCallerContext(ctx)); + return caller.getShiftPreviewDetail({ projectId: project.id, - newStartDate, - newEndDate, + newStartDate: parseIsoDate(params.newStartDate, "newStartDate"), + newEndDate: parseIsoDate(params.newEndDate, "newEndDate"), }); - - return { - project, - requestedShift: { - newStartDate: fmtDate(newStartDate), - newEndDate: fmtDate(newEndDate), - }, - preview, - }; }, async update_timeline_allocation_inline(params: { @@ -3728,7 +3806,7 @@ const executors = { assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - const project = await resolveProjectIdentifier(params.projectIdentifier, ctx.db); + const project = await resolveProjectIdentifier(ctx, params.projectIdentifier); if ("error" in project) { return project; } @@ -3770,8 +3848,8 @@ const executors = { assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); const [resource, project] = await Promise.all([ - resolveResourceIdentifier(params.resourceIdentifier, ctx.db), - resolveProjectIdentifier(params.projectIdentifier, ctx.db), + resolveResourceIdentifier(ctx, params.resourceIdentifier), + resolveProjectIdentifier(ctx, params.projectIdentifier), ]); if ("error" in resource) { return resource; @@ -3826,8 +3904,8 @@ const executors = { const resolvedAssignments = await Promise.all(params.assignments.map(async (assignment, index) => { const [resource, project] = await Promise.all([ - resolveResourceIdentifier(assignment.resourceIdentifier, ctx.db), - resolveProjectIdentifier(assignment.projectIdentifier, ctx.db), + resolveResourceIdentifier(ctx, assignment.resourceIdentifier), + resolveProjectIdentifier(ctx, assignment.projectIdentifier), ]); if ("error" in resource) { throw new Error(`assignments[${index}].resourceIdentifier: ${resource.error}`); @@ -3889,141 +3967,91 @@ const executors = { resourceName?: string; projectCode?: string; status?: string; limit?: number; }, ctx: ToolContext) { - const limit = Math.min(params.limit ?? 30, 50); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const where: Record = {}; - if (params.resourceId) where.resourceId = params.resourceId; - if (params.projectId) where.projectId = params.projectId; - if (params.status) where.status = params.status; - if (params.resourceName) { - where.resource = { displayName: { contains: params.resourceName, mode: "insensitive" } }; - } - if (params.projectCode) { - where.project = { shortCode: { contains: params.projectCode, mode: "insensitive" } }; - } - const allocs = await ctx.db.assignment.findMany({ - where, - select: { - id: true, status: true, hoursPerDay: true, dailyCostCents: true, - startDate: true, endDate: true, role: true, - resource: { select: { displayName: true, eid: true } }, - project: { select: { name: true, shortCode: true } }, - }, - take: limit, - orderBy: { startDate: "desc" }, + const caller = createAllocationCaller(createScopedCallerContext(ctx)); + const status = params.status && Object.values(AllocationStatus).includes(params.status as AllocationStatus) + ? params.status as AllocationStatus + : undefined; + const readModel = await caller.listView({ + resourceId: params.resourceId, + projectId: params.projectId, + status, }); - return allocs.map((a) => ({ - id: a.id, - resource: a.resource.displayName, - resourceEid: a.resource.eid, - project: a.project.name, - projectCode: a.project.shortCode, - role: a.role ?? null, - status: a.status, - hoursPerDay: a.hoursPerDay, - dailyCost: fmtEur(a.dailyCostCents), - start: fmtDate(a.startDate), - end: fmtDate(a.endDate), - })); + + const resourceNameQuery = params.resourceName?.trim().toLowerCase(); + const projectCodeQuery = params.projectCode?.trim().toLowerCase(); + const limit = Math.min(params.limit ?? 30, 50); + + return readModel.assignments + .filter((assignment) => { + if ( + resourceNameQuery + && !assignment.resource?.displayName?.toLowerCase().includes(resourceNameQuery) + ) { + return false; + } + if ( + projectCodeQuery + && !assignment.project?.shortCode?.toLowerCase().includes(projectCodeQuery) + ) { + return false; + } + return true; + }) + .slice(0, limit) + .map((assignment) => ({ + id: assignment.id, + resource: assignment.resource?.displayName ?? "Unknown", + resourceEid: assignment.resource?.eid ?? null, + project: assignment.project?.name ?? "Unknown", + projectCode: assignment.project?.shortCode ?? null, + role: assignment.role ?? assignment.roleEntity?.name ?? null, + status: assignment.status, + hoursPerDay: assignment.hoursPerDay, + dailyCost: fmtEur(assignment.dailyCostCents), + start: fmtDate(new Date(assignment.startDate)), + end: fmtDate(new Date(assignment.endDate)), + })); }, async get_budget_status(params: { projectId: string }, ctx: ToolContext) { - const sel = { id: true, name: true, shortCode: true, budgetCents: true, winProbability: true, startDate: true, endDate: true } as const; - let project = await ctx.db.project.findUnique({ where: { id: params.projectId }, select: sel }); - if (!project) { - project = await ctx.db.project.findUnique({ where: { shortCode: params.projectId }, select: sel }); + const project = await resolveProjectIdentifier(ctx, params.projectId); + if ("error" in project) { + return project; } - if (!project) return { error: `Project not found: ${params.projectId}` }; - const allocs = await ctx.db.assignment.findMany({ - where: { projectId: project.id }, - select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true }, - }); + const caller = createTimelineCaller(createScopedCallerContext(ctx)); + const budgetStatus = await caller.getBudgetStatus({ projectId: project.id }); - if (project.budgetCents <= 0) { + if (budgetStatus.budgetCents <= 0) { return { - project: project.name, - code: project.shortCode, + project: budgetStatus.projectName, + code: budgetStatus.projectCode, budget: "Not set", note: "No budget defined for this project", - totalAllocations: allocs.length, + totalAllocations: budgetStatus.totalAllocations, }; } - const status = computeBudgetStatus( - project.budgetCents, - project.winProbability, - allocs.map((a) => ({ - status: a.status as unknown as string, - dailyCostCents: a.dailyCostCents, - startDate: a.startDate, - endDate: a.endDate, - hoursPerDay: a.hoursPerDay, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - })) as any, - project.startDate ?? new Date(), - project.endDate ?? new Date(), - ); - return { - project: project.name, - code: project.shortCode, - budget: fmtEur(project.budgetCents), - confirmed: fmtEur(status.confirmedCents), - proposed: fmtEur(status.proposedCents), - allocated: fmtEur(status.allocatedCents), - remaining: fmtEur(status.remainingCents), - utilization: `${status.utilizationPercent.toFixed(1)}%`, - winWeighted: fmtEur(status.winProbabilityWeightedCents), + project: budgetStatus.projectName, + code: budgetStatus.projectCode, + budget: fmtEur(budgetStatus.budgetCents), + confirmed: fmtEur(budgetStatus.confirmedCents), + proposed: fmtEur(budgetStatus.proposedCents), + allocated: fmtEur(budgetStatus.allocatedCents), + remaining: fmtEur(budgetStatus.remainingCents), + utilization: `${budgetStatus.utilizationPercent.toFixed(1)}%`, + winWeighted: fmtEur(budgetStatus.winProbabilityWeightedCents), }; }, async get_vacation_balance(params: { resourceId: string; year?: number }, ctx: ToolContext) { const year = params.year ?? new Date().getFullYear(); - let resource = await ctx.db.resource.findUnique({ - where: { id: params.resourceId }, - select: { id: true, displayName: true }, - }); - if (!resource) { - resource = await ctx.db.resource.findFirst({ - where: { displayName: { contains: params.resourceId, mode: "insensitive" } }, - select: { id: true, displayName: true }, - }); - } - if (!resource) return { error: `Resource not found: ${params.resourceId}` }; + const resource = await resolveResourceIdentifier(ctx, params.resourceId); + if ("error" in resource) return resource; - const entitlement = await ctx.db.vacationEntitlement.findUnique({ - where: { resourceId_year: { resourceId: resource.id, year } }, - }); - - const vacations = await ctx.db.vacation.findMany({ - where: { - resourceId: resource.id, - status: { in: ["APPROVED", "PENDING"] }, - startDate: { gte: new Date(`${year}-01-01`) }, - endDate: { lte: new Date(`${year}-12-31`) }, - }, - select: { type: true, status: true, startDate: true, endDate: true, isHalfDay: true }, - }); - - let takenDays = 0; - let pendingDays = 0; - for (const v of vacations) { - if (v.type === "PUBLIC_HOLIDAY") continue; - const days = v.isHalfDay ? 0.5 : Math.ceil((v.endDate.getTime() - v.startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1; - if (v.status === "APPROVED") takenDays += days; - else pendingDays += days; - } - - return { - resource: resource.displayName, - year, - entitlement: entitlement?.entitledDays ?? "Not set", - carryOver: entitlement?.carryoverDays ?? 0, - taken: takenDays, - pending: pendingDays, - remaining: entitlement ? entitlement.entitledDays + (entitlement.carryoverDays ?? 0) - takenDays : "Unknown (no entitlement set)", - }; + const caller = createEntitlementCaller(createScopedCallerContext(ctx)); + return caller.getBalanceDetail({ resourceId: resource.id, year }); }, async list_vacations_upcoming(params: { @@ -4031,36 +4059,39 @@ const executors = { }, ctx: ToolContext) { const daysAhead = params.daysAhead ?? 30; const limit = Math.min(params.limit ?? 30, 50); + const caller = createVacationCaller(createScopedCallerContext(ctx)); const now = new Date(); const until = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const where: Record = { + const vacations = await caller.list({ status: "APPROVED", - endDate: { gte: now }, - startDate: { lte: until }, - }; - if (params.resourceName) { - where.resource = { displayName: { contains: params.resourceName, mode: "insensitive" } }; - } - if (params.chapter) { - where.resource = { ...where.resource, chapter: { contains: params.chapter, mode: "insensitive" } }; - } - - const vacations = await ctx.db.vacation.findMany({ - where, - select: { - id: true, type: true, startDate: true, endDate: true, isHalfDay: true, halfDayPart: true, - resource: { select: { displayName: true, eid: true, chapter: true } }, - }, - take: limit, - orderBy: { startDate: "asc" }, + startDate: now, + endDate: until, + limit, }); - return vacations.map((v) => ({ + const filtered = vacations + .filter((vacation) => { + if (params.resourceName) { + const resourceName = vacation.resource?.displayName?.toLowerCase() ?? ""; + if (!resourceName.includes(params.resourceName.toLowerCase())) { + return false; + } + } + if (params.chapter) { + const chapter = vacation.resource?.chapter?.toLowerCase() ?? ""; + if (!chapter.includes(params.chapter.toLowerCase())) { + return false; + } + } + return true; + }) + .slice(0, limit); + + return filtered.map((v) => ({ resource: v.resource.displayName, eid: v.resource.eid, - chapter: v.resource.chapter, + chapter: v.resource.chapter ?? null, type: v.type, start: fmtDate(v.startDate), end: fmtDate(v.endDate), @@ -4078,28 +4109,23 @@ const executors = { periodEnd?: string; }, ctx: ToolContext) { const { year, periodStart, periodEnd } = resolveHolidayPeriod(params); - - const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { + const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); + const resolved = await caller.resolveHolidaysDetail({ periodStart, periodEnd, countryCode: params.countryCode.trim().toUpperCase(), - federalState: params.federalState?.trim().toUpperCase() || null, - metroCityName: params.metroCity?.trim() || null, + ...(params.federalState ? { stateCode: params.federalState } : {}), + ...(params.metroCity ? { metroCityName: params.metroCity } : {}), }); - const formattedHolidays = holidays.map(formatResolvedHoliday); return { - locationContext: { - countryCode: params.countryCode.trim().toUpperCase(), - federalState: params.federalState?.trim().toUpperCase() || null, - metroCity: params.metroCity?.trim() || null, - }, + locationContext: resolved.locationContext, year, - periodStart: fmtDate(periodStart), - periodEnd: fmtDate(periodEnd), - count: holidays.length, - summary: summarizeResolvedHolidays(formattedHolidays), - holidays: formattedHolidays, + periodStart: resolved.periodStart, + periodEnd: resolved.periodEnd, + count: resolved.count, + summary: resolved.summary, + holidays: resolved.holidays, }; }, @@ -4109,65 +4135,25 @@ const executors = { periodStart?: string; periodEnd?: string; }, ctx: ToolContext) { - const select = { - id: true, - eid: true, - displayName: true, - federalState: true, - countryId: true, - metroCityId: true, - country: { select: { code: true, name: true } }, - metroCity: { select: { name: true } }, - } as const; - - let resource = await ctx.db.resource.findUnique({ - where: { id: params.identifier }, - select, - }); - if (!resource) { - resource = await ctx.db.resource.findUnique({ - where: { eid: params.identifier }, - select, - }); - } - if (!resource) { - resource = await ctx.db.resource.findFirst({ - where: { displayName: { contains: params.identifier, mode: "insensitive" } }, - select, - }); - } - if (!resource) { - return { error: `Resource not found: ${params.identifier}` }; - } + const resource = await resolveResourceIdentifier(ctx, params.identifier); + if ("error" in resource) return resource; const { year, periodStart, periodEnd } = resolveHolidayPeriod(params); - const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { + const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); + const resolved = await caller.resolveResourceHolidaysDetail({ + resourceId: resource.id, periodStart, periodEnd, - countryId: resource.countryId ?? null, - countryCode: resource.country?.code ?? null, - federalState: resource.federalState ?? null, - metroCityId: resource.metroCityId ?? null, - metroCityName: resource.metroCity?.name ?? null, }); - const formattedHolidays = holidays.map(formatResolvedHoliday); return { - 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, - }, + resource: resolved.resource, year, - periodStart: fmtDate(periodStart), - periodEnd: fmtDate(periodEnd), - count: holidays.length, - summary: summarizeResolvedHolidays(formattedHolidays), - holidays: formattedHolidays, + periodStart: resolved.periodStart, + periodEnd: resolved.periodEnd, + count: resolved.count, + summary: resolved.summary, + holidays: resolved.holidays, }; }, @@ -4178,51 +4164,15 @@ const executors = { stateCode?: string; metroCity?: string; }, ctx: ToolContext) { - const calendars = await ctx.db.holidayCalendar.findMany({ - where: { - ...(params.includeInactive ? {} : { isActive: true }), - ...(params.countryCode - ? { - country: { code: { equals: params.countryCode.trim().toUpperCase(), mode: "insensitive" } }, - } - : {}), - ...(params.scopeType ? { scopeType: params.scopeType } : {}), - ...(params.stateCode ? { stateCode: params.stateCode.trim().toUpperCase() } : {}), - ...(params.metroCity - ? { - metroCity: { name: { contains: params.metroCity.trim(), mode: "insensitive" } }, - } - : {}), - }, - 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" }, - ], - }); - - return { - count: calendars.length, - calendars: calendars.map(formatHolidayCalendar), - }; + const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); + return caller.listCalendarsDetail(params); }, async get_holiday_calendar(params: { identifier: string; }, ctx: ToolContext) { - const calendar = await findHolidayCalendarByIdentifier(ctx.db, params.identifier.trim()); - if (!calendar) { - return { error: `Holiday calendar not found: ${params.identifier}` }; - } - - return formatHolidayCalendar(calendar); + const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); + return caller.getCalendarByIdentifierDetail({ identifier: params.identifier.trim() }); }, async preview_resolved_holiday_calendar(params: { @@ -4232,46 +4182,8 @@ const executors = { year: number; }, ctx: ToolContext) { const input = PreviewResolvedHolidaysSchema.parse(params); - const country = await ctx.db.country.findUnique({ - where: { id: input.countryId }, - select: { code: true }, - }); - if (!country) { - return { error: `Country not found: ${input.countryId}` }; - } - - const metroCity = input.metroCityId - ? await ctx.db.metroCity.findUnique({ - where: { id: input.metroCityId }, - select: { name: true }, - }) - : null; - - const holidays = 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, - }); - - const formattedHolidays = holidays.map(formatResolvedHoliday); - - return { - count: formattedHolidays.length, - locationContext: { - countryId: input.countryId, - countryCode: country.code, - stateCode: input.stateCode?.trim().toUpperCase() ?? null, - metroCityId: input.metroCityId ?? null, - metroCity: metroCity?.name ?? null, - year: input.year, - }, - summary: summarizeResolvedHolidays(formattedHolidays), - holidays: formattedHolidays, - }; + const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); + return caller.previewResolvedHolidaysDetail(input); }, async create_holiday_calendar(params: { @@ -4284,42 +4196,8 @@ const executors = { priority?: number; }, ctx: ToolContext) { assertAdminRole(ctx); - const input = CreateHolidayCalendarSchema.parse(params); - - const country = await ctx.db.country.findUnique({ - where: { id: input.countryId }, - select: { id: true, code: true, name: true }, - }); - if (!country) { - return { error: `Country not found: ${input.countryId}` }; - } - - const stateCode = input.stateCode?.trim().toUpperCase() ?? null; - const metroCityId = input.metroCityId ?? null; - - await assertHolidayCalendarScopeConsistency(ctx.db, { - scopeType: input.scopeType, - countryId: input.countryId, - stateCode, - metroCityId, - }); - - const created = await ctx.db.holidayCalendar.create({ - data: { - name: input.name, - scopeType: input.scopeType, - countryId: input.countryId, - ...(stateCode ? { stateCode } : {}), - ...(metroCityId ? { metroCityId } : {}), - isActive: input.isActive ?? true, - priority: input.priority ?? 0, - }, - include: { - country: { select: { id: true, code: true, name: true } }, - metroCity: { select: { id: true, name: true } }, - entries: { orderBy: [{ date: "asc" }, { name: "asc" }] }, - }, - }); + const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); + const created = await caller.createCalendar(CreateHolidayCalendarSchema.parse(params)); return { __action: "invalidate", @@ -4341,55 +4219,12 @@ const executors = { }; }, ctx: ToolContext) { assertAdminRole(ctx); + const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); const input = { id: params.id, data: UpdateHolidayCalendarSchema.parse(params.data), }; - - const existing = await ctx.db.holidayCalendar.findUnique({ - where: { id: input.id }, - select: { - id: true, - name: true, - scopeType: true, - countryId: true, - stateCode: true, - metroCityId: true, - }, - }); - if (!existing) { - return { error: `Holiday calendar not found: ${input.id}` }; - } - - const stateCode = input.data.stateCode === undefined - ? existing.stateCode - : input.data.stateCode?.trim().toUpperCase() ?? null; - const metroCityId = input.data.metroCityId === undefined - ? existing.metroCityId - : input.data.metroCityId ?? null; - - await assertHolidayCalendarScopeConsistency(ctx.db, { - scopeType: existing.scopeType, - countryId: existing.countryId, - stateCode, - metroCityId, - }, existing.id); - - const updated = await ctx.db.holidayCalendar.update({ - where: { id: input.id }, - data: { - ...(input.data.name !== undefined ? { name: input.data.name } : {}), - ...(input.data.stateCode !== undefined ? { stateCode } : {}), - ...(input.data.metroCityId !== undefined ? { metroCityId } : {}), - ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}), - ...(input.data.priority !== undefined ? { priority: input.data.priority } : {}), - }, - include: { - country: { select: { id: true, code: true, name: true } }, - metroCity: { select: { id: true, name: true } }, - entries: { orderBy: [{ date: "asc" }, { name: "asc" }] }, - }, - }); + const updated = await caller.updateCalendar(input); return { __action: "invalidate", @@ -4404,21 +4239,14 @@ const executors = { id: string; }, ctx: ToolContext) { assertAdminRole(ctx); - const existing = await ctx.db.holidayCalendar.findUnique({ - where: { id: params.id }, - select: { id: true, name: true }, - }); - if (!existing) { - return { error: `Holiday calendar not found: ${params.id}` }; - } - - await ctx.db.holidayCalendar.delete({ where: { id: params.id } }); + const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); + const deleted = await caller.deleteCalendar({ id: params.id }); return { __action: "invalidate", scope: ["holidayCalendar", "vacation"], success: true, - message: `Deleted holiday calendar: ${existing.name}`, + message: `Deleted holiday calendar: ${deleted.name}`, }; }, @@ -4430,30 +4258,8 @@ const executors = { source?: string; }, ctx: ToolContext) { assertAdminRole(ctx); - const input = CreateHolidayCalendarEntrySchema.parse(params); - - const calendar = await ctx.db.holidayCalendar.findUnique({ - where: { id: input.holidayCalendarId }, - select: { id: true, name: true }, - }); - if (!calendar) { - return { error: `Holiday calendar not found: ${input.holidayCalendarId}` }; - } - - await assertHolidayCalendarEntryDateAvailable(ctx.db, { - holidayCalendarId: input.holidayCalendarId, - date: input.date, - }); - - const created = await ctx.db.holidayCalendarEntry.create({ - data: { - holidayCalendarId: input.holidayCalendarId, - date: clampHolidayCalendarDate(input.date), - name: input.name, - isRecurringAnnual: input.isRecurringAnnual ?? false, - ...(input.source ? { source: input.source } : {}), - }, - }); + const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); + const created = await caller.createEntry(CreateHolidayCalendarEntrySchema.parse(params)); return { __action: "invalidate", @@ -4474,39 +4280,12 @@ const executors = { }; }, ctx: ToolContext) { assertAdminRole(ctx); + const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); const input = { id: params.id, data: UpdateHolidayCalendarEntrySchema.parse(params.data), }; - - const existing = await ctx.db.holidayCalendarEntry.findUnique({ - where: { id: input.id }, - select: { - id: true, - name: true, - date: true, - holidayCalendarId: true, - }, - }); - if (!existing) { - return { error: `Holiday calendar entry not found: ${input.id}` }; - } - - const nextDate = input.data.date !== undefined ? clampHolidayCalendarDate(input.data.date) : existing.date; - await assertHolidayCalendarEntryDateAvailable(ctx.db, { - holidayCalendarId: existing.holidayCalendarId, - date: nextDate, - }, existing.id); - - const updated = await ctx.db.holidayCalendarEntry.update({ - where: { id: input.id }, - data: { - ...(input.data.date !== undefined ? { date: nextDate } : {}), - ...(input.data.name !== undefined ? { name: input.data.name } : {}), - ...(input.data.isRecurringAnnual !== undefined ? { isRecurringAnnual: input.data.isRecurringAnnual } : {}), - ...(input.data.source !== undefined ? { source: input.data.source ?? null } : {}), - }, - }); + const updated = await caller.updateEntry(input); return { __action: "invalidate", @@ -4521,286 +4300,62 @@ const executors = { id: string; }, ctx: ToolContext) { assertAdminRole(ctx); - const existing = await ctx.db.holidayCalendarEntry.findUnique({ - where: { id: params.id }, - select: { id: true, name: true }, - }); - if (!existing) { - return { error: `Holiday calendar entry not found: ${params.id}` }; - } - - await ctx.db.holidayCalendarEntry.delete({ where: { id: params.id } }); + const caller = createHolidayCalendarCaller(createScopedCallerContext(ctx)); + const deleted = await caller.deleteEntry({ id: params.id }); return { __action: "invalidate", scope: ["holidayCalendar", "vacation"], success: true, - message: `Deleted holiday entry: ${existing.name}`, + message: `Deleted holiday entry: ${deleted.name}`, }; }, async list_roles(_params: Record, ctx: ToolContext) { - const roles = await ctx.db.role.findMany({ - select: { id: true, name: true, color: true }, - orderBy: { name: "asc" }, - }); - return roles; + const caller = createRoleCaller(createScopedCallerContext(ctx)); + const roles = await caller.list({}); + return roles.map((role) => ({ + id: role.id, + name: role.name, + color: role.color, + })); }, async search_by_skill(params: { skill: string }, ctx: ToolContext) { - const all = await ctx.db.resource.findMany({ - where: { isActive: true }, - select: { id: true, eid: true, displayName: true, skills: true }, - }); - const lower = params.skill.toLowerCase(); - const matched = all.filter((r) => { - const skills = (r.skills as Array<{ name?: string }>) ?? []; - return skills.some((s) => s.name?.toLowerCase().includes(lower)); - }).slice(0, 20); - return matched.map((r) => { - const skills = (r.skills as Array<{ name?: string; level?: number }>) ?? []; - const match = skills.find((s) => s.name?.toLowerCase().includes(lower)); - return { id: r.id, eid: r.eid, name: r.displayName, matchedSkill: match?.name, level: match?.level }; + const caller = createResourceCaller(createScopedCallerContext(ctx)); + const matched = await caller.searchBySkills({ + rules: [{ skill: params.skill, minProficiency: 1 }], + operator: "OR", }); + + return matched.slice(0, 20).map((resource) => ({ + id: resource.id, + eid: resource.eid, + name: resource.displayName, + matchedSkill: resource.matchedSkills[0]?.skill ?? null, + level: resource.matchedSkills[0]?.proficiency ?? null, + chapter: resource.chapter ?? null, + })); }, async get_statistics(_params: Record, ctx: ToolContext) { - const [ - resourceCount, projectCount, activeProjectCount, - allocationCount, vacationCount, estimateCount, - projects, chapters, - ] = await Promise.all([ - ctx.db.resource.count({ where: { isActive: true } }), - ctx.db.project.count(), - ctx.db.project.count({ where: { status: "ACTIVE" } }), - ctx.db.assignment.count(), - ctx.db.vacation.count({ where: { status: "APPROVED" } }), - ctx.db.estimate.count(), - ctx.db.project.findMany({ select: { status: true, budgetCents: true } }), - ctx.db.resource.groupBy({ - by: ["chapter"], - where: { isActive: true }, - _count: true, - orderBy: { _count: { chapter: "desc" } }, - }), - ]); - - const totalBudgetCents = projects.reduce((sum, p) => sum + (p.budgetCents ?? 0), 0); - const statusBreakdown: Record = {}; - for (const p of projects) { - statusBreakdown[p.status] = (statusBreakdown[p.status] ?? 0) + 1; - } - - return { - activeResources: resourceCount, - totalProjects: projectCount, - activeProjects: activeProjectCount, - totalAllocations: allocationCount, - approvedVacations: vacationCount, - totalEstimates: estimateCount, - totalBudget: totalBudgetCents > 0 ? fmtEur(totalBudgetCents) : "N/A", - projectsByStatus: statusBreakdown, - topChapters: chapters.slice(0, 10).map((c) => ({ - chapter: c.chapter ?? "Unassigned", - count: c._count, - })), - }; + const caller = createDashboardCaller(createScopedCallerContext(ctx)); + return caller.getStatisticsDetail(); }, async get_chargeability(params: { resourceId: string; month?: string }, ctx: ToolContext) { - // Resolve resource - const sel = { - id: true, displayName: true, eid: true, fte: true, chargeabilityTarget: true, - availability: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { code: true, name: true, dailyWorkingHours: true } }, - metroCity: { select: { name: true } }, - } as const; - let resource = await ctx.db.resource.findUnique({ where: { id: params.resourceId }, select: sel }); - if (!resource) { - resource = await ctx.db.resource.findUnique({ where: { eid: params.resourceId }, select: sel }); - } - if (!resource) { - resource = await ctx.db.resource.findFirst({ - where: { displayName: { contains: params.resourceId, mode: "insensitive" } }, - select: sel, - }); - } - if (!resource) return { error: `Resource not found: ${params.resourceId}` }; - - // Parse month const now = new Date(); const month = params.month ?? `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; - const [yearStr, moStr] = month.split("-"); - const year = Number(yearStr); - const mo = Number(moStr); - const start = new Date(year, mo - 1, 1); - const end = new Date(year, mo, 0); // last day of month - - // Get allocations in this month - const allocs = await ctx.db.assignment.findMany({ - where: { - resourceId: resource.id, - status: { not: "CANCELLED" }, - startDate: { lte: end }, - endDate: { gte: start }, - }, - select: { - hoursPerDay: true, startDate: true, endDate: true, status: true, - project: { select: { name: true, shortCode: true } }, - }, - }); - - const contexts = await loadResourceDailyAvailabilityContexts( - ctx.db, - [{ - id: resource.id, - availability: resource.availability as unknown as import("@capakraken/shared").WeekdayAvailability, - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - }], - start, - end, - ); - const context = contexts.get(resource.id); - const availability = resource.availability as unknown as WeekdayAvailability; - const baseAvailableHours = calculateEffectiveAvailableHours({ - availability, - periodStart: start, - periodEnd: end, - context: undefined, - }); - const baseWorkingDays = countEffectiveWorkingDays({ - availability, - periodStart: start, - periodEnd: end, - context: undefined, - }); - const availableHours = calculateEffectiveAvailableHours({ - availability, - periodStart: start, - periodEnd: end, - context, - }); - const workingDays = countEffectiveWorkingDays({ - availability, - periodStart: start, - periodEnd: end, - context, - }); - - const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { - periodStart: start, - periodEnd: end, - countryId: resource.countryId ?? null, - countryCode: resource.country?.code ?? null, - federalState: resource.federalState ?? null, - metroCityId: resource.metroCityId ?? null, - metroCityName: resource.metroCity?.name ?? null, - }); - const formattedHolidays = resolvedHolidays - .filter((holiday) => context?.holidayDates.has(holiday.date) ?? true) - .map(formatResolvedHoliday); - const holidayWorkdayCount = [...(context?.holidayDates ?? new Set())].reduce((count, isoDate) => ( - count + (getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0) - ), 0); - const holidayHoursDeduction = [...(context?.holidayDates ?? new Set())].reduce((sum, isoDate) => ( - sum + getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)) - ), 0); - let absenceDayEquivalent = 0; - let absenceHoursDeduction = 0; - for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) { - const dayHours = getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)); - if (dayHours <= 0 || context?.holidayDates.has(isoDate)) { - continue; - } - absenceDayEquivalent += fraction; - absenceHoursDeduction += dayHours * fraction; + const resource = await resolveResourceIdentifier(ctx, params.resourceId); + if ("error" in resource) { + return resource; } - let bookedHours = 0; - const allocDetails: Array<{ project: string; code: string; hours: number; status: string }> = []; - for (const a of allocs) { - const hours = calculateEffectiveBookedHours({ - availability, - startDate: a.startDate, - endDate: a.endDate, - hoursPerDay: a.hoursPerDay, - periodStart: start, - periodEnd: end, - context, - }); - bookedHours += hours; - allocDetails.push({ - project: a.project.name, - code: a.project.shortCode, - hours: round1(hours), - status: a.status, - }); - } - - const chargeabilityPercent = availableHours > 0 - ? Math.round((bookedHours / availableHours) * 1000) / 10 - : 0; - const targetPct = resource.chargeabilityTarget; - const targetHours = availableHours > 0 ? round1((availableHours * targetPct) / 100) : 0; - const unassignedHours = round1(Math.max(0, availableHours - bookedHours)); - - return { - resource: resource.displayName, - eid: resource.eid, + const caller = createResourceCaller(createScopedCallerContext(ctx)); + return caller.getChargeabilitySummary({ + resourceId: resource.id, month, - periodStart: fmtDate(start), - periodEnd: fmtDate(end), - fte: resource.fte, - target: `${resource.chargeabilityTarget}%`, - targetPct, - targetHours, - workingDays, - baseWorkingDays, - locationContext: { - countryCode: resource.country?.code ?? null, - country: resource.country?.name ?? resource.country?.code ?? null, - federalState: resource.federalState ?? null, - metroCity: resource.metroCity?.name ?? null, - }, - baseAvailableHours: round1(baseAvailableHours), - availableHours: round1(availableHours), - bookedHours: round1(bookedHours), - unassignedHours, - chargeability: `${chargeabilityPercent}%`, - chargeabilityPct: chargeabilityPercent, - onTarget: chargeabilityPercent >= resource.chargeabilityTarget, - holidaySummary: { - count: formattedHolidays.length, - workdayCount: holidayWorkdayCount, - hoursDeduction: round1(holidayHoursDeduction), - holidays: formattedHolidays, - breakdown: summarizeResolvedHolidays(formattedHolidays), - }, - absenceSummary: { - dayEquivalent: round1(absenceDayEquivalent), - hoursDeduction: round1(absenceHoursDeduction), - }, - capacityBreakdown: { - formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours", - baseAvailableHours: round1(baseAvailableHours), - holidayHoursDeduction: round1(holidayHoursDeduction), - absenceHoursDeduction: round1(absenceHoursDeduction), - availableHours: round1(availableHours), - }, - averages: { - availableHoursPerWorkingDay: averagePerWorkingDay(availableHours, workingDays), - bookedHoursPerWorkingDay: averagePerWorkingDay(bookedHours, workingDays), - remainingHoursPerWorkingDay: averagePerWorkingDay(Math.max(0, availableHours - bookedHours), workingDays), - }, - allocations: allocDetails, - }; + }); }, async get_chargeability_report(params: { @@ -4817,66 +4372,16 @@ const executors = { assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); const caller = createChargeabilityReportCaller(createScopedCallerContext(ctx)); - const report = await caller.getReport({ + return caller.getDetail({ startMonth: params.startMonth, endMonth: params.endMonth, ...(params.orgUnitId ? { orgUnitId: params.orgUnitId } : {}), ...(params.managementLevelGroupId ? { managementLevelGroupId: params.managementLevelGroupId } : {}), ...(params.countryId ? { countryId: params.countryId } : {}), includeProposed: params.includeProposed ?? false, + ...(params.resourceQuery ? { resourceQuery: params.resourceQuery } : {}), + ...(params.resourceLimit !== undefined ? { resourceLimit: params.resourceLimit } : {}), }); - - const resourceQuery = params.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(params.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: params.startMonth, - endMonth: params.endMonth, - orgUnitId: params.orgUnitId ?? null, - managementLevelGroupId: params.managementLevelGroupId ?? null, - countryId: params.countryId ?? null, - includeProposed: params.includeProposed ?? false, - resourceQuery: params.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, - }; }, async get_resource_computation_graph(params: { @@ -4888,34 +4393,18 @@ const executors = { assertPermission(ctx, PermissionKey.VIEW_COSTS); assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - const resource = await resolveResourceIdentifier(params.resourceId, ctx.db); + const resource = await resolveResourceIdentifier(ctx, params.resourceId); if ("error" in resource) { return resource; } const caller = createComputationGraphCaller(createScopedCallerContext(ctx)); - const graph = await caller.getResourceData({ + return caller.getResourceDataDetail({ resourceId: resource.id, month: params.month, + ...(params.domain ? { domain: params.domain } : {}), + ...(params.includeLinks !== undefined ? { includeLinks: params.includeLinks } : {}), }); - - return { - resource: { - id: resource.id, - eid: resource.eid, - displayName: resource.displayName, - }, - availableDomains: [...new Set(graph.nodes.map((node) => node.domain))], - totalNodeCount: graph.nodes.length, - totalLinkCount: graph.links.length, - ...filterGraphData({ - nodes: graph.nodes, - links: graph.links, - ...(params.domain ? { domain: params.domain } : {}), - ...(params.includeLinks !== undefined ? { includeLinks: params.includeLinks } : {}), - }), - meta: graph.meta, - }; }, async get_project_computation_graph(params: { @@ -4926,90 +4415,47 @@ const executors = { assertPermission(ctx, PermissionKey.VIEW_COSTS); assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS); - const project = await resolveProjectIdentifier(params.projectId, ctx.db); + const project = await resolveProjectIdentifier(ctx, params.projectId); if ("error" in project) { return project; } const caller = createComputationGraphCaller(createScopedCallerContext(ctx)); - const graph = await caller.getProjectData({ + return caller.getProjectDataDetail({ projectId: project.id, + ...(params.domain ? { domain: params.domain } : {}), + ...(params.includeLinks !== undefined ? { includeLinks: params.includeLinks } : {}), }); - - return { - project, - availableDomains: [...new Set(graph.nodes.map((node) => node.domain))], - totalNodeCount: graph.nodes.length, - totalLinkCount: graph.links.length, - ...filterGraphData({ - nodes: graph.nodes, - links: graph.links, - ...(params.domain ? { domain: params.domain } : {}), - ...(params.includeLinks !== undefined ? { includeLinks: params.includeLinks } : {}), - }), - meta: graph.meta, - }; }, async search_estimates(params: { projectCode?: string; query?: string; status?: string; limit?: number; }, ctx: ToolContext) { - const limit = Math.min(params.limit ?? 20, 50); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const where: Record = {}; - if (params.status) where.status = params.status; - if (params.query) { - where.name = { contains: params.query, mode: "insensitive" }; - } + const caller = createEstimateCaller(createScopedCallerContext(ctx)); + let projectId: string | undefined; if (params.projectCode) { - where.project = { shortCode: { contains: params.projectCode, mode: "insensitive" } }; + const project = await resolveProjectIdentifier(ctx, params.projectCode); + if ("error" in project) { + return project; + } + projectId = project.id; } - const estimates = await ctx.db.estimate.findMany({ - where, - select: { - id: true, name: true, status: true, latestVersionNumber: true, - project: { select: { name: true, shortCode: true } }, - _count: { select: { versions: true } }, - createdAt: true, - updatedAt: true, - }, - take: limit, - orderBy: { updatedAt: "desc" }, + return caller.list({ + ...(params.query ? { query: params.query } : {}), + ...(params.status ? { status: params.status as EstimateStatus } : {}), + ...(projectId ? { projectId } : {}), }); - - return estimates.map((e) => ({ - id: e.id, - name: e.name, - status: e.status, - project: e.project?.name ?? null, - projectCode: e.project?.shortCode ?? null, - versions: e._count.versions, - latestVersion: e.latestVersionNumber, - updated: fmtDate(e.updatedAt), - })); }, async list_clients(params: { query?: string; limit?: number }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 20, 50); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const where: Record = { isActive: true }; - if (params.query) { - where.OR = [ - { name: { contains: params.query, mode: "insensitive" } }, - { code: { contains: params.query, mode: "insensitive" } }, - ]; - } - const clients = await ctx.db.client.findMany({ - where, - select: { - id: true, name: true, code: true, - _count: { select: { projects: true } }, - }, - take: limit, - orderBy: { name: "asc" }, + const caller = createClientCaller(createScopedCallerContext(ctx)); + const clients = await caller.list({ + isActive: true, + ...(params.query ? { search: params.query } : {}), }); - return clients.map((c) => ({ + return clients.slice(0, limit).map((c) => ({ id: c.id, name: c.name, code: c.code, @@ -5018,19 +4464,14 @@ const executors = { }, async list_org_units(params: { level?: number }, ctx: ToolContext) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const where: Record = { isActive: true }; - if (params.level !== undefined) where.level = params.level; - const units = await ctx.db.orgUnit.findMany({ - where, - select: { - id: true, name: true, shortName: true, level: true, - parent: { select: { name: true } }, - _count: { select: { resources: true } }, - }, - orderBy: [{ level: "asc" }, { sortOrder: "asc" }], + const caller = createOrgUnitCaller(createScopedCallerContext(ctx)); + const units = await caller.list({ + isActive: true, + ...(params.level !== undefined ? { level: params.level } : {}), }); - return units.map((u) => ({ + const details = await Promise.all(units.map((unit) => caller.getById({ id: unit.id }))); + + return details.map((u) => ({ id: u.id, name: u.name, shortName: u.shortName, @@ -5092,93 +4533,42 @@ const executors = { hoursPerDay: number; role?: string; }, ctx: ToolContext) { assertPermission(ctx, "manageAllocations" as PermissionKey); - - // Validate resource and project exist const [resource, project] = await Promise.all([ - ctx.db.resource.findUnique({ where: { id: params.resourceId }, select: { id: true, displayName: true, eid: true, lcrCents: true } }), - ctx.db.project.findUnique({ where: { id: params.projectId }, select: { id: true, name: true, shortCode: true } }), + resolveResourceIdentifier(ctx, params.resourceId), + resolveProjectIdentifier(ctx, params.projectId), ]); - if (!resource) return { error: `Resource not found: ${params.resourceId}` }; - if (!project) return { error: `Project not found: ${params.projectId}` }; - - const dailyCostCents = Math.round(resource.lcrCents * params.hoursPerDay); - const startDate = new Date(params.startDate); - const endDate = new Date(params.endDate); - - // Check for overlapping duplicate assignments (same resource + project + overlapping dates) - const existingAssignments = await ctx.db.assignment.findMany({ - where: { resourceId: resource.id, status: { not: "CANCELLED" } }, - select: { id: true, resourceId: true, projectId: true, startDate: true, endDate: true, status: true }, - }); - const dupCheck = checkDuplicateAssignment(resource.id, project.id, startDate, endDate, existingAssignments); - if (dupCheck.isDuplicate) { - return { error: dupCheck.message + " Use update_allocation_status to modify the existing assignment." }; + if ("error" in resource) { + return resource; + } + if ("error" in project) { + return project; } - // Check for existing CANCELLED allocation with same unique key — reactivate it - const existing = await ctx.db.assignment.findUnique({ - where: { - unique_assignment: { - resourceId: resource.id, - projectId: project.id, - startDate, - endDate, - }, - }, - select: { id: true, status: true }, - }); - - if (existing) { - if (existing.status === "CANCELLED") { - // Reactivate the cancelled allocation - const updated = await ctx.db.assignment.update({ - where: { id: existing.id }, - data: { - status: "PROPOSED", - hoursPerDay: params.hoursPerDay, - percentage: (params.hoursPerDay / 8) * 100, - dailyCostCents, - ...(params.role ? { role: params.role } : {}), - }, - select: { id: true, status: true }, - }); - return { - __action: "invalidate", - scope: ["allocation", "timeline"], - success: true, - message: `Reactivated allocation: ${resource.displayName} → ${project.name} (${project.shortCode}), ${params.hoursPerDay}h/day, ${params.startDate} to ${params.endDate}`, - allocationId: updated.id, - status: updated.status, - }; - } - return { - error: `An allocation already exists for this resource/project/dates with status ${existing.status}. No new allocation created.`, - }; - } - - const assignment = await ctx.db.assignment.create({ - data: { + const caller = createAllocationCaller(createScopedCallerContext(ctx)); + try { + const result = await caller.ensureAssignment({ resourceId: resource.id, projectId: project.id, - startDate, - endDate, + startDate: new Date(`${params.startDate}T00:00:00.000Z`), + endDate: new Date(`${params.endDate}T00:00:00.000Z`), hoursPerDay: params.hoursPerDay, - percentage: (params.hoursPerDay / 8) * 100, - dailyCostCents, - status: "PROPOSED", ...(params.role ? { role: params.role } : {}), - }, - select: { id: true, status: true }, - }); + }); - return { - __action: "invalidate", - scope: ["allocation", "timeline"], - success: true, - message: `Created allocation: ${resource.displayName} → ${project.name} (${project.shortCode}), ${params.hoursPerDay}h/day, ${params.startDate} to ${params.endDate}`, - allocationId: assignment.id, - status: assignment.status, - }; + return { + __action: "invalidate", + scope: ["allocation", "timeline"], + success: true, + message: `${result.action === "reactivated" ? "Reactivated" : "Created"} allocation: ${resource.displayName} → ${project.name} (${project.shortCode}), ${params.hoursPerDay}h/day, ${params.startDate} to ${params.endDate}`, + allocationId: result.assignment.id, + status: result.assignment.status, + }; + } catch (error) { + if (error instanceof TRPCError && error.code === "CONFLICT") { + return { error: "Allocation already exists for this resource/project/dates. No new allocation created." }; + } + throw error; + } }, async cancel_allocation(params: { @@ -5188,43 +4578,42 @@ const executors = { }, ctx: ToolContext) { assertPermission(ctx, "manageAllocations" as PermissionKey); - let assignment; - if (params.allocationId) { - assignment = await ctx.db.assignment.findUnique({ - where: { id: params.allocationId }, - select: { - id: true, status: true, startDate: true, endDate: true, - resource: { select: { displayName: true } }, - project: { select: { name: true, shortCode: true } }, - }, - }); - } else if (params.resourceName && params.projectCode) { - // Find by resource + project + date overlap - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const where: Record = { - resource: { displayName: { contains: params.resourceName, mode: "insensitive" } }, - project: { shortCode: { contains: params.projectCode, mode: "insensitive" } }, - status: { not: "CANCELLED" }, - }; - if (params.startDate) where.startDate = { gte: new Date(params.startDate) }; - if (params.endDate) where.endDate = { lte: new Date(params.endDate) }; - - assignment = await ctx.db.assignment.findFirst({ - where, - select: { - id: true, status: true, startDate: true, endDate: true, - resource: { select: { displayName: true } }, - project: { select: { name: true, shortCode: true } }, - }, - orderBy: { startDate: "desc" }, - }); + const caller = createAllocationCaller(createScopedCallerContext(ctx)); + let resourceId: string | undefined; + let projectId: string | undefined; + if (!params.allocationId && params.resourceName && params.projectCode) { + const [resource, project] = await Promise.all([ + resolveResourceIdentifier(ctx, params.resourceName), + resolveProjectIdentifier(ctx, params.projectCode), + ]); + if ("error" in resource) { + return resource; + } + if ("error" in project) { + return project; + } + resourceId = resource.id; + projectId = project.id; } - if (!assignment) return { error: "Allocation not found with the given criteria." }; + let assignment; + try { + assignment = await caller.resolveAssignment({ + ...(params.allocationId ? { assignmentId: params.allocationId } : {}), + ...(resourceId ? { resourceId } : {}), + ...(projectId ? { projectId } : {}), + ...(params.startDate ? { startDate: new Date(`${params.startDate}T00:00:00.000Z`) } : {}), + ...(params.endDate ? { endDate: new Date(`${params.endDate}T00:00:00.000Z`) } : {}), + selectionMode: "WINDOW", + excludeCancelled: true, + }); + } catch { + return { error: "Allocation not found with the given criteria." }; + } - await ctx.db.assignment.update({ - where: { id: assignment.id }, - data: { status: "CANCELLED" }, + await caller.updateAssignment({ + id: assignment.id, + data: UpdateAssignmentSchema.parse({ status: AllocationStatus.CANCELLED }), }); return { @@ -5248,40 +4637,43 @@ const executors = { return { error: `Invalid status: ${params.newStatus}. Valid: ${validStatuses.join(", ")}` }; } - let assignment; - if (params.allocationId) { - assignment = await ctx.db.assignment.findUnique({ - where: { id: params.allocationId }, - select: { - id: true, status: true, startDate: true, endDate: true, - resource: { select: { displayName: true } }, - project: { select: { name: true, shortCode: true } }, - }, - }); - } else if (params.resourceName && params.projectCode) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const where: Record = { - resource: { displayName: { contains: params.resourceName, mode: "insensitive" } }, - project: { shortCode: { contains: params.projectCode, mode: "insensitive" } }, - }; - if (params.startDate) where.startDate = new Date(params.startDate); - assignment = await ctx.db.assignment.findFirst({ - where, - select: { - id: true, status: true, startDate: true, endDate: true, - resource: { select: { displayName: true } }, - project: { select: { name: true, shortCode: true } }, - }, - orderBy: { startDate: "desc" }, - }); + const caller = createAllocationCaller(createScopedCallerContext(ctx)); + let resourceId: string | undefined; + let projectId: string | undefined; + if (!params.allocationId && params.resourceName && params.projectCode) { + const [resource, project] = await Promise.all([ + resolveResourceIdentifier(ctx, params.resourceName), + resolveProjectIdentifier(ctx, params.projectCode), + ]); + if ("error" in resource) { + return resource; + } + if ("error" in project) { + return project; + } + resourceId = resource.id; + projectId = project.id; } - if (!assignment) return { error: "Allocation not found with the given criteria." }; + let assignment; + try { + assignment = await caller.resolveAssignment({ + ...(params.allocationId ? { assignmentId: params.allocationId } : {}), + ...(resourceId ? { resourceId } : {}), + ...(projectId ? { projectId } : {}), + ...(params.startDate ? { startDate: new Date(`${params.startDate}T00:00:00.000Z`) } : {}), + selectionMode: "EXACT_START", + }); + } catch { + return { error: "Allocation not found with the given criteria." }; + } const oldStatus = assignment.status; - await ctx.db.assignment.update({ - where: { id: assignment.id }, - data: { status: params.newStatus as "PROPOSED" | "CONFIRMED" | "ACTIVE" | "COMPLETED" | "CANCELLED" }, + await caller.updateAssignment({ + id: assignment.id, + data: UpdateAssignmentSchema.parse({ + status: params.newStatus as AllocationStatus, + }), }); return { @@ -5297,21 +4689,29 @@ const executors = { lcrCents?: number; chapter?: string; chargeabilityTarget?: number; }, ctx: ToolContext) { assertPermission(ctx, "manageResources" as PermissionKey); - const data: Record = {}; - if (params.displayName !== undefined) data.displayName = params.displayName; - if (params.fte !== undefined) data.fte = params.fte; - if (params.lcrCents !== undefined) data.lcrCents = params.lcrCents; - if (params.chapter !== undefined) data.chapter = params.chapter; - if (params.chargeabilityTarget !== undefined) data.chargeabilityTarget = params.chargeabilityTarget; + const resource = await resolveResourceIdentifier(ctx, params.id); + if ("error" in resource) return resource; - if (Object.keys(data).length === 0) return { error: "No fields to update" }; - - const resource = await ctx.db.resource.update({ - where: { id: params.id }, - data, - select: { id: true, eid: true, displayName: true }, + const caller = createResourceCaller(createScopedCallerContext(ctx)); + const data = UpdateResourceSchema.parse({ + ...(params.displayName !== undefined ? { displayName: params.displayName } : {}), + ...(params.fte !== undefined ? { fte: params.fte } : {}), + ...(params.lcrCents !== undefined ? { lcrCents: params.lcrCents } : {}), + ...(params.chapter !== undefined ? { chapter: params.chapter } : {}), + ...(params.chargeabilityTarget !== undefined ? { chargeabilityTarget: params.chargeabilityTarget } : {}), }); - return { __action: "invalidate", scope: ["resource"], success: true, message: `Updated resource ${resource.displayName} (${resource.eid})`, updatedFields: Object.keys(data) }; + + const updatedFields = Object.keys(data); + if (updatedFields.length === 0) return { error: "No fields to update" }; + + const updated = await caller.update({ id: resource.id, data }); + return { + __action: "invalidate", + scope: ["resource"], + success: true, + message: `Updated resource ${updated.displayName} (${updated.eid})`, + updatedFields, + }; }, async update_project(params: { @@ -5320,6 +4720,9 @@ const executors = { responsiblePerson?: string; }, ctx: ToolContext) { assertPermission(ctx, "manageProjects" as PermissionKey); + const project = await resolveProjectIdentifier(ctx, params.id); + if ("error" in project) return project; + const data: Record = {}; if (params.name !== undefined) data.name = params.name; if (params.budgetCents !== undefined) data.budgetCents = params.budgetCents; @@ -5328,19 +4731,24 @@ const executors = { // Validate responsible person against existing resources if (params.responsiblePerson !== undefined) { - const result = await resolveResponsiblePerson(params.responsiblePerson, ctx.db); + const result = await resolveResponsiblePerson(params.responsiblePerson, ctx); if ("error" in result) return { error: result.error }; data.responsiblePerson = result.displayName; } - if (Object.keys(data).length === 0) return { error: "No fields to update" }; + const parsedData = UpdateProjectSchema.parse(data); + const updatedFields = Object.keys(parsedData); + if (updatedFields.length === 0) return { error: "No fields to update" }; - const project = await ctx.db.project.update({ - where: { id: params.id }, - data, - select: { id: true, shortCode: true, name: true }, - }); - return { __action: "invalidate", scope: ["project"], success: true, message: `Updated project ${project.name} (${project.shortCode})`, updatedFields: Object.keys(data) }; + const caller = createProjectCaller(createScopedCallerContext(ctx)); + const updated = await caller.update({ id: project.id, data: parsedData }); + return { + __action: "invalidate", + scope: ["project"], + success: true, + message: `Updated project ${updated.name} (${updated.shortCode})`, + updatedFields, + }; }, async create_project(params: { @@ -5352,102 +4760,61 @@ const executors = { blueprintName?: string; clientName?: string; }, ctx: ToolContext) { assertPermission(ctx, "manageProjects" as PermissionKey); - - // Validate enums - const validOrderTypes = ["BD", "CHARGEABLE", "INTERNAL", "OVERHEAD"]; - if (!validOrderTypes.includes(params.orderType)) { - return { error: `Invalid orderType: ${params.orderType}. Valid: ${validOrderTypes.join(", ")}` }; + if (!params.responsiblePerson?.trim()) { + return { error: "responsiblePerson is required to create a project." }; } - const allocationType = params.allocationType ?? "INT"; - if (!["INT", "EXT"].includes(allocationType)) { - return { error: `Invalid allocationType: ${allocationType}. Valid: INT, EXT` }; - } - const status = params.status ?? "DRAFT"; - const validStatuses = ["DRAFT", "ACTIVE", "ON_HOLD", "COMPLETED", "CANCELLED"]; - if (!validStatuses.includes(status)) { - return { error: `Invalid status: ${status}. Valid: ${validStatuses.join(", ")}` }; - } - - // Validate short code format - if (!/^[A-Z0-9_-]+$/.test(params.shortCode)) { - return { error: `Invalid shortCode: "${params.shortCode}". Must be uppercase alphanumeric with hyphens/underscores.` }; - } - - // Check uniqueness - const existing = await ctx.db.project.findUnique({ - where: { shortCode: params.shortCode }, - select: { id: true }, - }); - if (existing) { - return { error: `A project with short code "${params.shortCode}" already exists.` }; - } - - // Validate dates - const startDate = new Date(params.startDate); - const endDate = new Date(params.endDate); - if (isNaN(startDate.getTime())) return { error: `Invalid startDate: ${params.startDate}` }; - if (isNaN(endDate.getTime())) return { error: `Invalid endDate: ${params.endDate}` }; - if (endDate < startDate) return { error: "endDate must be after startDate" }; // Validate responsible person against existing resources - let resolvedResponsible: string | undefined; - if (params.responsiblePerson) { - const result = await resolveResponsiblePerson(params.responsiblePerson, ctx.db); - if ("error" in result) return { error: result.error }; - resolvedResponsible = result.displayName; - } + const responsible = await resolveResponsiblePerson(params.responsiblePerson, ctx); + if ("error" in responsible) return { error: responsible.error }; + + const blueprintCaller = createBlueprintCaller(createScopedCallerContext(ctx)); + const clientCaller = createClientCaller(createScopedCallerContext(ctx)); - // Optional: look up blueprint by name let blueprintId: string | undefined; if (params.blueprintName) { - const bp = await ctx.db.blueprint.findFirst({ - where: { name: { contains: params.blueprintName, mode: "insensitive" } }, - select: { id: true, name: true }, - }); - if (!bp) return { error: `Blueprint not found: "${params.blueprintName}"` }; - blueprintId = bp.id; + const blueprint = await resolveEntityOrAssistantError( + () => blueprintCaller.resolveByIdentifier({ identifier: params.blueprintName! }), + `Blueprint not found: "${params.blueprintName}"`, + ); + if ("error" in blueprint) { + return blueprint; + } + blueprintId = blueprint.id; } - // Optional: look up client by name let clientId: string | undefined; if (params.clientName) { - const client = await ctx.db.client.findFirst({ - where: { name: { contains: params.clientName, mode: "insensitive" } }, - select: { id: true, name: true }, - }); - if (!client) return { error: `Client not found: "${params.clientName}"` }; + const client = await resolveEntityOrAssistantError( + () => clientCaller.resolveByIdentifier({ identifier: params.clientName! }), + `Client not found: "${params.clientName}"`, + ); + if ("error" in client) { + return client; + } clientId = client.id; } - const project = await ctx.db.project.create({ - data: { - shortCode: params.shortCode, - name: params.name, - orderType: params.orderType, - allocationType, - budgetCents: params.budgetCents, - startDate, - endDate, - winProbability: params.winProbability ?? 100, - status, - ...(resolvedResponsible ? { responsiblePerson: resolvedResponsible } : {}), - ...(params.color ? { color: params.color } : {}), - ...(blueprintId ? { blueprintId } : {}), - ...(clientId ? { clientId } : {}), - staffingReqs: [], - dynamicFields: {}, - } as Parameters[0]["data"], - select: { id: true, shortCode: true, name: true, status: true }, + const input = CreateProjectSchema.parse({ + shortCode: params.shortCode, + name: params.name, + orderType: params.orderType, + allocationType: params.allocationType ?? "INT", + budgetCents: params.budgetCents, + startDate: params.startDate, + endDate: params.endDate, + winProbability: params.winProbability ?? 100, + status: params.status ?? "DRAFT", + responsiblePerson: responsible.displayName, + ...(params.color ? { color: params.color } : {}), + ...(blueprintId ? { blueprintId } : {}), + ...(clientId ? { clientId } : {}), + staffingReqs: [], + dynamicFields: {}, }); - await ctx.db.auditLog.create({ - data: { - entityType: "Project", - entityId: project.id, - action: "CREATE", - changes: { after: project }, - }, - }); + const caller = createProjectCaller(createScopedCallerContext(ctx)); + const project = await caller.create(input); return { __action: "invalidate", @@ -5469,51 +4836,71 @@ const executors = { postalCode?: string; }, ctx: ToolContext) { assertPermission(ctx, "manageResources" as PermissionKey); + if (!params.email?.trim()) { + return { error: "email is required to create a resource." }; + } - const existing = await ctx.db.resource.findUnique({ where: { eid: params.eid }, select: { id: true } }); - if (existing) return { error: `Resource with EID "${params.eid}" already exists.` }; + const roleCaller = createRoleCaller(createScopedCallerContext(ctx)); + const countryCaller = createCountryCaller(createScopedCallerContext(ctx)); + const orgUnitCaller = createOrgUnitCaller(createScopedCallerContext(ctx)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const data: Record = { eid: params.eid, displayName: params.displayName, - fte: params.fte ?? 1, + email: params.email, lcrCents: params.lcrCents, ucrCents: params.ucrCents ?? Math.round(params.lcrCents * 0.7), chargeabilityTarget: params.chargeabilityTarget ?? 80, - isActive: true, + currency: "EUR", + availability: { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + }, skills: [], + dynamicFields: {}, + ...(params.fte !== undefined ? { fte: params.fte } : {}), }; - if (params.email) data.email = params.email; if (params.chapter) data.chapter = params.chapter; if (params.postalCode) data.postalCode = params.postalCode; if (params.roleName) { - const role = await ctx.db.role.findFirst({ - where: { name: { contains: params.roleName, mode: "insensitive" } }, - select: { id: true }, - }); - if (role) data.roleId = role.id; + const role = await resolveEntityOrAssistantError( + () => roleCaller.resolveByIdentifier({ identifier: params.roleName! }), + `Role not found: "${params.roleName}"`, + ); + if ("error" in role) { + return role; + } + data.roleId = role.id; } if (params.countryCode) { - const country = await ctx.db.country.findFirst({ - where: { code: { equals: params.countryCode, mode: "insensitive" } }, - select: { id: true }, - }); - if (country) data.countryId = country.id; + const country = await resolveEntityOrAssistantError( + () => countryCaller.resolveByIdentifier({ identifier: params.countryCode! }), + `Country not found: "${params.countryCode}"`, + ); + if ("error" in country) { + return country; + } + data.countryId = country.id; } if (params.orgUnitName) { - const ou = await ctx.db.orgUnit.findFirst({ - where: { name: { contains: params.orgUnitName, mode: "insensitive" } }, - select: { id: true }, - }); - if (ou) data.orgUnitId = ou.id; + const orgUnit = await resolveEntityOrAssistantError( + () => orgUnitCaller.resolveByIdentifier({ identifier: params.orgUnitName! }), + `Org unit not found: "${params.orgUnitName}"`, + ); + if ("error" in orgUnit) { + return orgUnit; + } + data.orgUnitId = orgUnit.id; } - const resource = await ctx.db.resource.create({ - data: data as Parameters[0]["data"], - select: { id: true, eid: true, displayName: true }, - }); + const input = CreateResourceSchema.parse(data); + const caller = createResourceCaller(createScopedCallerContext(ctx)); + const resource = await caller.create(input); return { __action: "invalidate", @@ -5526,26 +4913,12 @@ const executors = { async deactivate_resource(params: { identifier: string }, ctx: ToolContext) { assertPermission(ctx, "manageResources" as PermissionKey); + const resource = await resolveResourceIdentifier(ctx, params.identifier); + if ("error" in resource) return resource; - let resource = await ctx.db.resource.findUnique({ - where: { id: params.identifier }, - select: { id: true, displayName: true, eid: true }, - }); - if (!resource) { - resource = await ctx.db.resource.findUnique({ - where: { eid: params.identifier }, - select: { id: true, displayName: true, eid: true }, - }); - } - if (!resource) { - resource = await ctx.db.resource.findFirst({ - where: { displayName: { contains: params.identifier, mode: "insensitive" } }, - select: { id: true, displayName: true, eid: true }, - }); - } + const caller = createResourceCaller(createScopedCallerContext(ctx)); + await caller.deactivate({ id: resource.id }); if (!resource) return { error: `Resource not found: ${params.identifier}` }; - - await ctx.db.resource.update({ where: { id: resource.id }, data: { isActive: false } }); return { __action: "invalidate", scope: ["resource"], @@ -5561,120 +4934,81 @@ const executors = { startDate: string; endDate: string; isHalfDay?: boolean; halfDayPart?: string; note?: string; }, ctx: ToolContext) { - assertPermission(ctx, "manageVacations" as PermissionKey); + const resource = await resolveResourceIdentifier(ctx, params.resourceId); + if ("error" in resource) return resource; - const validTypes = ["VACATION", "SICK", "PARENTAL", "SPECIAL", "PUBLIC_HOLIDAY"]; - if (!validTypes.includes(params.type)) { - return { error: `Invalid type: ${params.type}. Valid: ${validTypes.join(", ")}` }; - } - - let resource = await ctx.db.resource.findUnique({ - where: { id: params.resourceId }, - select: { id: true, displayName: true }, - }); - if (!resource) { - resource = await ctx.db.resource.findFirst({ - where: { displayName: { contains: params.resourceId, mode: "insensitive" } }, - select: { id: true, displayName: true }, - }); - } - if (!resource) return { error: `Resource not found: ${params.resourceId}` }; - - // We need a requestedById — use a system/admin user as fallback - const systemUser = await ctx.db.user.findFirst({ select: { id: true }, orderBy: { createdAt: "asc" } }); - if (!systemUser) return { error: "No users found in system to set as requester." }; - - const vacation = await ctx.db.vacation.create({ - data: { - resourceId: resource.id, - type: params.type as unknown as import("@capakraken/db").VacationType, - startDate: new Date(params.startDate), - endDate: new Date(params.endDate), - status: "PENDING", - requestedById: systemUser.id, - ...(params.isHalfDay ? { isHalfDay: true } : {}), - ...(params.halfDayPart ? { halfDayPart: params.halfDayPart } : {}), - ...(params.note ? { note: params.note } : {}), - }, - select: { id: true, status: true }, + const caller = createVacationCaller(createScopedCallerContext(ctx)); + const type = parseAssistantVacationRequestType(params.type); + const vacation = await caller.create({ + resourceId: resource.id, + type, + startDate: new Date(params.startDate), + endDate: new Date(params.endDate), + ...(params.isHalfDay !== undefined ? { isHalfDay: params.isHalfDay } : {}), + ...(params.halfDayPart !== undefined ? { halfDayPart: params.halfDayPart as "MORNING" | "AFTERNOON" } : {}), + ...(params.note !== undefined ? { note: params.note } : {}), }); + const effectiveDays = "effectiveDays" in vacation && typeof vacation.effectiveDays === "number" + ? vacation.effectiveDays + : null; return { __action: "invalidate", scope: ["vacation"], success: true, - message: `Created ${params.type} for ${resource.displayName}: ${params.startDate} to ${params.endDate} (status: PENDING)`, + message: `Created ${type} for ${resource.displayName}: ${params.startDate} to ${params.endDate} (status: ${vacation.status}${effectiveDays !== null ? `, deducted ${effectiveDays} day(s)` : ""})`, vacationId: vacation.id, + vacation, }; }, async approve_vacation(params: { vacationId: string }, ctx: ToolContext) { - assertPermission(ctx, "manageVacations" as PermissionKey); - const vacation = await ctx.db.vacation.findUnique({ - where: { id: params.vacationId }, - select: { id: true, status: true, resource: { select: { displayName: true } } }, - }); - if (!vacation) return { error: `Vacation not found: ${params.vacationId}` }; - if (vacation.status !== "PENDING") return { error: `Vacation is ${vacation.status}, not PENDING` }; - - await ctx.db.vacation.update({ where: { id: params.vacationId }, data: { status: "APPROVED" } }); + const caller = createVacationCaller(createScopedCallerContext(ctx)); + const existing = await caller.getById({ id: params.vacationId }); + const approved = await caller.approve({ id: params.vacationId }); return { __action: "invalidate", scope: ["vacation"], success: true, - message: `Approved vacation for ${vacation.resource.displayName}`, + warnings: approved.warnings, + vacation: approved, + message: `Approved vacation for ${existing.resource?.displayName ?? params.vacationId}`, }; }, async reject_vacation(params: { vacationId: string; reason?: string }, ctx: ToolContext) { - assertPermission(ctx, "manageVacations" as PermissionKey); - const vacation = await ctx.db.vacation.findUnique({ - where: { id: params.vacationId }, - select: { id: true, status: true, resource: { select: { displayName: true } } }, - }); - if (!vacation) return { error: `Vacation not found: ${params.vacationId}` }; - if (vacation.status !== "PENDING") return { error: `Vacation is ${vacation.status}, not PENDING` }; - - await ctx.db.vacation.update({ - where: { id: params.vacationId }, - data: { status: "REJECTED", ...(params.reason ? { rejectionReason: params.reason } : {}) }, + const caller = createVacationCaller(createScopedCallerContext(ctx)); + const existing = await caller.getById({ id: params.vacationId }); + const rejected = await caller.reject({ + id: params.vacationId, + ...(params.reason !== undefined ? { rejectionReason: params.reason } : {}), }); return { __action: "invalidate", scope: ["vacation"], success: true, - message: `Rejected vacation for ${vacation.resource.displayName}${params.reason ? `: ${params.reason}` : ""}`, + vacation: rejected, + message: `Rejected vacation for ${existing.resource?.displayName ?? params.vacationId}${params.reason ? `: ${params.reason}` : ""}`, }; }, async cancel_vacation(params: { vacationId: string }, ctx: ToolContext) { - assertPermission(ctx, "manageVacations" as PermissionKey); - const vacation = await ctx.db.vacation.findUnique({ - where: { id: params.vacationId }, - select: { id: true, status: true, resource: { select: { displayName: true } } }, - }); - if (!vacation) return { error: `Vacation not found: ${params.vacationId}` }; - - await ctx.db.vacation.update({ where: { id: params.vacationId }, data: { status: "CANCELLED" } }); + const caller = createVacationCaller(createScopedCallerContext(ctx)); + const existing = await caller.getById({ id: params.vacationId }); + const cancelled = await caller.cancel({ id: params.vacationId }); return { __action: "invalidate", scope: ["vacation"], success: true, - message: `Cancelled vacation for ${vacation.resource.displayName}`, + vacation: cancelled, + message: `Cancelled vacation for ${existing.resource?.displayName ?? params.vacationId}`, }; }, async get_pending_vacation_approvals(params: { limit?: number }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 20, 50); - const vacations = await ctx.db.vacation.findMany({ - where: { status: "PENDING" }, - select: { - id: true, type: true, startDate: true, endDate: true, isHalfDay: true, - resource: { select: { displayName: true, eid: true, chapter: true } }, - }, - take: limit, - orderBy: { createdAt: "asc" }, - }); + const caller = createVacationCaller(createScopedCallerContext(ctx)); + const vacations = await caller.getPendingApprovals(); return vacations.map((v) => ({ id: v.id, resource: v.resource.displayName, @@ -5684,115 +5018,61 @@ const executors = { start: fmtDate(v.startDate), end: fmtDate(v.endDate), isHalfDay: v.isHalfDay, - })); + })).slice(0, limit); }, async get_team_vacation_overlap(params: { resourceId: string; startDate: string; endDate: string; }, ctx: ToolContext) { - const resource = await ctx.db.resource.findUnique({ - where: { id: params.resourceId }, - select: { id: true, displayName: true, chapter: true }, + const resource = await resolveResourceIdentifier(ctx, params.resourceId); + if ("error" in resource) { + return resource; + } + + const caller = createVacationCaller(createScopedCallerContext(ctx)); + return caller.getTeamOverlapDetail({ + resourceId: resource.id, + startDate: new Date(params.startDate), + endDate: new Date(params.endDate), }); - if (!resource) return { error: `Resource not found: ${params.resourceId}` }; - - const start = new Date(params.startDate); - const end = new Date(params.endDate); - - // Find team members in same chapter - const teamVacations = await ctx.db.vacation.findMany({ - where: { - resource: { chapter: resource.chapter, id: { not: resource.id }, isActive: true }, - status: { in: ["APPROVED", "PENDING"] }, - startDate: { lte: end }, - endDate: { gte: start }, - }, - select: { - type: true, startDate: true, endDate: true, status: true, - resource: { select: { displayName: true } }, - }, - take: 20, - }); - - return { - resource: resource.displayName, - chapter: resource.chapter, - period: `${params.startDate} to ${params.endDate}`, - overlappingVacations: teamVacations.map((v) => ({ - resource: v.resource.displayName, - type: v.type, - status: v.status, - start: fmtDate(v.startDate), - end: fmtDate(v.endDate), - })), - overlapCount: teamVacations.length, - }; }, // ── ENTITLEMENT ── async get_entitlement_summary(params: { year?: number; resourceName?: string }, ctx: ToolContext) { const year = params.year ?? new Date().getFullYear(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const where: Record = { year }; - if (params.resourceName) { - where.resource = { displayName: { contains: params.resourceName, mode: "insensitive" } }; - } - const entitlements = await ctx.db.vacationEntitlement.findMany({ - where, - select: { - entitledDays: true, carryoverDays: true, usedDays: true, - resource: { select: { displayName: true, eid: true } }, - }, - take: 50, - orderBy: { resource: { displayName: "asc" } }, - }); - return entitlements.map((e) => ({ - resource: e.resource.displayName, - eid: e.resource.eid, + const caller = createEntitlementCaller(createScopedCallerContext(ctx)); + return caller.getYearSummaryDetail({ year, - entitled: e.entitledDays, - carryover: e.carryoverDays ?? 0, - used: e.usedDays, - remaining: e.entitledDays + (e.carryoverDays ?? 0) - e.usedDays, - })); + ...(params.resourceName ? { resourceName: params.resourceName } : {}), + }); }, async set_entitlement(params: { resourceId: string; year: number; entitledDays: number; carryoverDays?: number; }, ctx: ToolContext) { - assertPermission(ctx, "manageVacations" as PermissionKey); - - let resource = await ctx.db.resource.findUnique({ - where: { id: params.resourceId }, - select: { id: true, displayName: true }, - }); - if (!resource) { - resource = await ctx.db.resource.findFirst({ - where: { displayName: { contains: params.resourceId, mode: "insensitive" } }, - select: { id: true, displayName: true }, - }); + if (params.carryoverDays !== undefined) { + return { + error: "Manual carryoverDays is not supported here. Carryover is computed automatically from prior-year balances.", + }; } - if (!resource) return { error: `Resource not found: ${params.resourceId}` }; - await ctx.db.vacationEntitlement.upsert({ - where: { resourceId_year: { resourceId: resource.id, year: params.year } }, - create: { - resourceId: resource.id, - year: params.year, - entitledDays: params.entitledDays, - carryoverDays: params.carryoverDays ?? 0, - usedDays: 0, - }, - update: { - entitledDays: params.entitledDays, - ...(params.carryoverDays !== undefined ? { carryoverDays: params.carryoverDays } : {}), - }, + const resource = await resolveResourceIdentifier(ctx, params.resourceId); + if ("error" in resource) return resource; + + const caller = createEntitlementCaller(createScopedCallerContext(ctx)); + const entitlement = await caller.set({ + resourceId: resource.id, + year: params.year, + entitledDays: params.entitledDays, }); return { + __action: "invalidate", + scope: ["vacation"], success: true, - message: `Set entitlement for ${resource.displayName} (${params.year}): ${params.entitledDays} days${params.carryoverDays ? ` + ${params.carryoverDays} carryover` : ""}`, + entitlement, + message: `Set entitlement for ${resource.displayName} (${params.year}): ${params.entitledDays} days`, }; }, @@ -5800,34 +5080,16 @@ const executors = { async list_demands(params: { projectId?: string; status?: string; limit?: number }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 30, 50); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const where: Record = {}; - if (params.status) where.status = params.status; - if (params.projectId) { - const project = await ctx.db.project.findFirst({ - where: { - OR: [ - { id: params.projectId }, - { shortCode: { contains: params.projectId, mode: "insensitive" } }, - ], - }, - select: { id: true }, - }); - if (project) where.projectId = project.id; + const caller = createAllocationCaller(createScopedCallerContext(ctx)); + const resolvedProject = params.projectId + ? await resolveProjectIdentifier(ctx, params.projectId) + : null; + if (resolvedProject && "error" in resolvedProject) { + return resolvedProject; } - - const demands = await ctx.db.demandRequirement.findMany({ - where, - select: { - id: true, status: true, headcount: true, - hoursPerDay: true, startDate: true, endDate: true, - role: true, // String? field - roleEntity: { select: { name: true, color: true } }, - project: { select: { name: true, shortCode: true } }, - _count: { select: { assignments: true } }, - }, - take: limit, - orderBy: { startDate: "desc" }, + const demands = await caller.listDemands({ + ...(resolvedProject ? { projectId: resolvedProject.id } : {}), + ...(params.status ? { status: params.status as AllocationStatus } : {}), }); return demands.map((d) => ({ @@ -5837,12 +5099,12 @@ const executors = { role: d.roleEntity?.name ?? d.role ?? "Unspecified", status: d.status, headcount: d.headcount, - filled: d._count.assignments, - remaining: d.headcount - d._count.assignments, + filled: d.assignments.length, + remaining: d.headcount - d.assignments.length, hoursPerDay: d.hoursPerDay, start: fmtDate(d.startDate), end: fmtDate(d.endDate), - })); + })).slice(0, limit); }, async create_demand(params: { @@ -5850,36 +5112,30 @@ const executors = { hoursPerDay: number; startDate: string; endDate: string; }, ctx: ToolContext) { assertPermission(ctx, "manageAllocations" as PermissionKey); + const roleCaller = createRoleCaller(createScopedCallerContext(ctx)); + const [project, role] = await Promise.all([ + resolveProjectIdentifier(ctx, params.projectId), + resolveEntityOrAssistantError( + () => roleCaller.resolveByIdentifier({ identifier: params.roleName }), + `Role not found: ${params.roleName}`, + ), + ]); + if ("error" in project) { + return project; + } + if ("error" in role) { + return role; + } - const project = await ctx.db.project.findFirst({ - where: { - OR: [ - { id: params.projectId }, - { shortCode: { contains: params.projectId, mode: "insensitive" } }, - ], - }, - select: { id: true, name: true, shortCode: true }, - }); - if (!project) return { error: `Project not found: ${params.projectId}` }; - - const role = await ctx.db.role.findFirst({ - where: { name: { contains: params.roleName, mode: "insensitive" } }, - select: { id: true, name: true }, - }); - if (!role) return { error: `Role not found: ${params.roleName}` }; - - const demand = await ctx.db.demandRequirement.create({ - data: { - projectId: project.id, - roleId: role.id, - headcount: params.headcount ?? 1, - hoursPerDay: params.hoursPerDay, - percentage: (params.hoursPerDay / 8) * 100, - startDate: new Date(params.startDate), - endDate: new Date(params.endDate), - status: "PROPOSED", - }, - select: { id: true }, + const caller = createAllocationCaller(createScopedCallerContext(ctx)); + const demand = await caller.createDemand({ + projectId: project.id, + roleId: role.id, + role: role.name, + headcount: params.headcount ?? 1, + hoursPerDay: params.hoursPerDay, + startDate: parseIsoDate(params.startDate, "startDate"), + endDate: parseIsoDate(params.endDate, "endDate"), }); return { @@ -5893,353 +5149,58 @@ const executors = { async fill_demand(params: { demandId: string; resourceId: string }, ctx: ToolContext) { assertPermission(ctx, "manageAllocations" as PermissionKey); + const allocationCaller = createAllocationCaller(createScopedCallerContext(ctx)); + const resource = await resolveResourceIdentifier(ctx, params.resourceId); + if ("error" in resource) return resource; - const demand = await ctx.db.demandRequirement.findUnique({ - where: { id: params.demandId }, - select: { - id: true, status: true, headcount: true, - hoursPerDay: true, startDate: true, endDate: true, - roleId: true, role: true, - roleEntity: { select: { name: true } }, - project: { select: { id: true, name: true, shortCode: true } }, - _count: { select: { assignments: true } }, - }, + const result = await allocationCaller.assignResourceToDemand({ + demandRequirementId: params.demandId, + resourceId: resource.id, }); - if (!demand) return { error: `Demand not found: ${params.demandId}` }; - - const filledCount = demand._count.assignments; - if (filledCount >= demand.headcount) return { error: "Demand is already fully filled." }; - - let resource = await ctx.db.resource.findUnique({ - where: { id: params.resourceId }, - select: { id: true, displayName: true, lcrCents: true }, - }); - if (!resource) { - resource = await ctx.db.resource.findFirst({ - where: { displayName: { contains: params.resourceId, mode: "insensitive" } }, - select: { id: true, displayName: true, lcrCents: true }, - }); - } - if (!resource) return { error: `Resource not found: ${params.resourceId}` }; - - // Check for overlapping duplicate assignments (same resource + project + overlapping dates) - const existingAssignments = await ctx.db.assignment.findMany({ - where: { resourceId: resource.id, status: { not: "CANCELLED" } }, - select: { id: true, resourceId: true, projectId: true, startDate: true, endDate: true, status: true }, - }); - const dupCheck = checkDuplicateAssignment(resource.id, demand.project.id, demand.startDate, demand.endDate, existingAssignments); - if (dupCheck.isDuplicate) { - return { error: dupCheck.message + " Use update_allocation_status to modify the existing assignment." }; - } - - const roleName = demand.roleEntity?.name ?? demand.role ?? null; - const dailyCostCents = Math.round(resource.lcrCents * demand.hoursPerDay); - const assignment = await ctx.db.assignment.create({ - data: { - resourceId: resource.id, - projectId: demand.project.id, - startDate: demand.startDate, - endDate: demand.endDate, - hoursPerDay: demand.hoursPerDay, - percentage: (demand.hoursPerDay / 8) * 100, - dailyCostCents, - status: "PROPOSED", - ...(roleName ? { role: roleName } : {}), - demandRequirementId: demand.id, - }, - select: { id: true }, - }); - - // Check if all headcount positions are now filled - const newFilled = filledCount + 1; - if (newFilled >= demand.headcount) { - await ctx.db.demandRequirement.update({ - where: { id: demand.id }, - data: { status: "COMPLETED" }, - }); - } + const roleName = result.demandRequirement.roleEntity?.name ?? result.demandRequirement.role ?? null; return { __action: "invalidate", scope: ["allocation", "timeline"], success: true, - message: `Assigned ${resource.displayName} to ${roleName ?? "demand"} on ${demand.project.name} (${demand.project.shortCode})`, - assignmentId: assignment.id, + message: `Assigned ${resource.displayName} to ${roleName ?? "demand"} on ${result.demandRequirement.project.name} (${result.demandRequirement.project.shortCode})`, + assignmentId: result.assignment.id, }; }, async check_resource_availability(params: { resourceId: string; startDate: string; endDate: string; }, ctx: ToolContext) { - let resource = await ctx.db.resource.findUnique({ - where: { id: params.resourceId }, - select: { - id: true, - displayName: true, - fte: true, - availability: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { code: true } }, - metroCity: { select: { name: true } }, - }, - }); - if (!resource) { - resource = await ctx.db.resource.findUnique({ - where: { eid: params.resourceId }, - select: { - id: true, - displayName: true, - fte: true, - availability: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { code: true } }, - metroCity: { select: { name: true } }, - }, - }); + const resource = await resolveResourceIdentifier(ctx, params.resourceId); + if ("error" in resource) { + return resource; } - if (!resource) { - resource = await ctx.db.resource.findFirst({ - where: { displayName: { contains: params.resourceId, mode: "insensitive" } }, - select: { - id: true, - displayName: true, - fte: true, - availability: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { code: true } }, - metroCity: { select: { name: true } }, - }, - }); - } - if (!resource) return { error: `Resource not found: ${params.resourceId}` }; - const start = new Date(params.startDate); - const end = new Date(params.endDate); - - const [allocations, vacations] = await Promise.all([ - ctx.db.assignment.findMany({ - where: { - resourceId: resource.id, - status: { not: "CANCELLED" }, - startDate: { lte: end }, - endDate: { gte: start }, - }, - select: { - hoursPerDay: true, startDate: true, endDate: true, status: true, - project: { select: { name: true, shortCode: true } }, - }, - }), - ctx.db.vacation.findMany({ - where: { - resourceId: resource.id, - status: { in: ["APPROVED", "PENDING"] }, - startDate: { lte: end }, - endDate: { gte: start }, - }, - select: { type: true, startDate: true, endDate: true, isHalfDay: true }, - }), - ]); - const availability = resource.availability as unknown as WeekdayAvailability; - const contexts = await loadResourceDailyAvailabilityContexts( - ctx.db, - [{ - id: resource.id, - availability, - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - }], - start, - end, - ); - const context = contexts.get(resource.id); - const periodAvailableHours = calculateEffectiveAvailableHours({ - availability, - periodStart: start, - periodEnd: end, - context, + const caller = createAllocationCaller(createScopedCallerContext(ctx)); + return caller.getResourceAvailabilitySummary({ + resourceId: resource.id, + startDate: new Date(`${params.startDate}T00:00:00.000Z`), + endDate: new Date(`${params.endDate}T00:00:00.000Z`), }); - const periodBookedHours = allocations.reduce( - (sum, allocation) => - sum + calculateEffectiveBookedHours({ - availability, - startDate: allocation.startDate, - endDate: allocation.endDate, - hoursPerDay: allocation.hoursPerDay, - periodStart: start, - periodEnd: end, - context, - }), - 0, - ); - const workingDays = countEffectiveWorkingDays({ - availability, - periodStart: start, - periodEnd: end, - context, - }); - const availableHoursPerDay = averagePerWorkingDay( - Math.max(0, periodAvailableHours - periodBookedHours), - workingDays, - ); - const bookedHoursPerDay = averagePerWorkingDay(periodBookedHours, workingDays); - const maxHoursPerDay = averagePerWorkingDay(periodAvailableHours, workingDays); - - return { - resource: resource.displayName, - period: `${params.startDate} to ${params.endDate}`, - fte: resource.fte, - workingDays, - periodAvailableHours: round1(periodAvailableHours), - periodBookedHours: round1(periodBookedHours), - periodRemainingHours: round1(Math.max(0, periodAvailableHours - periodBookedHours)), - maxHoursPerDay, - currentBookedHoursPerDay: bookedHoursPerDay, - availableHoursPerDay, - isFullyAvailable: periodBookedHours === 0 && vacations.length === 0, - existingAllocations: allocations.map((a) => ({ - project: `${a.project.name} (${a.project.shortCode})`, - hoursPerDay: a.hoursPerDay, - status: a.status, - start: fmtDate(a.startDate), - end: fmtDate(a.endDate), - })), - vacations: vacations.map((v) => ({ - type: v.type, - start: fmtDate(v.startDate), - end: fmtDate(v.endDate), - isHalfDay: v.isHalfDay, - })), - }; }, async get_staffing_suggestions(params: { projectId: string; roleName?: string; startDate?: string; endDate?: string; limit?: number; }, ctx: ToolContext) { - const limit = params.limit ?? 5; - - const project = await ctx.db.project.findFirst({ - where: { - OR: [ - { id: params.projectId }, - { shortCode: { contains: params.projectId, mode: "insensitive" } }, - ], - }, - select: { id: true, name: true, shortCode: true, startDate: true, endDate: true }, - }); - if (!project) return { error: `Project not found: ${params.projectId}` }; - - const start = params.startDate ? new Date(params.startDate) : project.startDate ?? new Date(); - const end = params.endDate ? new Date(params.endDate) : project.endDate ?? new Date(); - - // Find available resources - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resourceWhere: Record = { isActive: true }; - if (params.roleName) { - resourceWhere.areaRole = { name: { contains: params.roleName, mode: "insensitive" } }; + const project = await resolveProjectIdentifier(ctx, params.projectId); + if ("error" in project) { + return project; } - const resources = await ctx.db.resource.findMany({ - where: resourceWhere, - select: { - id: true, displayName: true, eid: true, fte: true, lcrCents: true, - availability: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { code: true } }, - metroCity: { select: { name: true } }, - areaRole: { select: { name: true } }, - chapter: true, - assignments: { - where: { - status: { not: "CANCELLED" }, - startDate: { lte: end }, - endDate: { gte: start }, - }, - select: { hoursPerDay: true, startDate: true, endDate: true }, - }, - }, - take: 50, + const caller = createStaffingCaller(createScopedCallerContext(ctx)); + return caller.getProjectStaffingSuggestions({ + projectId: project.id, + ...(params.roleName ? { roleName: params.roleName } : {}), + ...(params.startDate ? { startDate: new Date(params.startDate) } : {}), + ...(params.endDate ? { endDate: new Date(params.endDate) } : {}), + ...(params.limit ? { limit: params.limit } : {}), }); - const contexts = 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, - })), - start, - end, - ); - - // Score by availability - const scored = resources.map((r) => { - const availability = r.availability as unknown as WeekdayAvailability; - const context = contexts.get(r.id); - const workingDays = countEffectiveWorkingDays({ - availability, - periodStart: start, - periodEnd: end, - context, - }); - const maxHours = calculateEffectiveAvailableHours({ - availability, - periodStart: start, - periodEnd: end, - context, - }); - const bookedHours = r.assignments.reduce( - (sum, assignment) => - sum + calculateEffectiveBookedHours({ - availability, - startDate: assignment.startDate, - endDate: assignment.endDate, - hoursPerDay: assignment.hoursPerDay, - periodStart: start, - periodEnd: end, - context, - }), - 0, - ); - const available = Math.max(0, maxHours - bookedHours); - return { - id: r.id, - name: r.displayName, - eid: r.eid, - role: r.areaRole?.name ?? null, - chapter: r.chapter, - fte: r.fte, - lcr: fmtEur(r.lcrCents), - workingDays, - availableHours: round1(available), - bookedHours: round1(bookedHours), - availableHoursPerDay: averagePerWorkingDay(available, workingDays), - utilization: maxHours > 0 ? Math.round((bookedHours / maxHours) * 100) : 0, - }; - }) - .filter((r) => r.availableHours > 0) - .sort((a, b) => b.availableHours - a.availableHours) - .slice(0, limit); - - return { - project: `${project.name} (${project.shortCode})`, - period: `${fmtDate(start)} to ${fmtDate(end)}`, - suggestions: scored, - }; }, async find_capacity(params: { @@ -6247,121 +5208,22 @@ const executors = { minHoursPerDay?: number; roleName?: string; chapter?: string; limit?: number; }, ctx: ToolContext) { - const limit = params.limit ?? 20; - const minHours = params.minHoursPerDay ?? 4; - const start = new Date(params.startDate); - const end = new Date(params.endDate); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const where: Record = { isActive: true }; - if (params.roleName) { - where.areaRole = { name: { contains: params.roleName, mode: "insensitive" } }; - } - if (params.chapter) { - where.chapter = { contains: params.chapter, mode: "insensitive" }; - } - - const resources = await ctx.db.resource.findMany({ - where, - select: { - id: true, displayName: true, eid: true, fte: true, - availability: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { code: true } }, - metroCity: { select: { name: true } }, - areaRole: { select: { name: true } }, - chapter: true, - assignments: { - where: { - status: { not: "CANCELLED" }, - startDate: { lte: end }, - endDate: { gte: start }, - }, - select: { hoursPerDay: true, startDate: true, endDate: true }, - }, - }, - take: 100, + const caller = createStaffingCaller(createScopedCallerContext(ctx)); + return caller.searchCapacity({ + startDate: new Date(params.startDate), + endDate: new Date(params.endDate), + minHoursPerDay: params.minHoursPerDay ?? 4, + ...(params.roleName ? { roleName: params.roleName } : {}), + ...(params.chapter ? { chapter: params.chapter } : {}), + ...(params.limit ? { limit: params.limit } : {}), }); - const contexts = 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, - })), - start, - end, - ); - - const available = resources - .map((r) => { - const availability = r.availability as unknown as WeekdayAvailability; - const context = contexts.get(r.id); - const workingDays = countEffectiveWorkingDays({ - availability, - periodStart: start, - periodEnd: end, - context, - }); - const maxH = calculateEffectiveAvailableHours({ - availability, - periodStart: start, - periodEnd: end, - context, - }); - const booked = r.assignments.reduce( - (sum, assignment) => - sum + calculateEffectiveBookedHours({ - availability, - startDate: assignment.startDate, - endDate: assignment.endDate, - hoursPerDay: assignment.hoursPerDay, - periodStart: start, - periodEnd: end, - context, - }), - 0, - ); - const remaining = Math.max(0, maxH - booked); - return { - id: r.id, - name: r.displayName, - eid: r.eid, - role: r.areaRole?.name ?? null, - chapter: r.chapter, - workingDays, - availableHours: round1(remaining), - availableHoursPerDay: averagePerWorkingDay(remaining, workingDays), - }; - }) - .filter((r) => r.availableHoursPerDay >= minHours) - .sort((a, b) => b.availableHours - a.availableHours) - .slice(0, limit); - - return { - period: `${params.startDate} to ${params.endDate}`, - minHoursFilter: minHours, - results: available, - totalFound: available.length, - }; }, // ── BLUEPRINT ── async list_blueprints(_params: Record, ctx: ToolContext) { - const blueprints = await ctx.db.blueprint.findMany({ - select: { - id: true, name: true, - _count: { select: { projects: true } }, - }, - orderBy: { name: "asc" }, - }); + const caller = createBlueprintCaller(createScopedCallerContext(ctx)); + const blueprints = await caller.listSummaries(); return blueprints.map((b) => ({ id: b.id, name: b.name, @@ -6370,17 +5232,8 @@ const executors = { }, async get_blueprint(params: { identifier: string }, ctx: ToolContext) { - let bp = await ctx.db.blueprint.findUnique({ - where: { id: params.identifier }, - select: { id: true, name: true, fieldDefs: true, rolePresets: true }, - }); - if (!bp) { - bp = await ctx.db.blueprint.findFirst({ - where: { name: { contains: params.identifier, mode: "insensitive" } }, - select: { id: true, name: true, fieldDefs: true, rolePresets: true }, - }); - } - if (!bp) return { error: `Blueprint not found: ${params.identifier}` }; + const caller = createBlueprintCaller(createScopedCallerContext(ctx)); + const bp = await caller.getByIdentifier({ identifier: params.identifier }); return { id: bp.id, name: bp.name, @@ -6392,20 +5245,10 @@ const executors = { // ── RATE CARDS ── async list_rate_cards(params: { query?: string; limit?: number }, ctx: ToolContext) { - const limit = Math.min(params.limit ?? 20, 50); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const where: Record = { isActive: true }; - if (params.query) { - where.name = { contains: params.query, mode: "insensitive" }; - } - const cards = await ctx.db.rateCard.findMany({ - where, - select: { - id: true, name: true, effectiveFrom: true, effectiveTo: true, - _count: { select: { lines: true } }, - }, - take: limit, - orderBy: { effectiveFrom: "desc" }, + const caller = createRateCardCaller(createScopedCallerContext(ctx)); + const cards = await caller.list({ + isActive: true, + ...(params.query ? { search: params.query } : {}), }); return cards.map((c) => ({ id: c.id, @@ -6413,238 +5256,447 @@ const executors = { effectiveFrom: fmtDate(c.effectiveFrom), effectiveTo: fmtDate(c.effectiveTo), lineCount: c._count.lines, - })); + })).slice(0, Math.min(params.limit ?? 20, 50)); }, async resolve_rate(params: { resourceId?: string; roleName?: string; date?: string }, ctx: ToolContext) { - const date = params.date ? new Date(params.date) : new Date(); - - // Find active rate card for this date - const card = await ctx.db.rateCard.findFirst({ - where: { - isActive: true, - effectiveFrom: { lte: date }, - OR: [ - { effectiveTo: null }, - { effectiveTo: { gte: date } }, - ], - }, - select: { - id: true, name: true, - lines: { - select: { - id: true, costRateCents: true, billRateCents: true, - role: { select: { name: true } }, - seniority: true, chapter: true, location: true, - }, - }, - }, - orderBy: { effectiveFrom: "desc" }, - }); - - if (!card) return { error: "No active rate card found for the given date." }; - - // If resource specified, try to match their role + const caller = createRateCardCaller(createScopedCallerContext(ctx)); if (params.resourceId) { - let resource = await ctx.db.resource.findUnique({ - where: { id: params.resourceId }, - select: { displayName: true, areaRole: { select: { name: true } }, managementLevelGroup: { select: { name: true } } }, + const resource = await resolveResourceIdentifier(ctx, params.resourceId); + if ("error" in resource) { + return resource; + } + return caller.resolveBestRate({ + resourceId: resource.id, + ...(params.date ? { date: new Date(params.date) } : {}), }); - if (!resource) { - resource = await ctx.db.resource.findFirst({ - where: { displayName: { contains: params.resourceId, mode: "insensitive" } }, - select: { displayName: true, areaRole: { select: { name: true } }, managementLevelGroup: { select: { name: true } } }, - }); - } - - if (resource) { - const match = card.lines.find((l) => - l.role?.name === resource!.areaRole?.name, - ); - if (match) { - return { - rateCard: card.name, - resource: resource.displayName, - rate: fmtEur(match.costRateCents), - rateCents: match.costRateCents, - matchedBy: match.role?.name ? `role: ${match.role.name}` : "unknown", - }; - } - } } - // Return all lines - return { - rateCard: card.name, - lines: card.lines.map((l) => ({ - role: l.role?.name ?? null, - seniority: l.seniority, - chapter: l.chapter, - location: l.location, - costRate: fmtEur(l.costRateCents), - billRate: l.billRateCents != null ? fmtEur(l.billRateCents) : null, - })), - }; + return caller.resolveBestRate({ + ...(params.roleName ? { roleName: params.roleName } : {}), + ...(params.date ? { date: new Date(params.date) } : {}), + }); }, // ── ESTIMATES ── async get_estimate_detail(params: { estimateId: string }, ctx: ToolContext) { - const estimate = await ctx.db.estimate.findUnique({ - where: { id: params.estimateId }, - select: { - id: true, name: true, status: true, latestVersionNumber: true, - project: { select: { name: true, shortCode: true } }, - versions: { - select: { - id: true, versionNumber: true, status: true, label: true, - notes: true, lockedAt: true, - _count: { select: { demandLines: true } }, - }, - orderBy: { versionNumber: "desc" }, - take: 5, - }, - }, - }); - if (!estimate) return { error: `Estimate not found: ${params.estimateId}` }; - - return { - id: estimate.id, - name: estimate.name, - status: estimate.status, - project: estimate.project?.name ?? null, - projectCode: estimate.project?.shortCode ?? null, - latestVersion: estimate.latestVersionNumber, - versions: estimate.versions.map((v) => ({ - id: v.id, - version: v.versionNumber, - status: v.status, - label: v.label, - demandLineCount: v._count.demandLines, - lockedAt: v.lockedAt ? fmtDate(v.lockedAt) : null, - })), - }; + assertPermission(ctx, PermissionKey.VIEW_COSTS); + const caller = createEstimateCaller(createScopedCallerContext(ctx)); + return caller.getById({ id: params.estimateId }); }, - async create_estimate(params: { name: string; projectId: string }, ctx: ToolContext) { - assertPermission(ctx, "manageProjects" as PermissionKey); + async list_estimate_versions(params: { estimateId: string }, ctx: ToolContext) { + const caller = createEstimateCaller(createScopedCallerContext(ctx)); + return caller.listVersions({ estimateId: params.estimateId }); + }, - const project = await ctx.db.project.findFirst({ - where: { - OR: [ - { id: params.projectId }, - { shortCode: { contains: params.projectId, mode: "insensitive" } }, - ], - }, - select: { id: true, name: true, shortCode: true }, + async get_estimate_version_snapshot(params: { + estimateId: string; + versionId?: string; + }, ctx: ToolContext) { + assertPermission(ctx, PermissionKey.VIEW_COSTS); + const caller = createEstimateCaller(createScopedCallerContext(ctx)); + return caller.getVersionSnapshot({ + estimateId: params.estimateId, + ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), }); - if (!project) return { error: `Project not found: ${params.projectId}` }; + }, - const estimate = await ctx.db.estimate.create({ - data: { - name: params.name, - projectId: project.id, - status: "DRAFT", - latestVersionNumber: 0, - }, - select: { id: true, name: true }, + async create_estimate(params: { + projectId?: string; + projectCode?: string; + name: string; + opportunityId?: string; + baseCurrency?: string; + status?: EstimateStatus; + versionLabel?: string; + versionNotes?: string; + assumptions?: CreateEstimateInput["assumptions"]; + scopeItems?: CreateEstimateInput["scopeItems"]; + demandLines?: CreateEstimateInput["demandLines"]; + resourceSnapshots?: CreateEstimateInput["resourceSnapshots"]; + metrics?: CreateEstimateInput["metrics"]; + }, ctx: ToolContext) { + const caller = createEstimateCaller(createScopedCallerContext(ctx)); + let projectId = params.projectId; + if (!projectId && params.projectCode) { + const project = await resolveProjectIdentifier(ctx, params.projectCode); + if ("error" in project) { + return project; + } + projectId = project.id; + } + + const estimate = await caller.create({ + name: params.name, + ...(projectId ? { projectId } : {}), + ...(params.opportunityId !== undefined ? { opportunityId: params.opportunityId } : {}), + ...(params.baseCurrency !== undefined ? { baseCurrency: params.baseCurrency } : {}), + ...(params.status !== undefined ? { status: params.status } : {}), + ...(params.versionLabel !== undefined ? { versionLabel: params.versionLabel } : {}), + ...(params.versionNotes !== undefined ? { versionNotes: params.versionNotes } : {}), + ...(params.assumptions !== undefined ? { assumptions: params.assumptions } : {}), + ...(params.scopeItems !== undefined ? { scopeItems: params.scopeItems } : {}), + ...(params.demandLines !== undefined ? { demandLines: params.demandLines } : {}), + ...(params.resourceSnapshots !== undefined ? { resourceSnapshots: params.resourceSnapshots } : {}), + ...(params.metrics !== undefined ? { metrics: params.metrics } : {}), }); return { __action: "invalidate", scope: ["estimate"], success: true, - message: `Created estimate "${estimate.name}" for ${project.name} (${project.shortCode})`, + estimate, estimateId: estimate.id, + message: `Created estimate "${estimate.name}".`, + }; + }, + + async clone_estimate(params: { + sourceEstimateId: string; + name?: string; + projectId?: string; + projectCode?: string; + }, ctx: ToolContext) { + const caller = createEstimateCaller(createScopedCallerContext(ctx)); + let projectId = params.projectId; + if (!projectId && params.projectCode) { + const project = await resolveProjectIdentifier(ctx, params.projectCode); + if ("error" in project) { + return project; + } + projectId = project.id; + } + + const estimate = await caller.clone({ + sourceEstimateId: params.sourceEstimateId, + ...(params.name !== undefined ? { name: params.name } : {}), + ...(projectId ? { projectId } : {}), + }); + + return { + __action: "invalidate", + scope: ["estimate"], + success: true, + estimate, + estimateId: estimate.id, + message: `Cloned estimate "${estimate.name}".`, + }; + }, + + async update_estimate_draft(params: { + id: string; + projectId?: string; + projectCode?: string; + name?: string; + opportunityId?: string; + baseCurrency?: string; + status?: EstimateStatus; + versionLabel?: string; + versionNotes?: string; + assumptions?: UpdateEstimateDraftInput["assumptions"]; + scopeItems?: UpdateEstimateDraftInput["scopeItems"]; + demandLines?: UpdateEstimateDraftInput["demandLines"]; + resourceSnapshots?: UpdateEstimateDraftInput["resourceSnapshots"]; + metrics?: UpdateEstimateDraftInput["metrics"]; + }, ctx: ToolContext) { + const caller = createEstimateCaller(createScopedCallerContext(ctx)); + let projectId = params.projectId; + if (!projectId && params.projectCode) { + const project = await resolveProjectIdentifier(ctx, params.projectCode); + if ("error" in project) { + return project; + } + projectId = project.id; + } + + const estimate = await caller.updateDraft({ + id: params.id, + ...(projectId ? { projectId } : {}), + ...(params.name !== undefined ? { name: params.name } : {}), + ...(params.opportunityId !== undefined ? { opportunityId: params.opportunityId } : {}), + ...(params.baseCurrency !== undefined ? { baseCurrency: params.baseCurrency } : {}), + ...(params.status !== undefined ? { status: params.status } : {}), + ...(params.versionLabel !== undefined ? { versionLabel: params.versionLabel } : {}), + ...(params.versionNotes !== undefined ? { versionNotes: params.versionNotes } : {}), + ...(params.assumptions !== undefined ? { assumptions: params.assumptions } : {}), + ...(params.scopeItems !== undefined ? { scopeItems: params.scopeItems } : {}), + ...(params.demandLines !== undefined ? { demandLines: params.demandLines } : {}), + ...(params.resourceSnapshots !== undefined ? { resourceSnapshots: params.resourceSnapshots } : {}), + ...(params.metrics !== undefined ? { metrics: params.metrics } : {}), + }); + + return { + __action: "invalidate", + scope: ["estimate"], + success: true, + estimate, + estimateId: estimate.id, + message: `Updated estimate draft "${estimate.name}".`, + }; + }, + + async submit_estimate_version(params: { + estimateId: string; + versionId?: string; + }, ctx: ToolContext) { + const caller = createEstimateCaller(createScopedCallerContext(ctx)); + const estimate = await caller.submitVersion({ + estimateId: params.estimateId, + ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), + }); + return { + __action: "invalidate", + scope: ["estimate"], + success: true, + estimate, + estimateId: estimate.id, + message: `Submitted estimate version for "${estimate.name}".`, + }; + }, + + async approve_estimate_version(params: { + estimateId: string; + versionId?: string; + }, ctx: ToolContext) { + const caller = createEstimateCaller(createScopedCallerContext(ctx)); + const estimate = await caller.approveVersion({ + estimateId: params.estimateId, + ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), + }); + return { + __action: "invalidate", + scope: ["estimate"], + success: true, + estimate, + estimateId: estimate.id, + message: `Approved estimate version for "${estimate.name}".`, + }; + }, + + async create_estimate_revision(params: { + estimateId: string; + sourceVersionId?: string; + label?: string; + notes?: string; + }, ctx: ToolContext) { + const caller = createEstimateCaller(createScopedCallerContext(ctx)); + const estimate = await caller.createRevision({ + estimateId: params.estimateId, + ...(params.sourceVersionId !== undefined ? { sourceVersionId: params.sourceVersionId } : {}), + ...(params.label !== undefined ? { label: params.label } : {}), + ...(params.notes !== undefined ? { notes: params.notes } : {}), + }); + return { + __action: "invalidate", + scope: ["estimate"], + success: true, + estimate, + estimateId: estimate.id, + message: `Created a new estimate revision for "${estimate.name}".`, + }; + }, + + async create_estimate_export(params: { + estimateId: string; + versionId?: string; + format: EstimateExportFormat; + }, ctx: ToolContext) { + const caller = createEstimateCaller(createScopedCallerContext(ctx)); + const estimate = await caller.createExport({ + estimateId: params.estimateId, + format: params.format, + ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), + }); + return { + __action: "invalidate", + scope: ["estimate"], + success: true, + estimate, + estimateId: estimate.id, + message: `Created ${params.format} export for estimate "${estimate.name}".`, + }; + }, + + async create_estimate_planning_handoff(params: { + estimateId: string; + versionId?: string; + }, ctx: ToolContext) { + const caller = createEstimateCaller(createScopedCallerContext(ctx)); + const result = await caller.createPlanningHandoff({ + estimateId: params.estimateId, + ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), + }); + return { + __action: "invalidate", + scope: ["estimate", "allocation", "timeline"], + success: true, + ...result, + message: `Created planning handoff for estimate ${params.estimateId}.`, + }; + }, + + async generate_estimate_weekly_phasing(params: { + estimateId: string; + startDate: string; + endDate: string; + pattern?: "even" | "front_loaded" | "back_loaded" | "custom"; + }, ctx: ToolContext) { + const caller = createEstimateCaller(createScopedCallerContext(ctx)); + const result = await caller.generateWeeklyPhasing({ + estimateId: params.estimateId, + startDate: params.startDate, + endDate: params.endDate, + ...(params.pattern !== undefined ? { pattern: params.pattern } : {}), + }); + return { + __action: "invalidate", + scope: ["estimate"], + success: true, + ...result, + message: `Generated weekly phasing for estimate ${params.estimateId}.`, + }; + }, + + async get_estimate_weekly_phasing(params: { + estimateId: string; + }, ctx: ToolContext) { + const caller = createEstimateCaller(createScopedCallerContext(ctx)); + return caller.getWeeklyPhasing({ estimateId: params.estimateId }); + }, + + async get_estimate_commercial_terms(params: { + estimateId: string; + versionId?: string; + }, ctx: ToolContext) { + const caller = createEstimateCaller(createScopedCallerContext(ctx)); + return caller.getCommercialTerms({ + estimateId: params.estimateId, + ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), + }); + }, + + async update_estimate_commercial_terms(params: { + estimateId: string; + versionId?: string; + terms: Record; + }, ctx: ToolContext) { + const caller = createEstimateCaller(createScopedCallerContext(ctx)); + const result = await caller.updateCommercialTerms({ + estimateId: params.estimateId, + terms: params.terms, + ...(params.versionId !== undefined ? { versionId: params.versionId } : {}), + }); + return { + __action: "invalidate", + scope: ["estimate"], + success: true, + ...result, + message: `Updated commercial terms for estimate ${params.estimateId}.`, }; }, // ── ROLES ── - async create_role(params: { name: string; color?: string }, ctx: ToolContext) { - assertPermission(ctx, "manageResources" as PermissionKey); - const role = await ctx.db.role.create({ - data: { name: params.name, color: params.color ?? "#6b7280" }, - select: { id: true, name: true }, - }); - return { __action: "invalidate", scope: ["role"], success: true, message: `Created role: ${role.name}`, roleId: role.id }; + async create_role(params: { + name: string; + description?: string; + color?: string; + }, ctx: ToolContext) { + const caller = createRoleCaller(createScopedCallerContext(ctx)); + const role = await caller.create(CreateRoleSchema.parse(params)); + return { __action: "invalidate", scope: ["role"], success: true, message: `Created role: ${role.name}`, roleId: role.id, role }; }, - async update_role(params: { id: string; name?: string; color?: string }, ctx: ToolContext) { - assertPermission(ctx, "manageResources" as PermissionKey); - const data: Record = {}; - if (params.name !== undefined) data.name = params.name; - if (params.color !== undefined) data.color = params.color; + async update_role(params: { + id: string; + name?: string; + description?: string; + color?: string; + isActive?: boolean; + }, ctx: ToolContext) { + const caller = createRoleCaller(createScopedCallerContext(ctx)); + const data = UpdateRoleSchema.parse({ + ...(params.name !== undefined ? { name: params.name } : {}), + ...(params.description !== undefined ? { description: params.description } : {}), + ...(params.color !== undefined ? { color: params.color } : {}), + ...(params.isActive !== undefined ? { isActive: params.isActive } : {}), + }); if (Object.keys(data).length === 0) return { error: "No fields to update" }; - const role = await ctx.db.role.update({ where: { id: params.id }, data, select: { id: true, name: true } }); - return { __action: "invalidate", scope: ["role"], success: true, message: `Updated role: ${role.name}` }; + const role = await caller.update({ id: params.id, data }); + return { __action: "invalidate", scope: ["role"], success: true, message: `Updated role: ${role.name}`, roleId: role.id, role }; }, async delete_role(params: { id: string }, ctx: ToolContext) { - assertPermission(ctx, "manageResources" as PermissionKey); - const role = await ctx.db.role.findUnique({ where: { id: params.id }, select: { name: true, _count: { select: { areaResources: true } } } }); - if (!role) return { error: `Role not found: ${params.id}` }; - if (role._count.areaResources > 0) return { error: `Cannot delete role "${role.name}" — ${role._count.areaResources} resources are assigned to it.` }; - await ctx.db.role.delete({ where: { id: params.id } }); + const caller = createRoleCaller(createScopedCallerContext(ctx)); + const role = await caller.getById({ id: params.id }); + await caller.delete({ id: params.id }); return { __action: "invalidate", scope: ["role"], success: true, message: `Deleted role: ${role.name}` }; }, // ── CLIENTS ── - async create_client(params: { name: string; code?: string }, ctx: ToolContext) { - assertPermission(ctx, "manageProjects" as PermissionKey); - const client = await ctx.db.client.create({ - data: { name: params.name, ...(params.code ? { code: params.code } : {}) }, - select: { id: true, name: true, code: true }, - }); - return { __action: "invalidate", scope: ["client"], success: true, message: `Created client: ${client.name}`, clientId: client.id }; + async create_client(params: { + name: string; + code?: string; + parentId?: string; + sortOrder?: number; + tags?: string[]; + }, ctx: ToolContext) { + const caller = createClientCaller(createScopedCallerContext(ctx)); + const client = await caller.create(CreateClientSchema.parse(params)); + return { __action: "invalidate", scope: ["client"], success: true, message: `Created client: ${client.name}`, clientId: client.id, client }; }, - async update_client(params: { id: string; name?: string; code?: string }, ctx: ToolContext) { - assertPermission(ctx, "manageProjects" as PermissionKey); - const data: Record = {}; - if (params.name !== undefined) data.name = params.name; - if (params.code !== undefined) data.code = params.code; + async update_client(params: { + id: string; + name?: string; + code?: string | null; + sortOrder?: number; + isActive?: boolean; + parentId?: string | null; + tags?: string[]; + }, ctx: ToolContext) { + const caller = createClientCaller(createScopedCallerContext(ctx)); + const data = UpdateClientSchema.parse({ + ...(params.name !== undefined ? { name: params.name } : {}), + ...(params.code !== undefined ? { code: params.code } : {}), + ...(params.sortOrder !== undefined ? { sortOrder: params.sortOrder } : {}), + ...(params.isActive !== undefined ? { isActive: params.isActive } : {}), + ...(params.parentId !== undefined ? { parentId: params.parentId } : {}), + ...(params.tags !== undefined ? { tags: params.tags } : {}), + }); if (Object.keys(data).length === 0) return { error: "No fields to update" }; - const client = await ctx.db.client.update({ where: { id: params.id }, data, select: { id: true, name: true } }); - return { __action: "invalidate", scope: ["client"], success: true, message: `Updated client: ${client.name}` }; + const client = await caller.update({ id: params.id, data }); + return { __action: "invalidate", scope: ["client"], success: true, message: `Updated client: ${client.name}`, clientId: client.id, client }; }, // ── ADMIN / CONFIG ── async list_countries(params: { includeInactive?: boolean; search?: string }, ctx: ToolContext) { - const countries = await ctx.db.country.findMany({ - where: { - ...(params.includeInactive ? {} : { isActive: true }), - ...(params.search - ? { - OR: [ - { code: { contains: params.search.trim().toUpperCase(), mode: "insensitive" } }, - { name: { contains: params.search.trim(), mode: "insensitive" } }, - ], - } - : {}), - }, - select: { - id: true, code: true, name: true, dailyWorkingHours: true, - scheduleRules: true, - isActive: true, - metroCities: { select: { id: true, name: true }, orderBy: { name: "asc" as const } }, - }, - orderBy: { name: "asc" }, - }); + const caller = createCountryCaller(createScopedCallerContext(ctx)); + const countries = await caller.list( + params.includeInactive + ? undefined + : { isActive: true }, + ); + const normalizedSearch = params.search?.trim().toLowerCase(); + const filteredCountries = normalizedSearch + ? countries.filter((country) => + country.code.toLowerCase().includes(normalizedSearch) + || country.name.toLowerCase().includes(normalizedSearch)) + : countries; + return { - count: countries.length, - countries: countries.map(formatCountry), + count: filteredCountries.length, + countries: filteredCountries.map(formatCountry), }; }, async get_country(params: { identifier: string }, ctx: ToolContext) { - const country = await findCountryByIdentifier(ctx.db, params.identifier); - if (!country) { - return { error: `Country not found: ${params.identifier}` }; - } - + const caller = createCountryCaller(createScopedCallerContext(ctx)); + const country = await caller.getByIdentifier({ identifier: params.identifier }); return formatCountry(country); }, @@ -6655,32 +5707,8 @@ const executors = { scheduleRules?: Prisma.JsonValue | null; }, ctx: ToolContext) { assertAdminRole(ctx); - const input = CreateCountrySchema.parse(params); - - const existing = await ctx.db.country.findUnique({ where: { code: input.code } }); - if (existing) { - return { error: `Country code "${input.code}" already exists` }; - } - - const created = await ctx.db.country.create({ - data: { - code: input.code, - name: input.name, - dailyWorkingHours: input.dailyWorkingHours, - ...(input.scheduleRules !== undefined - ? { - scheduleRules: - input.scheduleRules === null - ? Prisma.JsonNull - : input.scheduleRules as Prisma.InputJsonValue, - } - : {}), - }, - include: { - metroCities: { orderBy: { name: "asc" } }, - _count: { select: { resources: true } }, - }, - }); + const caller = createCountryCaller(createScopedCallerContext(ctx)); + const created = await caller.create(CreateCountrySchema.parse(params)); return { __action: "invalidate", @@ -6702,47 +5730,12 @@ const executors = { }; }, ctx: ToolContext) { assertAdminRole(ctx); + const caller = createCountryCaller(createScopedCallerContext(ctx)); const input = { id: params.id, data: UpdateCountrySchema.parse(params.data), }; - - const existing = await ctx.db.country.findUnique({ - where: { id: input.id }, - select: { id: true, code: true }, - }); - if (!existing) { - return { error: `Country not found: ${input.id}` }; - } - - if (input.data.code && input.data.code !== existing.code) { - const conflict = await ctx.db.country.findUnique({ where: { code: input.data.code } }); - if (conflict) { - return { error: `Country code "${input.data.code}" already exists` }; - } - } - - const updated = await ctx.db.country.update({ - where: { id: input.id }, - data: { - ...(input.data.code !== undefined ? { code: input.data.code } : {}), - ...(input.data.name !== undefined ? { name: input.data.name } : {}), - ...(input.data.dailyWorkingHours !== undefined ? { dailyWorkingHours: input.data.dailyWorkingHours } : {}), - ...(input.data.scheduleRules !== undefined - ? { - scheduleRules: - input.data.scheduleRules === null - ? Prisma.JsonNull - : input.data.scheduleRules as Prisma.InputJsonValue, - } - : {}), - ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}), - }, - include: { - metroCities: { orderBy: { name: "asc" } }, - _count: { select: { resources: true } }, - }, - }); + const updated = await caller.update(input); return { __action: "invalidate", @@ -6755,20 +5748,8 @@ const executors = { async create_metro_city(params: { countryId: string; name: string }, ctx: ToolContext) { assertAdminRole(ctx); - const input = CreateMetroCitySchema.parse(params); - - const country = await ctx.db.country.findUnique({ - where: { id: input.countryId }, - select: { id: true, name: true }, - }); - if (!country) { - return { error: `Country not found: ${input.countryId}` }; - } - - const created = await ctx.db.metroCity.create({ - data: { name: input.name, countryId: input.countryId }, - select: { id: true, name: true, countryId: true }, - }); + const caller = createCountryCaller(createScopedCallerContext(ctx)); + const created = await caller.createCity(CreateMetroCitySchema.parse(params)); return { __action: "invalidate", @@ -6781,26 +5762,12 @@ const executors = { async update_metro_city(params: { id: string; data: { name?: string } }, ctx: ToolContext) { assertAdminRole(ctx); + const caller = createCountryCaller(createScopedCallerContext(ctx)); const input = { id: params.id, data: UpdateMetroCitySchema.parse(params.data), }; - - const existing = await ctx.db.metroCity.findUnique({ - where: { id: input.id }, - select: { id: true, name: true, countryId: true }, - }); - if (!existing) { - return { error: `Metro city not found: ${input.id}` }; - } - - const updated = await ctx.db.metroCity.update({ - where: { id: input.id }, - data: { - ...(input.data.name !== undefined ? { name: input.data.name } : {}), - }, - select: { id: true, name: true, countryId: true }, - }); + const updated = await caller.updateCity(input); return { __action: "invalidate", @@ -6813,35 +5780,20 @@ const executors = { async delete_metro_city(params: { id: string }, ctx: ToolContext) { assertAdminRole(ctx); - const city = await ctx.db.metroCity.findUnique({ - where: { id: params.id }, - include: { _count: { select: { resources: true } } }, - }); - if (!city) { - return { error: `Metro city not found: ${params.id}` }; - } - if (city._count.resources > 0) { - return { error: `Cannot delete metro city assigned to ${city._count.resources} resource(s)` }; - } - - await ctx.db.metroCity.delete({ where: { id: params.id } }); + const caller = createCountryCaller(createScopedCallerContext(ctx)); + const deleted = await caller.deleteCity({ id: params.id }); return { __action: "invalidate", scope: ["country", "resource", "holidayCalendar", "vacation"], success: true, - message: `Deleted metro city: ${city.name}`, + message: `Deleted metro city: ${deleted.name ?? params.id}`, }; }, async list_management_levels(_params: Record, ctx: ToolContext) { - const groups = await ctx.db.managementLevelGroup.findMany({ - select: { - id: true, name: true, targetPercentage: true, - levels: { select: { id: true, name: true }, orderBy: { name: "asc" as const } }, - }, - orderBy: { name: "asc" }, - }); + const caller = createManagementLevelCaller(createScopedCallerContext(ctx)); + const groups = await caller.listGroups(); return groups.map((g) => ({ id: g.id, name: g.name, @@ -6851,232 +5803,428 @@ const executors = { }, async list_utilization_categories(_params: Record, ctx: ToolContext) { - const cats = await ctx.db.utilizationCategory.findMany({ - select: { - id: true, code: true, name: true, description: true, - _count: { select: { projects: true } }, - }, - orderBy: { code: "asc" }, - }); - return cats.map((c) => ({ - id: c.id, - code: c.code, - name: c.name, - description: c.description, - projectCount: c._count.projects, + const caller = createUtilizationCategoryCaller(createScopedCallerContext(ctx)); + const categories = await caller.list(); + const categoriesWithCounts = await Promise.all( + categories.map(async (category) => { + const detailed = await caller.getById({ id: category.id }); + return { + category, + projectCount: detailed._count.projects, + }; + }), + ); + return categoriesWithCounts.map(({ category, projectCount }) => ({ + id: category.id, + code: category.code, + name: category.name, + description: category.description, + projectCount, })); }, async list_calculation_rules(_params: Record, ctx: ToolContext) { - const rules = await ctx.db.calculationRule.findMany({ - select: { - id: true, name: true, description: true, isActive: true, - triggerType: true, orderType: true, - costEffect: true, costReductionPercent: true, - chargeabilityEffect: true, priority: true, - }, - orderBy: { priority: "asc" }, - }); - return rules; + const caller = createCalculationRuleCaller(createScopedCallerContext(ctx)); + const rules = await caller.list(); + return rules.map((rule) => ({ + id: rule.id, + name: rule.name, + description: rule.description, + isActive: rule.isActive, + triggerType: rule.triggerType, + orderType: rule.orderType, + costEffect: rule.costEffect, + costReductionPercent: rule.costReductionPercent, + chargeabilityEffect: rule.chargeabilityEffect, + priority: rule.priority, + project: rule.project + ? { + id: rule.project.id, + name: rule.project.name, + shortCode: rule.project.shortCode, + } + : null, + })); }, async list_effort_rules(_params: Record, ctx: ToolContext) { - const rules = await ctx.db.effortRule.findMany({ - select: { - id: true, description: true, scopeType: true, - discipline: true, chapter: true, unitMode: true, - hoursPerUnit: true, sortOrder: true, - ruleSet: { select: { name: true, isDefault: true } }, + const caller = createEffortRuleCaller(createScopedCallerContext(ctx)); + const ruleSets = await caller.list(); + return ruleSets.flatMap((ruleSet) => ruleSet.rules.map((rule) => ({ + id: rule.id, + description: rule.description, + scopeType: rule.scopeType, + discipline: rule.discipline, + chapter: rule.chapter, + unitMode: rule.unitMode, + hoursPerUnit: rule.hoursPerUnit, + sortOrder: rule.sortOrder, + ruleSet: { + name: ruleSet.name, + isDefault: ruleSet.isDefault, }, - orderBy: { sortOrder: "asc" }, - }); - return rules; + }))); }, async list_experience_multipliers(_params: Record, ctx: ToolContext) { - const multipliers = await ctx.db.experienceMultiplierRule.findMany({ - select: { - id: true, description: true, chapter: true, location: true, - level: true, costMultiplier: true, billMultiplier: true, - shoringRatio: true, additionalEffortRatio: true, sortOrder: true, - multiplierSet: { select: { name: true, isDefault: true } }, + const caller = createExperienceMultiplierCaller(createScopedCallerContext(ctx)); + const multiplierSets = await caller.list(); + return multiplierSets.flatMap((multiplierSet) => multiplierSet.rules.map((rule) => ({ + id: rule.id, + description: rule.description, + chapter: rule.chapter, + location: rule.location, + level: rule.level, + costMultiplier: rule.costMultiplier, + billMultiplier: rule.billMultiplier, + shoringRatio: rule.shoringRatio, + additionalEffortRatio: rule.additionalEffortRatio, + sortOrder: rule.sortOrder, + multiplierSet: { + name: multiplierSet.name, + isDefault: multiplierSet.isDefault, }, - orderBy: { sortOrder: "asc" }, - }); - return multipliers; + }))); }, async list_users(params: { limit?: number }, ctx: ToolContext) { - assertPermission(ctx, PermissionKey.MANAGE_USERS); - const limit = Math.min(params.limit ?? 50, 100); - const users = await ctx.db.user.findMany({ - select: { - id: true, name: true, email: true, systemRole: true, - resource: { select: { displayName: true, eid: true } }, - }, - take: limit, - orderBy: { name: "asc" }, + const caller = createUserCaller(createScopedCallerContext(ctx)); + const users = await caller.list(); + return users.slice(0, Math.min(params.limit ?? 50, 100)); + }, + + async list_assignable_users(_params: Record, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + return caller.listAssignable(); + }, + + async get_current_user(_params: Record, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + return caller.me(); + }, + + async get_dashboard_layout(_params: Record, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + return caller.getDashboardLayout(); + }, + + async save_dashboard_layout(params: { layout: unknown[] }, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + const result = await caller.saveDashboardLayout({ layout: params.layout }); + return { + __action: "invalidate", + scope: ["dashboard"], + success: true, + ...result, + message: "Saved dashboard layout.", + }; + }, + + async get_favorite_project_ids(_params: Record, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + return caller.getFavoriteProjectIds(); + }, + + async toggle_favorite_project(params: { projectId: string }, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + const result = await caller.toggleFavoriteProject({ projectId: params.projectId }); + return { + __action: "invalidate", + scope: ["project"], + success: true, + ...result, + message: result.added ? "Added project to favorites." : "Removed project from favorites.", + }; + }, + + async get_column_preferences(_params: Record, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + return caller.getColumnPreferences(); + }, + + async set_column_preferences(params: { + view: "resources" | "projects" | "allocations" | "vacations" | "roles" | "users" | "blueprints"; + visible?: string[]; + sort?: { field: string; dir: "asc" | "desc" } | null; + rowOrder?: string[] | null; + }, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + const result = await caller.setColumnPreferences({ + view: params.view, + ...(params.visible !== undefined ? { visible: params.visible } : {}), + ...(params.sort !== undefined ? { sort: params.sort } : {}), + ...(params.rowOrder !== undefined ? { rowOrder: params.rowOrder } : {}), }); - return users.map((u) => ({ - id: u.id, - name: u.name, - email: u.email, - role: u.systemRole, - linkedResource: u.resource?.displayName ?? null, - linkedEid: u.resource?.eid ?? null, - })); + return { + __action: "invalidate", + scope: ["user"], + success: true, + ...result, + message: `Updated column preferences for ${params.view}.`, + }; + }, + + async generate_totp_secret(_params: Record, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + const result = await caller.generateTotpSecret(); + return { + __action: "invalidate", + scope: ["user"], + success: true, + ...result, + message: "Generated a new MFA TOTP secret.", + }; + }, + + async verify_and_enable_totp(params: { token: string }, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + const result = await caller.verifyAndEnableTotp({ token: params.token }); + return { + __action: "invalidate", + scope: ["user"], + success: true, + ...result, + message: "Enabled MFA TOTP.", + }; + }, + + async get_mfa_status(_params: Record, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + return caller.getMfaStatus(); + }, + + async get_active_user_count(_params: Record, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + return caller.activeCount(); + }, + + async create_user(params: { + email: string; + name: string; + systemRole?: SystemRole; + password: string; + }, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + const user = await caller.create({ + email: params.email, + name: params.name, + password: params.password, + ...(params.systemRole !== undefined ? { systemRole: params.systemRole } : {}), + }); + return { + __action: "invalidate", + scope: ["user", "resource"], + success: true, + user, + userId: user.id, + message: `Created user ${user.name}.`, + }; + }, + + async set_user_password(params: { userId: string; password: string }, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + const result = await caller.setPassword(params); + return { + __action: "invalidate", + scope: ["user"], + ...result, + message: `Reset password for user ${params.userId}.`, + }; + }, + + async update_user_role(params: { id: string; systemRole: SystemRole }, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + const user = await caller.updateRole(params); + return { + __action: "invalidate", + scope: ["user"], + success: true, + user, + userId: user.id, + message: `Updated role for ${user.name} to ${user.systemRole}.`, + }; + }, + + async update_user_name(params: { id: string; name: string }, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + const user = await caller.updateName(params); + return { + __action: "invalidate", + scope: ["user"], + success: true, + user, + userId: user.id, + message: `Updated user name to ${user.name}.`, + }; + }, + + async link_user_resource(params: { userId: string; resourceId?: string | null }, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + const result = await caller.linkResource({ + userId: params.userId, + resourceId: params.resourceId ?? null, + }); + return { + __action: "invalidate", + scope: ["user", "resource"], + ...result, + message: params.resourceId ? "Linked user to resource." : "Unlinked user resource.", + }; + }, + + async auto_link_users_by_email(_params: Record, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + const result = await caller.autoLinkAllByEmail(); + return { + __action: "invalidate", + scope: ["user", "resource"], + success: true, + ...result, + message: `Auto-linked ${result.linked} user(s) by email.`, + }; + }, + + async set_user_permissions(params: { + userId: string; + overrides?: { + granted?: string[]; + denied?: string[]; + chapterIds?: string[]; + } | null; + }, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + const user = await caller.setPermissions({ + userId: params.userId, + overrides: params.overrides ?? null, + }); + return { + __action: "invalidate", + scope: ["user"], + success: true, + user, + userId: user.id, + message: params.overrides ? "Updated user permission overrides." : "Cleared user permission overrides.", + }; + }, + + async reset_user_permissions(params: { userId: string }, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + const user = await caller.resetPermissions(params); + return { + __action: "invalidate", + scope: ["user"], + success: true, + user, + userId: user.id, + message: "Reset user permissions to role defaults.", + }; + }, + + async get_effective_user_permissions(params: { userId: string }, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + return caller.getEffectivePermissions(params); + }, + + async disable_user_totp(params: { userId: string }, ctx: ToolContext) { + const caller = createUserCaller(createScopedCallerContext(ctx)); + const result = await caller.disableTotp(params); + return { + __action: "invalidate", + scope: ["user"], + success: true, + ...result, + message: `Disabled TOTP for user ${params.userId}.`, + }; }, async list_notifications(params: { unreadOnly?: boolean; limit?: number }, ctx: ToolContext) { - const limit = Math.min(params.limit ?? 20, 50); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const where: Record = { userId: ctx.userId }; - if (params.unreadOnly) where.readAt = null; - const notifications = await ctx.db.notification.findMany({ - where, - select: { - id: true, type: true, title: true, body: true, readAt: true, - createdAt: true, - }, - take: limit, - orderBy: { createdAt: "desc" }, + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + return caller.list({ + ...(params.unreadOnly !== undefined ? { unreadOnly: params.unreadOnly } : {}), + ...(params.limit !== undefined ? { limit: Math.min(params.limit, 100) } : {}), }); - return notifications.map((n) => ({ - id: n.id, - type: n.type, - title: n.title, - message: n.body, - read: n.readAt !== null, - created: fmtDate(n.createdAt), - })); }, - async mark_notification_read(params: { notificationId: string }, ctx: ToolContext) { - const notification = await ctx.db.notification.findUnique({ - where: { id: params.notificationId }, - select: { id: true, userId: true }, + async mark_notification_read(params: { notificationId?: string }, ctx: ToolContext) { + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + await caller.markRead({ + ...(params.notificationId !== undefined ? { id: params.notificationId } : {}), }); - if (!notification || notification.userId !== ctx.userId) { - return { error: "Access denied: this notification does not belong to you" }; - } - await ctx.db.notification.update({ - where: { id: params.notificationId }, - data: { readAt: new Date() }, + return { + __action: "invalidate", + scope: ["notification"], + success: true, + message: params.notificationId ? "Notification marked as read." : "All unread notifications marked as read.", + }; + }, + + async get_unread_notification_count(_params: Record, ctx: ToolContext) { + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + const count = await caller.unreadCount(); + return { count }; + }, + + async create_notification(params: { + userId: string; + type: string; + title: string; + body?: string; + entityId?: string; + entityType?: string; + category?: "NOTIFICATION" | "REMINDER" | "TASK" | "APPROVAL"; + priority?: "LOW" | "NORMAL" | "HIGH" | "URGENT"; + link?: string; + taskStatus?: "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED"; + taskAction?: string; + assigneeId?: string; + dueDate?: string; + channel?: "in_app" | "email" | "both"; + senderId?: string; + }, ctx: ToolContext) { + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + const notification = await caller.create({ + userId: params.userId, + type: params.type, + title: params.title, + ...(params.body !== undefined ? { body: params.body } : {}), + ...(params.entityId !== undefined ? { entityId: params.entityId } : {}), + ...(params.entityType !== undefined ? { entityType: params.entityType } : {}), + ...(params.category !== undefined ? { category: params.category } : {}), + ...(params.priority !== undefined ? { priority: params.priority } : {}), + ...(params.link !== undefined ? { link: params.link } : {}), + ...(params.taskStatus !== undefined ? { taskStatus: params.taskStatus } : {}), + ...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}), + ...(params.assigneeId !== undefined ? { assigneeId: params.assigneeId } : {}), + ...(params.dueDate !== undefined ? { dueDate: new Date(params.dueDate) } : {}), + ...(params.channel !== undefined ? { channel: params.channel } : {}), + ...(params.senderId !== undefined ? { senderId: params.senderId } : {}), }); - return { success: true, message: "Notification marked as read" }; + return { + __action: "invalidate", + scope: ["notification"], + success: true, + notification, + notificationId: notification?.id ?? null, + message: `Created notification "${params.title}".`, + }; }, // ── DASHBOARD DETAIL ── async get_dashboard_detail(params: { section?: string }, ctx: ToolContext) { - const section = params.section ?? "all"; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result: Record = {}; - - if (section === "all" || section === "peak_times") { - const allocations = await ctx.db.assignment.findMany({ - where: { status: { not: "CANCELLED" } }, - select: { startDate: true, endDate: true, hoursPerDay: true }, - }); - - if (allocations.length === 0) { - result.peakTimes = []; - } else { - const rangeStart = new Date(Math.min(...allocations.map((allocation) => allocation.startDate.getTime()))); - const rangeEnd = new Date(Math.max(...allocations.map((allocation) => allocation.endDate.getTime()))); - const peakTimes = await getDashboardPeakTimes(ctx.db, { - startDate: rangeStart, - endDate: rangeEnd, - granularity: "month", - groupBy: "project", - }); - - result.peakTimes = [...peakTimes] - .sort((left, right) => right.totalHours - left.totalHours) - .slice(0, 6) - .map((entry) => ({ - month: entry.period, - totalHours: Math.round(entry.totalHours * 10) / 10, - totalHoursPerDay: Math.round(entry.totalHours * 10) / 10, - capacityHours: Math.round(entry.capacityHours * 10) / 10, - })); - } - } - - if (section === "all" || section === "top_resources") { - const resources = await ctx.db.resource.findMany({ - where: { isActive: true }, - select: { - displayName: true, eid: true, lcrCents: true, - _count: { select: { assignments: true } }, - }, - orderBy: { lcrCents: "desc" }, - take: 10, - }); - result.topResources = resources.map((r) => ({ - name: r.displayName, - eid: r.eid, - lcr: fmtEur(r.lcrCents), - allocations: r._count.assignments, - })); - } - - if (section === "all" || section === "demand_pipeline") { - const demands = await ctx.db.demandRequirement.findMany({ - where: { status: { in: ["PROPOSED", "CONFIRMED"] } }, - select: { - headcount: true, - role: true, - roleEntity: { select: { name: true } }, - project: { select: { name: true, shortCode: true } }, - _count: { select: { assignments: true } }, - }, - take: 15, - orderBy: { startDate: "asc" }, - }); - result.demandPipeline = demands.map((d) => ({ - project: `${d.project.name} (${d.project.shortCode})`, - role: d.roleEntity?.name ?? d.role ?? "Unspecified", - needed: d.headcount - d._count.assignments, - })); - } - - if (section === "all" || section === "chargeability_overview") { - const chapters = await ctx.db.resource.groupBy({ - by: ["chapter"], - where: { isActive: true }, - _count: true, - _avg: { chargeabilityTarget: true }, - }); - result.chargeabilityByChapter = chapters.map((c) => ({ - chapter: c.chapter ?? "Unassigned", - headcount: c._count, - avgTarget: c._avg.chargeabilityTarget ? `${Math.round(c._avg.chargeabilityTarget)}%` : null, - })); - } - - return result; + const caller = createDashboardCaller(createScopedCallerContext(ctx)); + return caller.getDetail({ ...(params.section ? { section: params.section } : {}) }); }, // ── PROJECT MANAGEMENT ── async delete_project(params: { projectId: string }, ctx: ToolContext) { assertPermission(ctx, "manageProjects" as PermissionKey); + const project = await resolveProjectIdentifier(ctx, params.projectId); + if ("error" in project) return project; - let project = await ctx.db.project.findUnique({ - where: { id: params.projectId }, - select: { id: true, name: true, shortCode: true, status: true }, - }); - if (!project) { - project = await ctx.db.project.findUnique({ - where: { shortCode: params.projectId }, - select: { id: true, name: true, shortCode: true, status: true }, - }); - } - if (!project) return { error: `Project not found: ${params.projectId}` }; - if (project.status !== "DRAFT") return { error: `Only DRAFT projects can be deleted. This project is ${project.status}.` }; - - await ctx.db.project.delete({ where: { id: project.id } }); + const caller = createProjectCaller(createScopedCallerContext(ctx)); + await caller.delete({ id: project.id }); return { __action: "invalidate", scope: ["project"], @@ -7087,339 +6235,124 @@ const executors = { // ── ORG UNIT MANAGEMENT ── - async create_org_unit(params: { name: string; shortName?: string; level: number; parentId?: string }, ctx: ToolContext) { - assertPermission(ctx, "manageResources" as PermissionKey); - const ou = await ctx.db.orgUnit.create({ - data: { - name: params.name, - ...(params.shortName ? { shortName: params.shortName } : {}), - level: params.level, - ...(params.parentId ? { parentId: params.parentId } : {}), - isActive: true, - }, - select: { id: true, name: true }, - }); - return { __action: "invalidate", scope: ["orgUnit"], success: true, message: `Created org unit: ${ou.name}`, orgUnitId: ou.id }; + async create_org_unit(params: { + name: string; + shortName?: string; + level: number; + parentId?: string; + sortOrder?: number; + }, ctx: ToolContext) { + const caller = createOrgUnitCaller(createScopedCallerContext(ctx)); + const ou = await caller.create(CreateOrgUnitSchema.parse(params)); + return { __action: "invalidate", scope: ["orgUnit"], success: true, message: `Created org unit: ${ou.name}`, orgUnitId: ou.id, orgUnit: ou }; }, - async update_org_unit(params: { id: string; name?: string; shortName?: string }, ctx: ToolContext) { - assertPermission(ctx, "manageResources" as PermissionKey); - const data: Record = {}; - if (params.name !== undefined) data.name = params.name; - if (params.shortName !== undefined) data.shortName = params.shortName; + async update_org_unit(params: { + id: string; + name?: string; + shortName?: string | null; + sortOrder?: number; + isActive?: boolean; + parentId?: string | null; + }, ctx: ToolContext) { + const caller = createOrgUnitCaller(createScopedCallerContext(ctx)); + const data = UpdateOrgUnitSchema.parse({ + ...(params.name !== undefined ? { name: params.name } : {}), + ...(params.shortName !== undefined ? { shortName: params.shortName } : {}), + ...(params.sortOrder !== undefined ? { sortOrder: params.sortOrder } : {}), + ...(params.isActive !== undefined ? { isActive: params.isActive } : {}), + ...(params.parentId !== undefined ? { parentId: params.parentId } : {}), + }); if (Object.keys(data).length === 0) return { error: "No fields to update" }; - const ou = await ctx.db.orgUnit.update({ where: { id: params.id }, data, select: { id: true, name: true } }); - return { __action: "invalidate", scope: ["orgUnit"], success: true, message: `Updated org unit: ${ou.name}` }; + const ou = await caller.update({ id: params.id, data }); + return { __action: "invalidate", scope: ["orgUnit"], success: true, message: `Updated org unit: ${ou.name}`, orgUnitId: ou.id, orgUnit: ou }; }, // ─── Cover Art ─────────────────────────────────────────────────────────── async generate_project_cover(params: { projectId: string; prompt?: string }, ctx: ToolContext) { assertPermission(ctx, "manageProjects" as PermissionKey); - - const project = await ctx.db.project.findUnique({ - where: { id: params.projectId }, - include: { client: { select: { name: true } } }, + const caller = createProjectCaller(createScopedCallerContext(ctx)); + const project = await resolveProjectIdentifier(ctx, params.projectId); + if ("error" in project) { + return project; + } + const { coverImageUrl } = await caller.generateCover({ + projectId: project.id, + ...(params.prompt !== undefined ? { prompt: params.prompt } : {}), }); - if (!project) return { error: `Project not found: ${params.projectId}` }; - - const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); - const imageProvider = settings?.imageProvider ?? "dalle"; - const { isGeminiConfigured: isGeminiOk } = await import("../gemini-client.js"); - const useGemini = imageProvider === "gemini" && isGeminiOk(settings); - const useDalle = imageProvider === "dalle" && isDalleConfigured(settings); - - if (!useGemini && !useDalle) { - return { error: "No image provider configured. Set up DALL-E or Gemini in Admin → Settings." }; - } - - const clientName = project.client?.name ? ` for ${project.client.name}` : ""; - const basePrompt = `Professional cover art for a 3D automotive visualization project: "${project.name}"${clientName}. Style: cinematic, modern, photorealistic CGI rendering, dramatic lighting, studio environment. No text or typography in the image.`; - const finalPrompt = params.prompt ? `${basePrompt} Additional direction: ${params.prompt}` : basePrompt; - - let coverImageUrl: string; - - if (useGemini) { - try { - const { generateGeminiImage, parseGeminiError } = await import("../gemini-client.js"); - coverImageUrl = await generateGeminiImage( - settings!.geminiApiKey!, - finalPrompt, - settings!.geminiModel ?? undefined, - ); - } catch (err) { - const { parseGeminiError: parseErr } = await import("../gemini-client.js"); - return { error: `Gemini error: ${parseErr(err)}` }; - } - } else { - const dalleClient = createDalleClient(settings!); - const model = settings!.aiProvider === "azure" ? settings!.azureDalleDeployment! : "dall-e-3"; - - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const response: any = await dalleClient.images.generate({ - model, - prompt: finalPrompt, - size: "1024x1024", - n: 1, - response_format: "b64_json", - }); - - const b64 = response.data?.[0]?.b64_json; - if (!b64) return { error: "No image data returned from DALL-E" }; - coverImageUrl = `data:image/png;base64,${b64}`; - } catch (err) { - return { error: `DALL-E error: ${parseAiError(err)}` }; - } - } - - await ctx.db.project.update({ where: { id: params.projectId }, data: { coverImageUrl } }); return { __action: "invalidate", scope: ["project"], success: true, - message: `Generated cover art for project "${project.name}" using ${useGemini ? "Gemini" : "DALL-E"}`, + message: `Generated cover art for project "${project.name}"`, coverImageUrl: coverImageUrl.slice(0, 100) + "...[truncated]", }; }, async remove_project_cover(params: { projectId: string }, ctx: ToolContext) { assertPermission(ctx, "manageProjects" as PermissionKey); - - const project = await ctx.db.project.findUnique({ - where: { id: params.projectId }, - select: { id: true, name: true }, - }); - if (!project) return { error: `Project not found: ${params.projectId}` }; - - await ctx.db.project.update({ where: { id: params.projectId }, data: { coverImageUrl: null } }); + const caller = createProjectCaller(createScopedCallerContext(ctx)); + const project = await resolveProjectIdentifier(ctx, params.projectId); + if ("error" in project) { + return project; + } + await caller.removeCover({ projectId: project.id }); return { __action: "invalidate", scope: ["project"], success: true, message: `Removed cover art from project "${project.name}"` }; }, // ── TASK MANAGEMENT ── - async list_tasks(params: { status?: string; limit?: number }, ctx: ToolContext) { - const limit = Math.min(params.limit ?? 10, 50); - const status = params.status ?? "OPEN"; - const tasks = await ctx.db.notification.findMany({ - where: { - OR: [ - { userId: ctx.userId }, - { assigneeId: ctx.userId }, - ], - category: { in: ["TASK", "APPROVAL"] }, - taskStatus: status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED", - }, - select: { - id: true, title: true, body: true, priority: true, - taskStatus: true, taskAction: true, dueDate: true, - entityId: true, entityType: true, createdAt: true, - }, - take: limit, - orderBy: [{ priority: "desc" }, { createdAt: "desc" }], + async list_tasks(params: { + status?: "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED"; + includeAssigned?: boolean; + limit?: number; + }, ctx: ToolContext) { + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + return caller.listTasks({ + ...(params.status !== undefined ? { status: params.status } : {}), + ...(params.includeAssigned !== undefined ? { includeAssigned: params.includeAssigned } : {}), + ...(params.limit !== undefined ? { limit: Math.min(params.limit, 100) } : {}), }); - return tasks.map((t) => ({ - id: t.id, - title: t.title, - body: t.body, - priority: t.priority, - taskStatus: t.taskStatus, - taskAction: t.taskAction, - dueDate: fmtDate(t.dueDate), - entityId: t.entityId, - entityType: t.entityType, - createdAt: fmtDate(t.createdAt), - })); + }, + + async get_task_counts(_params: Record, ctx: ToolContext) { + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + return caller.taskCounts(); }, async get_task_detail(params: { taskId: string }, ctx: ToolContext) { - const task = await ctx.db.notification.findUnique({ - where: { id: params.taskId }, - 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: { name: true } }, - }, - }); - if (!task) return { error: `Task not found: ${params.taskId}` }; - - // Verify the user has access to this task - if (task.userId !== ctx.userId && task.assigneeId !== ctx.userId) { - return { error: "Access denied: this task does not belong to you" }; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result: Record = { - id: task.id, - title: task.title, - body: task.body, - type: task.type, - priority: task.priority, - category: task.category, - taskStatus: task.taskStatus, - taskAction: task.taskAction, - dueDate: fmtDate(task.dueDate), - entityId: task.entityId, - entityType: task.entityType, - completedAt: fmtDate(task.completedAt), - completedBy: task.completedBy, - createdAt: fmtDate(task.createdAt), - senderName: task.sender?.name ?? null, - }; - - // Enrich with linked entity details - if (task.entityId && task.entityType) { - try { - if (task.entityType === "project") { - const project = await ctx.db.project.findUnique({ - where: { id: task.entityId }, - select: { id: true, name: true, shortCode: true, status: true }, - }); - if (project) result.linkedEntity = project; - } else if (task.entityType === "vacation") { - const vacation = await ctx.db.vacation.findUnique({ - where: { id: task.entityId }, - select: { - id: true, type: true, status: true, startDate: true, endDate: true, - resource: { select: { displayName: true } }, - }, - }); - if (vacation) { - result.linkedEntity = { - id: vacation.id, - type: vacation.type, - status: vacation.status, - startDate: fmtDate(vacation.startDate), - endDate: fmtDate(vacation.endDate), - resourceName: vacation.resource.displayName, - }; - } - } else if (task.entityType === "assignment" || task.entityType === "allocation") { - const assignment = await ctx.db.assignment.findUnique({ - where: { id: task.entityId }, - select: { - id: true, status: true, startDate: true, endDate: true, - resource: { select: { displayName: true } }, - project: { select: { name: true } }, - }, - }); - if (assignment) { - result.linkedEntity = { - id: assignment.id, - status: assignment.status, - startDate: fmtDate(assignment.startDate), - endDate: fmtDate(assignment.endDate), - resourceName: assignment.resource.displayName, - projectName: assignment.project.name, - }; - } - } - } catch { - // Entity may have been deleted — ignore - } - } - - return result; + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + return caller.getTaskDetail({ id: params.taskId }); }, async update_task_status(params: { taskId: string; status: string }, ctx: ToolContext) { - const task = await ctx.db.notification.findUnique({ - where: { id: params.taskId }, - select: { id: true, userId: true, assigneeId: true, taskStatus: true }, + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + const task = await caller.updateTaskStatus({ + id: params.taskId, + status: params.status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED", }); - if (!task) return { error: `Task not found: ${params.taskId}` }; - if (task.userId !== ctx.userId && task.assigneeId !== ctx.userId) { - return { error: "Access denied: this task does not belong to you" }; - } - - const newStatus = params.status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED"; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const data: Record = { taskStatus: newStatus }; - if (newStatus === "DONE") { - data.completedAt = new Date(); - data.completedBy = "ai-assistant"; - } - - await ctx.db.notification.update({ - where: { id: params.taskId }, - data, - }); - - emitTaskStatusChanged(task.userId, task.id); - if (newStatus === "DONE") { - emitTaskCompleted(task.userId, task.id); - } - - return { __action: "invalidate", scope: ["notification"], success: true, message: `Task status updated to ${newStatus}` }; + return { + __action: "invalidate", + scope: ["notification"], + success: true, + task, + message: `Task status updated to ${task.taskStatus ?? params.status}.`, + }; }, async execute_task_action(params: { taskId: string }, ctx: ToolContext) { - // 1. Fetch the notification - const task = await ctx.db.notification.findUnique({ - where: { id: params.taskId }, - select: { - id: true, userId: true, assigneeId: true, - taskAction: true, taskStatus: true, - }, - }); - if (!task) return { error: `Task not found: ${params.taskId}` }; - if (task.userId !== ctx.userId && task.assigneeId !== ctx.userId) { - return { error: "Access denied: this task does not belong to you" }; - } - if (!task.taskAction) { - return { error: "This task has no executable action" }; - } - if (task.taskStatus === "DONE") { - return { error: "This task is already completed" }; - } - - // 2. Parse taskAction - const parsed = parseTaskAction(task.taskAction); - if (!parsed) { - return { error: `Invalid taskAction format: ${task.taskAction}` }; - } - - // 3. Look up handler in TASK_ACTION_REGISTRY - const handler = getTaskAction(parsed.action); - if (!handler) { - return { error: `Unknown action: ${parsed.action}` }; - } - - // 4. Check permission - if (handler.permission && !ctx.permissions.has(handler.permission as PermissionKey)) { - return { error: `Permission denied: you need "${handler.permission}" to perform this action` }; - } - - // 5. Execute the action - const actionResult = await handler.execute(parsed.entityId, ctx.db, ctx.userId); - if (!actionResult.success) { - return { error: actionResult.message }; - } - - // 6. Mark the task as DONE - await ctx.db.notification.update({ - where: { id: params.taskId }, - data: { - taskStatus: "DONE", - completedAt: new Date(), - completedBy: "ai-assistant", - }, - }); - - emitTaskCompleted(task.userId, task.id); + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + const result = await caller.executeTaskAction({ id: params.taskId }); return { __action: "invalidate", scope: ["notification"], success: true, - message: actionResult.message, - action: parsed.action, - entityId: parsed.entityId, + task: result.task, + message: result.actionResult.message, }; }, @@ -7430,36 +6363,69 @@ const executors = { recurrence?: string; entityId?: string; entityType?: string; + link?: string; }, ctx: ToolContext) { - const remindAt = new Date(params.remindAt); - if (isNaN(remindAt.getTime())) { - return { error: "Invalid remindAt date format. Use ISO 8601 (e.g. 2026-03-20T09:00:00Z)" }; - } - - const notification = await ctx.db.notification.create({ - data: { - userId: ctx.userId, - type: "REMINDER", - title: params.title, - category: "REMINDER", - remindAt, - nextRemindAt: remindAt, - ...(params.body !== undefined ? { body: params.body } : {}), - ...(params.recurrence !== undefined ? { recurrence: params.recurrence } : {}), - ...(params.entityId !== undefined ? { entityId: params.entityId } : {}), - ...(params.entityType !== undefined ? { entityType: params.entityType } : {}), - }, + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + const reminder = await caller.createReminder({ + title: params.title, + remindAt: new Date(params.remindAt), + ...(params.body !== undefined ? { body: params.body } : {}), + ...(params.recurrence !== undefined ? { recurrence: params.recurrence as "daily" | "weekly" | "monthly" } : {}), + ...(params.entityId !== undefined ? { entityId: params.entityId } : {}), + ...(params.entityType !== undefined ? { entityType: params.entityType } : {}), + ...(params.link !== undefined ? { link: params.link } : {}), }); - - emitNotificationCreated(ctx.userId, notification.id); - return { __action: "invalidate", scope: ["notification"], success: true, - message: `Reminder "${params.title}" created for ${fmtDate(remindAt)}`, - reminderId: notification.id, + reminder, + reminderId: reminder.id, + message: `Reminder "${params.title}" created.`, + }; + }, + + async list_reminders(params: { limit?: number }, ctx: ToolContext) { + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + return caller.listReminders({ + ...(params.limit !== undefined ? { limit: Math.min(params.limit, 100) } : {}), + }); + }, + + async update_reminder(params: { + id: string; + title?: string; + body?: string; + remindAt?: string; + recurrence?: "daily" | "weekly" | "monthly" | null; + }, ctx: ToolContext) { + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + const reminder = await caller.updateReminder({ + id: params.id, + ...(params.title !== undefined ? { title: params.title } : {}), + ...(params.body !== undefined ? { body: params.body } : {}), + ...(params.remindAt !== undefined ? { remindAt: new Date(params.remindAt) } : {}), ...(params.recurrence !== undefined ? { recurrence: params.recurrence } : {}), + }); + return { + __action: "invalidate", + scope: ["notification"], + success: true, + reminder, + reminderId: reminder.id, + message: `Updated reminder ${params.id}.`, + }; + }, + + async delete_reminder(params: { id: string }, ctx: ToolContext) { + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + await caller.deleteReminder({ id: params.id }); + return { + __action: "invalidate", + scope: ["notification"], + success: true, + id: params.id, + message: `Deleted reminder ${params.id}.`, }; }, @@ -7472,41 +6438,42 @@ const executors = { taskAction?: string; entityId?: string; entityType?: string; + link?: string; + channel?: "in_app" | "email" | "both"; }, ctx: ToolContext) { - assertPermission(ctx, "manageProjects" as PermissionKey); - - // Verify target user exists - const targetUser = await ctx.db.user.findUnique({ - where: { id: params.userId }, - select: { id: true, name: true }, + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + const task = await caller.createTask({ + userId: params.userId, + title: params.title, + ...(params.body !== undefined ? { body: params.body } : {}), + ...(params.priority !== undefined ? { priority: params.priority as "LOW" | "NORMAL" | "HIGH" | "URGENT" } : {}), + ...(params.dueDate !== undefined ? { dueDate: new Date(params.dueDate) } : {}), + ...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}), + ...(params.entityId !== undefined ? { entityId: params.entityId } : {}), + ...(params.entityType !== undefined ? { entityType: params.entityType } : {}), + ...(params.link !== undefined ? { link: params.link } : {}), + ...(params.channel !== undefined ? { channel: params.channel } : {}), }); - if (!targetUser) return { error: `User not found: ${params.userId}` }; - - const notification = await ctx.db.notification.create({ - data: { - userId: params.userId, - type: "TASK_ASSIGNED", - title: params.title, - category: "TASK", - taskStatus: "OPEN", - senderId: ctx.userId, - priority: (params.priority ?? "NORMAL") as "LOW" | "NORMAL" | "HIGH" | "URGENT", - ...(params.body !== undefined ? { body: params.body } : {}), - ...(params.dueDate !== undefined ? { dueDate: new Date(params.dueDate) } : {}), - ...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}), - ...(params.entityId !== undefined ? { entityId: params.entityId } : {}), - ...(params.entityType !== undefined ? { entityType: params.entityType } : {}), - }, - }); - - emitTaskAssigned(params.userId, notification.id); - return { __action: "invalidate", scope: ["notification"], success: true, - message: `Task "${params.title}" created for ${targetUser.name ?? params.userId}`, - taskId: notification.id, + task, + taskId: task?.id ?? null, + message: `Created task "${params.title}" for ${params.userId}.`, + }; + }, + + async assign_task(params: { id: string; assigneeId: string }, ctx: ToolContext) { + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + const task = await caller.assignTask(params); + return { + __action: "invalidate", + scope: ["notification"], + success: true, + task, + taskId: task.id, + message: `Assigned task ${params.id} to ${params.assigneeId}.`, }; }, @@ -7515,705 +6482,134 @@ const executors = { body?: string; targetType: string; targetValue?: string; + category?: "NOTIFICATION" | "REMINDER" | "TASK" | "APPROVAL"; priority?: string; channel?: string; link?: string; + scheduledAt?: string; + taskAction?: string; + dueDate?: string; }, ctx: ToolContext) { - assertPermission(ctx, "manageProjects" as PermissionKey); - - // Resolve recipients - const recipientIds = await resolveRecipients( - params.targetType, - params.targetValue, - ctx.db, - ctx.userId, // exclude sender - ); - - if (recipientIds.length === 0) { - return { error: "No recipients found for the given target" }; - } - - const priority = (params.priority ?? "NORMAL") as "LOW" | "NORMAL" | "HIGH" | "URGENT"; - const channel = params.channel ?? "in_app"; - - // Create broadcast record - const broadcast = await ctx.db.notificationBroadcast.create({ - data: { - senderId: ctx.userId, - title: params.title, - targetType: params.targetType, - priority, - channel, - recipientCount: recipientIds.length, - sentAt: new Date(), - ...(params.body !== undefined ? { body: params.body } : {}), - ...(params.targetValue !== undefined ? { targetValue: params.targetValue } : {}), - ...(params.link !== undefined ? { link: params.link } : {}), - }, + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + const broadcast = await caller.createBroadcast({ + title: params.title, + targetType: params.targetType as "user" | "role" | "project" | "orgUnit" | "all", + ...(params.body !== undefined ? { body: params.body } : {}), + ...(params.link !== undefined ? { link: params.link } : {}), + ...(params.category !== undefined ? { category: params.category } : {}), + ...(params.priority !== undefined ? { priority: params.priority as "LOW" | "NORMAL" | "HIGH" | "URGENT" } : {}), + ...(params.channel !== undefined ? { channel: params.channel as "in_app" | "email" | "both" } : {}), + ...(params.targetValue !== undefined ? { targetValue: params.targetValue } : {}), + ...(params.scheduledAt !== undefined ? { scheduledAt: new Date(params.scheduledAt) } : {}), + ...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}), + ...(params.dueDate !== undefined ? { dueDate: new Date(params.dueDate) } : {}), }); - // Create individual notifications for each recipient - await ctx.db.notification.createMany({ - data: recipientIds.map((userId) => ({ - userId, - type: "BROADCAST", - title: params.title, - category: "NOTIFICATION" as const, - priority, - channel, - senderId: ctx.userId, - sourceId: broadcast.id, - ...(params.body !== undefined ? { body: params.body } : {}), - ...(params.link !== undefined ? { link: params.link } : {}), - })), - }); - - // Emit SSE events for each recipient - for (const userId of recipientIds) { - emitNotificationCreated(userId, broadcast.id); - } - emitBroadcastSent(broadcast.id, recipientIds.length); - return { __action: "invalidate", scope: ["notification"], success: true, - message: `Broadcast "${params.title}" sent to ${recipientIds.length} recipients`, + broadcast, broadcastId: broadcast.id, - recipientCount: recipientIds.length, + recipientCount: broadcast.recipientCount ?? 0, + message: `Broadcast "${params.title}" created.`, + }; + }, + + async list_broadcasts(params: { limit?: number }, ctx: ToolContext) { + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + return caller.listBroadcasts({ + ...(params.limit !== undefined ? { limit: Math.min(params.limit, 50) } : {}), + }); + }, + + async get_broadcast_detail(params: { id: string }, ctx: ToolContext) { + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + return caller.getBroadcastById({ id: params.id }); + }, + + async delete_notification(params: { id: string }, ctx: ToolContext) { + const caller = createNotificationCaller(createScopedCallerContext(ctx)); + await caller.delete({ id: params.id }); + return { + __action: "invalidate", + scope: ["notification"], + success: true, + id: params.id, + message: `Deleted notification ${params.id}.`, }; }, // ── INSIGHTS & ANOMALIES ────────────────────────────────────────────────── async detect_anomalies(_params: Record, ctx: ToolContext) { - const now = new Date(); - const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000); - const anomalies: Array<{ - type: string; - severity: string; - entityId: string; - entityName: string; - message: string; - }> = []; - - 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, - }, - }, - }, - }); - - function countBizDays(start: Date, end: Date): number { - let count = 0; - const d = new Date(start); - while (d <= end) { - const dow = d.getDay(); - if (dow !== 0 && dow !== 6) count++; - d.setDate(d.getDate() + 1); - } - return count; - } - - for (const project of projects) { - // Budget anomaly - if (project.budgetCents > 0) { - const totalDays = countBizDays(project.startDate, project.endDate); - const elapsedDays = countBizDays(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 * countBizDays(aStart, aEnd); - }, 0); - const actualBurnRate = totalCostCents / project.budgetCents; - if (actualBurnRate > expectedBurnRate * 1.2) { - const overSpendPct = 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 ${overSpendPct}% faster than expected. ${Math.round(actualBurnRate * 100)}% spent at ${Math.round(expectedBurnRate * 100)}% timeline.`, - }); - } - } - } - - // Staffing anomaly - 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 - const overruns = project.assignments.filter( - (a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"), - ); - if (overruns.length > 0) { - anomalies.push({ - type: "timeline", - severity: "warning", - entityId: project.id, - entityName: project.name, - message: `${overruns.length} assignment(s) extend beyond the project end date (${project.endDate.toISOString().slice(0, 10)}).`, - }); - } - } - - // Utilization anomaly - const resources = await ctx.db.resource.findMany({ - where: { isActive: true }, - select: { id: true, displayName: 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(); - for (const a of activeAssignments) { - resourceHoursMap.set(a.resourceId, (resourceHoursMap.get(a.resourceId) ?? 0) + a.hoursPerDay); - } - for (const resource of resources) { - const avail = resource.availability as Record | 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) { - anomalies.push({ - type: "utilization", - severity: pct > 130 ? "critical" : "warning", - entityId: resource.id, - entityName: resource.displayName, - message: `Resource at ${pct}% utilization (${booked.toFixed(1)}h/${dailyAvail.toFixed(1)}h per day).`, - }); - } else if (pct < 40 && booked > 0) { - anomalies.push({ - type: "utilization", - severity: "warning", - entityId: resource.id, - entityName: resource.displayName, - message: `Resource at only ${pct}% utilization (${booked.toFixed(1)}h/${dailyAvail.toFixed(1)}h per day).`, - }); - } - } - - anomalies.sort((a, b) => { - if (a.severity !== b.severity) return a.severity === "critical" ? -1 : 1; - return a.type.localeCompare(b.type); - }); - - return { anomalies, count: anomalies.length }; + const caller = createInsightsCaller(createScopedCallerContext(ctx)); + return caller.getAnomalyDetail(); }, async get_skill_gaps(_params: Record, ctx: ToolContext) { - const now = new Date(); - - // Get active demand requirements with their roles - const demands = await ctx.db.demandRequirement.findMany({ - where: { - project: { status: { in: ["ACTIVE", "DRAFT"] } }, - status: { not: "CANCELLED" }, - endDate: { gte: now }, - }, - select: { - id: true, - role: true, - headcount: true, - roleEntity: { select: { name: true } }, - _count: { select: { assignments: true } }, - }, - }); - - // Aggregate demand by role - const demandByRole = new Map(); - for (const d of demands) { - const roleName = d.roleEntity?.name ?? d.role ?? "Unknown"; - const existing = demandByRole.get(roleName) ?? { needed: 0, filled: 0 }; - existing.needed += d.headcount; - existing.filled += Math.min(d._count.assignments, d.headcount); - demandByRole.set(roleName, existing); - } - - // Get active resources with skills - const resources = await ctx.db.resource.findMany({ - where: { isActive: true }, - select: { - id: true, - skills: true, - areaRole: { select: { name: true } }, - }, - }); - - // Count skill supply - const skillSupply = new Map(); - for (const r of resources) { - const skills = (r.skills ?? []) as Array<{ skill: string; level?: number }>; - for (const s of skills) { - const key = s.skill.toLowerCase(); - skillSupply.set(key, (skillSupply.get(key) ?? 0) + 1); - } - } - - // Build role gaps - const roleGaps = [...demandByRole.entries()] - .map(([role, { needed, filled }]) => ({ - role, - needed, - filled, - gap: needed - filled, - fillRate: needed > 0 ? Math.round((filled / needed) * 100) : 100, - })) - .filter((g) => g.gap > 0) - .sort((a, b) => b.gap - a.gap); - - // Count supply by role - const supplyByRole = new Map(); - for (const r of resources) { - const roleName = r.areaRole?.name; - if (roleName) { - supplyByRole.set(roleName, (supplyByRole.get(roleName) ?? 0) + 1); - } - } - - return { - roleGaps, - totalOpenPositions: roleGaps.reduce((s, g) => s + g.gap, 0), - skillSupplyTop10: [...skillSupply.entries()] - .sort((a, b) => b[1] - a[1]) - .slice(0, 10) - .map(([skill, count]) => ({ skill, resourceCount: count })), - resourcesByRole: [...supplyByRole.entries()] - .sort((a, b) => b[1] - a[1]) - .map(([role, count]) => ({ role, count })), - }; + const caller = createDashboardCaller(createScopedCallerContext(ctx)); + return caller.getSkillGapSummary(); }, async get_project_health(_params: Record, ctx: ToolContext) { - const now = new Date(); - - function countBizDays(start: Date, end: Date): number { - let count = 0; - const d = new Date(start); - while (d <= end) { - const dow = d.getDay(); - if (dow !== 0 && dow !== 6) count++; - d.setDate(d.getDate() + 1); - } - return count; - } - - const projects = await ctx.db.project.findMany({ - where: { status: { in: ["ACTIVE", "DRAFT"] } }, - include: { - demandRequirements: { - select: { - headcount: true, - _count: { select: { assignments: true } }, - }, - }, - assignments: { - where: { status: { not: "CANCELLED" } }, - select: { - dailyCostCents: true, - startDate: true, - endDate: true, - status: true, - }, - }, - }, - }); - - const healthScores = projects.map((project) => { - // Budget score (0-100) - let budgetScore = 100; - if (project.budgetCents > 0) { - const totalCostCents = project.assignments.reduce((s, a) => { - const days = countBizDays(a.startDate, a.endDate); - return s + a.dailyCostCents * days; - }, 0); - const budgetUsedPct = (totalCostCents / project.budgetCents) * 100; - const totalDays = countBizDays(project.startDate, project.endDate); - const elapsedDays = countBizDays(project.startDate, now < project.endDate ? now : project.endDate); - const timelinePct = totalDays > 0 ? (elapsedDays / totalDays) * 100 : 0; - // Score: penalize if budget burn is significantly ahead of timeline - if (timelinePct > 0 && budgetUsedPct > timelinePct * 1.5) { - budgetScore = Math.max(0, 100 - Math.round(budgetUsedPct - timelinePct)); - } else if (budgetUsedPct > 90) { - budgetScore = Math.max(0, 100 - Math.round((budgetUsedPct - 90) * 5)); - } - } - - // Staffing score (0-100) - const totalDemand = project.demandRequirements.reduce((s, d) => s + d.headcount, 0); - const filledDemand = project.demandRequirements.reduce( - (s, d) => s + Math.min(d._count.assignments, d.headcount), - 0, - ); - const staffingScore = totalDemand > 0 ? Math.round((filledDemand / totalDemand) * 100) : 100; - - // Timeline score (0-100) - const overrunCount = project.assignments.filter( - (a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"), - ).length; - const timelineScore = overrunCount > 0 - ? Math.max(0, 100 - overrunCount * 20) - : 100; - - const overall = Math.round((budgetScore + staffingScore + timelineScore) / 3); - - return { - projectId: project.id, - projectName: project.name, - shortCode: project.shortCode, - status: project.status, - overall, - budget: budgetScore, - staffing: staffingScore, - timeline: timelineScore, - rating: overall >= 80 ? "healthy" : overall >= 50 ? "at_risk" : "critical", - }; - }); - - healthScores.sort((a, b) => a.overall - b.overall); - - return { - projects: healthScores, - summary: { - healthy: healthScores.filter((p) => p.rating === "healthy").length, - atRisk: healthScores.filter((p) => p.rating === "at_risk").length, - critical: healthScores.filter((p) => p.rating === "critical").length, - }, - }; + const caller = createDashboardCaller(createScopedCallerContext(ctx)); + return caller.getProjectHealthDetail(); }, async get_budget_forecast(_params: Record, ctx: ToolContext) { assertPermission(ctx, "viewCosts" as PermissionKey); - const forecasts = await getDashboardBudgetForecast(ctx.db); - - return { - forecasts: forecasts.map((forecast) => ({ - projectName: forecast.projectName, - shortCode: forecast.shortCode, - budget: fmtEur(forecast.budgetCents), - budgetCents: forecast.budgetCents, - spent: fmtEur(forecast.spentCents), - spentCents: forecast.spentCents, - remaining: fmtEur(forecast.budgetCents - forecast.spentCents), - 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, - burnStatus: forecast.pctUsed >= 100 - ? "ahead" - : forecast.burnRate > 0 - ? "on_track" - : "not_started", - })), - }; + const caller = createDashboardCaller(createScopedCallerContext(ctx)); + return caller.getBudgetForecastDetail(); }, async get_insights_summary(_params: Record, ctx: ToolContext) { - const now = new Date(); - const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000); - - function countBizDays(start: Date, end: Date): number { - let count = 0; - const d = new Date(start); - while (d <= end) { - const dow = d.getDay(); - if (dow !== 0 && dow !== 6) count++; - d.setDate(d.getDate() + 1); - } - return count; - } - - 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) { - if (project.budgetCents > 0) { - const totalDays = countBizDays(project.startDate, project.endDate); - const elapsedDays = countBizDays(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 * countBizDays(aStart, aEnd); - }, 0); - const actualBurnRate = totalCostCents / project.budgetCents; - if (actualBurnRate > expectedBurnRate * 1.2) { - budgetCount++; - if (actualBurnRate > expectedBurnRate * 1.5) criticalCount++; - } - } - } - - 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++; - } - } - - const overruns = project.assignments.filter( - (a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"), - ); - if (overruns.length > 0) timelineCount++; - } - - // Utilization - 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(); - 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 | 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 }; + const caller = createInsightsCaller(createScopedCallerContext(ctx)); + return caller.getInsightsSummary(); }, async run_report(params: { entity: string; columns: string[]; - filters?: Array<{ field: string; op: string; value: string }>; + filters?: Array<{ + field: string; + op: "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in"; + value: string; + }>; + periodMonth?: string; limit?: number; }, ctx: ToolContext) { - const entity = params.entity as "resource" | "project" | "assignment"; - if (!["resource", "project", "assignment"].includes(entity)) { - return { error: `Unknown entity: ${params.entity}. Use resource, project, or assignment.` }; + const entity = params.entity as "resource" | "project" | "assignment" | "resource_month"; + if (!["resource", "project", "assignment", "resource_month"].includes(entity)) { + return { + error: + `Unknown entity: ${params.entity}. Use resource, project, assignment, or resource_month.`, + }; } - - const columns = params.columns; - const filters = params.filters ?? []; - const limit = Math.min(params.limit ?? 50, 200); - - // Build Prisma select from columns - const COLUMN_DEFS: Record> = { - resource: [ - { key: "id" }, { key: "eid" }, { key: "displayName" }, { key: "email" }, { key: "chapter" }, - { key: "resourceType" }, { key: "lcrCents" }, { key: "ucrCents" }, { key: "chargeabilityTarget" }, - { key: "fte" }, { key: "isActive" }, { key: "postalCode" }, { key: "federalState" }, - { key: "country.name", prismaPath: "country" }, { key: "metroCity.name", prismaPath: "metroCity" }, - { key: "orgUnit.name", prismaPath: "orgUnit" }, { key: "areaRole.name", prismaPath: "areaRole" }, - { key: "createdAt" }, { key: "updatedAt" }, - ], - project: [ - { key: "id" }, { key: "shortCode" }, { key: "name" }, { key: "orderType" }, { key: "allocationType" }, - { key: "status" }, { key: "winProbability" }, { key: "budgetCents" }, - { key: "startDate" }, { key: "endDate" }, { key: "responsiblePerson" }, - { key: "client.name", prismaPath: "client" }, { key: "createdAt" }, { key: "updatedAt" }, - ], - assignment: [ - { key: "id" }, { key: "resource.displayName", prismaPath: "resource" }, { key: "resource.eid", prismaPath: "resource" }, - { key: "project.name", prismaPath: "project" }, { key: "project.shortCode", prismaPath: "project" }, - { key: "startDate" }, { key: "endDate" }, { key: "hoursPerDay" }, { key: "percentage" }, - { key: "role" }, { key: "roleEntity.name", prismaPath: "roleEntity" }, - { key: "dailyCostCents" }, { key: "status" }, { key: "createdAt" }, { key: "updatedAt" }, - ], - }; - - const entityDefs = COLUMN_DEFS[entity]!; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const select: Record = { id: true }; - - for (const colKey of columns) { - const def = entityDefs.find((c) => c.key === colKey); - if (!def) continue; - if (colKey.includes(".")) { - const relationName = def.prismaPath ?? colKey.split(".")[0]!; - const fieldName = colKey.split(".").slice(1).join("."); - if (!select[relationName]) { - select[relationName] = { select: {} }; - } - select[relationName].select[fieldName] = true; - } else { - select[colKey] = true; - } - } - - // Build where from filters (only scalar top-level fields) - const SCALAR_FIELDS: Record> = { - resource: new Set(["id", "eid", "displayName", "email", "chapter", "resourceType", "lcrCents", "ucrCents", "chargeabilityTarget", "fte", "isActive", "postalCode", "federalState"]), - project: new Set(["id", "shortCode", "name", "orderType", "allocationType", "status", "winProbability", "budgetCents", "startDate", "endDate", "responsiblePerson"]), - assignment: new Set(["id", "startDate", "endDate", "hoursPerDay", "percentage", "role", "dailyCostCents", "status"]), - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const where: Record = {}; - for (const filter of filters) { - if (!SCALAR_FIELDS[entity]!.has(filter.field)) continue; - let value: unknown = filter.value; - // Try number conversion - const num = Number(filter.value); - if (!Number.isNaN(num) && filter.op !== "contains" && filter.op !== "in") { - value = num; - } - switch (filter.op) { - case "eq": where[filter.field] = value; break; - case "neq": where[filter.field] = { not: value }; break; - case "gt": where[filter.field] = { gt: value }; break; - case "lt": where[filter.field] = { lt: value }; break; - case "gte": where[filter.field] = { gte: value }; break; - case "lte": where[filter.field] = { lte: value }; break; - case "contains": where[filter.field] = { contains: String(filter.value), mode: "insensitive" }; break; - case "in": where[filter.field] = { in: filter.value.split(",").map((v: string) => v.trim()) }; break; - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const model = entity === "resource" ? ctx.db.resource : entity === "project" ? ctx.db.project : ctx.db.assignment; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const rows = await (model as any).findMany({ select, where, take: limit }); - - // Flatten nested relations - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const flatRows = (rows as any[]).map((row: Record) => { - const flat: Record = {}; - for (const [key, val] of Object.entries(row)) { - if (val !== null && typeof val === "object" && !(val instanceof Date) && !Array.isArray(val)) { - for (const [subKey, subVal] of Object.entries(val as Record)) { - flat[`${key}.${subKey}`] = subVal; - } - } else { - flat[key] = val; - } - } - return flat; + const caller = createReportCaller(createScopedCallerContext(ctx)); + const result = await caller.getReportData({ + entity, + columns: params.columns, + filters: params.filters ?? [], + periodMonth: params.periodMonth, + sortDir: "asc", + limit: Math.min(params.limit ?? 50, 200), + offset: 0, }); - return { rows: flatRows, rowCount: flatRows.length, columns: ["id", ...columns.filter((c) => c !== "id")] }; + return { + rows: result.rows, + rowCount: result.rows.length, + columns: result.columns, + }; }, async list_comments(params: { entityType: string; entityId: string }, ctx: ToolContext) { - const comments = await ctx.db.comment.findMany({ - where: { - entityType: params.entityType, - entityId: params.entityId, - parentId: null, - }, - include: { - author: { select: { id: true, name: true, email: true } }, - replies: { - include: { - author: { select: { id: true, name: true, email: true } }, - }, - orderBy: { createdAt: "asc" as const }, - }, - }, - orderBy: { createdAt: "asc" as const }, + const caller = createCommentCaller(createScopedCallerContext(ctx)); + const comments = await caller.list({ + entityType: params.entityType, + entityId: params.entityId, }); return comments.map((c) => ({ @@ -8241,106 +6637,24 @@ const executors = { seniority?: string; }, ctx: ToolContext) { assertPermission(ctx, "viewCosts" as PermissionKey); + const caller = createRateCardCaller(createScopedCallerContext(ctx)); + const result = await caller.lookupBestMatch(params); - // Find rate cards applicable — prefer client-specific, then generic - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const rateCardWhere: Record = { isActive: true }; - if (params.clientId) { - rateCardWhere.OR = [ - { clientId: params.clientId }, - { clientId: null }, - ]; - } - - const rateCards = await ctx.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 { message: "No active rate cards found." }; - } - - // Resolve role name to ID if needed - let roleId: string | undefined; - if (params.roleName) { - const role = await ctx.db.role.findFirst({ - where: { name: { contains: params.roleName, mode: "insensitive" } }, - select: { id: true, name: true }, - }); - if (role) roleId = role.id; - } - - // Score each line across all rate cards - type ScoredLine = { - rateCardName: string; - clientName: string | null; - lineId: string; - chapter: string | null; - seniority: string | null; - roleName: string | null; - costRate: string; - billRate: string | null; - score: number; - }; - const scoredLines: ScoredLine[] = []; - - 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 (params.chapter && line.chapter) { - if (line.chapter.toLowerCase() === params.chapter.toLowerCase()) score += 2; - else mismatch = true; - } - if (params.seniority && line.seniority) { - if (line.seniority.toLowerCase() === params.seniority.toLowerCase()) score += 1; - else mismatch = true; - } - // Prefer client-specific cards - if (params.clientId && card.client?.id === params.clientId) score += 3; - - if (!mismatch) { - scoredLines.push({ - rateCardName: card.name, - clientName: card.client?.name ?? null, - lineId: line.id, - chapter: line.chapter, - seniority: line.seniority, - roleName: line.role?.name ?? null, - costRate: fmtEur(line.costRateCents), - billRate: line.billRateCents ? fmtEur(line.billRateCents) : null, - score, - }); - } - } - } - - scoredLines.sort((a, b) => b.score - a.score); - - const best = scoredLines[0]; return { - bestMatch: best ?? null, - alternatives: scoredLines.slice(1, 4), - totalCandidates: scoredLines.length, + ...(result.message ? { message: result.message } : {}), + bestMatch: result.bestMatch + ? { + ...result.bestMatch, + costRate: fmtEur(result.bestMatch.costRateCents), + billRate: result.bestMatch.billRateCents ? fmtEur(result.bestMatch.billRateCents) : null, + } + : null, + alternatives: result.alternatives.map((alternative) => ({ + ...alternative, + costRate: fmtEur(alternative.costRateCents), + billRate: alternative.billRateCents ? fmtEur(alternative.billRateCents) : null, + })), + totalCandidates: result.totalCandidates, }; }, @@ -8358,356 +6672,38 @@ const executors = { remove?: boolean; }>; }, ctx: ToolContext) { - assertPermission(ctx, "manageAllocations" as PermissionKey); - - const DEFAULT_AVAILABILITY = { - monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0, - } as const; - - const project = await ctx.db.project.findUnique({ - where: { id: params.projectId }, - select: { id: true, name: true, budgetCents: true, startDate: true, endDate: true }, + const caller = createScenarioCaller(createScopedCallerContext(ctx)); + const result = await caller.simulate({ + projectId: params.projectId, + changes: params.changes.map((change) => ({ + ...change, + startDate: new Date(change.startDate), + endDate: new Date(change.endDate), + })), }); - if (!project) return { error: "Project not found" }; - - // Load current assignments - const currentAssignments = await ctx.db.assignment.findMany({ - where: { projectId: params.projectId, status: { not: "CANCELLED" } }, - include: { - resource: { - select: { - id: true, - displayName: true, - lcrCents: true, - availability: true, - chargeabilityTarget: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { code: true, dailyWorkingHours: true } }, - metroCity: { select: { name: true } }, - }, - }, - }, - }); - - // Collect resource IDs - const resourceIds = new Set(); - for (const c of params.changes) { if (c.resourceId) resourceIds.add(c.resourceId); } - for (const a of currentAssignments) { if (a.resourceId) resourceIds.add(a.resourceId); } - - const resources = await ctx.db.resource.findMany({ - where: { id: { in: [...resourceIds] } }, - select: { - id: true, - displayName: true, - lcrCents: true, - availability: true, - chargeabilityTarget: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { code: true, dailyWorkingHours: true } }, - metroCity: { select: { name: true } }, - }, - }); - const resourceMap = new Map(resources.map((r) => [r.id, r])); - - const scenarioRangeStarts = [ - ...currentAssignments.map((assignment) => assignment.startDate), - ...params.changes.map((change) => new Date(change.startDate)), - ]; - const scenarioRangeEnds = [ - ...currentAssignments.map((assignment) => assignment.endDate), - ...params.changes.map((change) => new Date(change.endDate)), - ]; - const scenarioPeriodStart = scenarioRangeStarts.length > 0 - ? new Date(Math.min(...scenarioRangeStarts.map((date) => date.getTime()))) - : project.startDate; - const scenarioPeriodEnd = scenarioRangeEnds.length > 0 - ? new Date(Math.max(...scenarioRangeEnds.map((date) => date.getTime()))) - : project.endDate; - const contexts = resourceIds.size > 0 - ? await loadResourceDailyAvailabilityContexts( - ctx.db, - resources.map((resource) => ({ - id: resource.id, - availability: (resource.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY, - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - })), - scenarioPeriodStart, - scenarioPeriodEnd, - ) - : new Map(); - - function calculateScenarioEntryTotals(input: { - resourceId: string | null; - lcrCents: number; - hoursPerDay: number; - startDate: Date; - endDate: Date; - availability: typeof DEFAULT_AVAILABILITY; - }): { totalHours: number; totalCostCents: number } { - const context = input.resourceId ? contexts.get(input.resourceId) : undefined; - const totalHours = calculateEffectiveBookedHours({ - availability: input.availability, - startDate: input.startDate, - endDate: input.endDate, - hoursPerDay: input.hoursPerDay, - periodStart: input.startDate, - periodEnd: input.endDate, - context, - }); - - return { - totalHours, - totalCostCents: Math.round(totalHours * input.lcrCents), - }; - } - - // Compute baseline - let baselineCostCents = 0; - let baselineHours = 0; - for (const a of currentAssignments) { - const fallbackDailyHours = a.resource?.country?.dailyWorkingHours ?? 8; - const availability = (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? { - monday: fallbackDailyHours, - tuesday: fallbackDailyHours, - wednesday: fallbackDailyHours, - thursday: fallbackDailyHours, - friday: fallbackDailyHours, - saturday: 0, - sunday: 0, - }; - const result = calculateScenarioEntryTotals({ - resourceId: a.resourceId, - lcrCents: a.resource?.lcrCents ?? 0, - hoursPerDay: a.hoursPerDay, - startDate: a.startDate, - endDate: a.endDate, - availability, - }); - baselineCostCents += result.totalCostCents; - baselineHours += result.totalHours; - } - - // Build scenario entries - const removedIds = new Set(params.changes.filter((c) => c.remove && c.assignmentId).map((c) => c.assignmentId!)); - const modifiedIds = new Set(params.changes.filter((c) => !c.remove && c.assignmentId).map((c) => c.assignmentId!)); - - const scenarioEntries: Array<{ - resourceId: string | null; - lcrCents: number; - hoursPerDay: number; - startDate: Date; - endDate: Date; - availability: typeof DEFAULT_AVAILABILITY; - }> = []; - - for (const a of currentAssignments) { - if (removedIds.has(a.id) || modifiedIds.has(a.id)) continue; - const fallbackDailyHours = a.resource?.country?.dailyWorkingHours ?? 8; - scenarioEntries.push({ - resourceId: a.resourceId, - lcrCents: a.resource?.lcrCents ?? 0, - hoursPerDay: a.hoursPerDay, - startDate: a.startDate, - endDate: a.endDate, - availability: (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? { - monday: fallbackDailyHours, - tuesday: fallbackDailyHours, - wednesday: fallbackDailyHours, - thursday: fallbackDailyHours, - friday: fallbackDailyHours, - saturday: 0, - sunday: 0, - }, - }); - } - - for (const c of params.changes) { - if (c.remove) continue; - const resource = c.resourceId ? resourceMap.get(c.resourceId) : null; - const fallbackDailyHours = resource?.country?.dailyWorkingHours ?? 8; - scenarioEntries.push({ - resourceId: c.resourceId ?? null, - lcrCents: resource?.lcrCents ?? 0, - hoursPerDay: c.hoursPerDay, - startDate: new Date(c.startDate), - endDate: new Date(c.endDate), - availability: (resource?.availability as typeof DEFAULT_AVAILABILITY) ?? { - monday: fallbackDailyHours, - tuesday: fallbackDailyHours, - wednesday: fallbackDailyHours, - thursday: fallbackDailyHours, - friday: fallbackDailyHours, - saturday: 0, - sunday: 0, - }, - }); - } - - // Compute scenario totals - let scenarioCostCents = 0; - let scenarioHours = 0; - for (const entry of scenarioEntries) { - const result = calculateScenarioEntryTotals(entry); - scenarioCostCents += result.totalCostCents; - scenarioHours += result.totalHours; - } - - const warnings: string[] = []; - const budgetCents = project.budgetCents ?? 0; - if (budgetCents > 0 && scenarioCostCents > budgetCents) { - const overBudgetPct = Math.round(((scenarioCostCents - budgetCents) / budgetCents) * 100); - warnings.push(`Scenario exceeds budget by ${overBudgetPct}%`); - } return { baseline: { - totalCost: fmtEur(baselineCostCents), - totalCostCents: baselineCostCents, - totalHours: baselineHours, - headcount: currentAssignments.length, + ...result.baseline, + totalCost: fmtEur(result.baseline.totalCostCents), }, scenario: { - totalCost: fmtEur(scenarioCostCents), - totalCostCents: scenarioCostCents, - totalHours: scenarioHours, - headcount: scenarioEntries.length, + ...result.scenario, + totalCost: fmtEur(result.scenario.totalCostCents), }, delta: { - costCents: scenarioCostCents - baselineCostCents, - cost: fmtEur(scenarioCostCents - baselineCostCents), - hours: scenarioHours - baselineHours, - headcount: scenarioEntries.length - currentAssignments.length, + ...result.delta, + cost: fmtEur(result.delta.costCents), }, - warnings, - budgetCents, + resourceImpacts: result.resourceImpacts, + warnings: result.warnings, + budgetCents: result.budgetCents, }; }, async generate_project_narrative(params: { projectId: string }, ctx: ToolContext) { - function countBizDays(start: Date, end: Date): number { - let count = 0; - const d = new Date(start); - while (d <= end) { - const dow = d.getDay(); - if (dow !== 0 && dow !== 6) count++; - d.setDate(d.getDate() + 1); - } - return count; - } - - const [project, settings] = await Promise.all([ - ctx.db.project.findUnique({ - where: { id: params.projectId }, - include: { - demandRequirements: { - select: { - headcount: true, - _count: { select: { assignments: true } }, - }, - }, - assignments: { - select: { - status: true, - dailyCostCents: true, - startDate: true, - endDate: true, - resource: { select: { displayName: true } }, - }, - }, - }, - }), - ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }), - ]); - - if (!project) return { error: "Project not found" }; - - if (!isAiConfigured(settings)) { - return { error: "AI is not configured. Please set credentials in Admin > Settings." }; - } - - const now = new Date(); - const totalDays = countBizDays(project.startDate, project.endDate); - const elapsedDays = countBizDays(project.startDate, now < project.endDate ? now : project.endDate); - const progressPercent = totalDays > 0 ? Math.round((elapsedDays / totalDays) * 100) : 0; - - const totalDemandHeadcount = project.demandRequirements.reduce((s, d) => s + d.headcount, 0); - const filledDemandHeadcount = project.demandRequirements.reduce( - (s, d) => s + Math.min(d._count.assignments, d.headcount), 0, - ); - const staffingPercent = totalDemandHeadcount > 0 ? Math.round((filledDemandHeadcount / totalDemandHeadcount) * 100) : 100; - - const totalCostCents = project.assignments.reduce((s, a) => { - const days = countBizDays(a.startDate, a.endDate); - return s + a.dailyCostCents * days; - }, 0); - const budgetCents = project.budgetCents; - const budgetUsedPercent = budgetCents > 0 ? Math.round((totalCostCents / budgetCents) * 100) : 0; - - const overrunCount = project.assignments.filter((a) => a.endDate > project.endDate).length; - - const dataContext = [ - `Project: ${project.name} (${project.shortCode})`, - `Status: ${project.status}`, - `Timeline: ${project.startDate.toISOString().slice(0, 10)} to ${project.endDate.toISOString().slice(0, 10)} (${progressPercent}% elapsed)`, - `Budget: ${fmtEur(budgetCents)} | Estimated cost: ${fmtEur(totalCostCents)} (${budgetUsedPercent}% of budget)`, - `Staffing: ${filledDemandHeadcount}/${totalDemandHeadcount} positions filled (${staffingPercent}%)`, - `Active assignments: ${project.assignments.filter((a) => a.status === "ACTIVE" || a.status === "CONFIRMED").length}`, - overrunCount > 0 - ? `Timeline risk: ${overrunCount} assignment(s) extend beyond project end date` - : "No timeline overruns detected", - ].join("\n"); - - const prompt = `Generate a concise executive summary for this project covering: budget status, staffing completeness, timeline risk, and key action items. Be specific with numbers. Keep it to 3-5 sentences.\n\n${dataContext}`; - - try { - const client = createAiClient(settings!); - const model = settings!.azureOpenAiDeployment!; - const maxTokens = settings!.aiMaxCompletionTokens ?? 300; - const temperature = settings!.aiTemperature ?? 1; - - const provider = settings!.aiProvider ?? "openai"; - const completion = await loggedAiCall(provider, model, prompt.length, () => - client.chat.completions.create({ - messages: [ - { role: "system", content: "You are a project management analyst providing brief executive summaries. Be factual and action-oriented." }, - { role: "user", content: prompt }, - ], - max_completion_tokens: maxTokens, - model, - ...(temperature !== 1 ? { temperature } : {}), - }), - ); - const narrative = completion.choices[0]?.message?.content?.trim() ?? ""; - - if (!narrative) return { error: "AI returned an empty response." }; - - const generatedAt = new Date().toISOString(); - - // Cache in project dynamicFields - const existingDynamic = (project.dynamicFields as Record) ?? {}; - await ctx.db.project.update({ - where: { id: params.projectId }, - data: { - dynamicFields: { - ...existingDynamic, - aiNarrative: narrative, - aiNarrativeGeneratedAt: generatedAt, - }, - }, - }); - - return { narrative, generatedAt }; - } catch (err) { - return { error: `AI call failed: ${parseAiError(err)}` }; - } + const caller = createInsightsCaller(createScopedCallerContext(ctx)); + return caller.generateProjectNarrative({ projectId: params.projectId }); }, async create_comment(params: { @@ -8715,18 +6711,11 @@ const executors = { entityId: string; body: string; }, ctx: ToolContext) { - // Resolve the DB user from the assistant tool context userId - const comment = await ctx.db.comment.create({ - data: { - entityType: params.entityType, - entityId: params.entityId, - authorId: ctx.userId, - body: params.body, - mentions: [], - }, - include: { - author: { select: { id: true, name: true, email: true } }, - }, + const caller = createCommentCaller(createScopedCallerContext(ctx)); + const comment = await caller.create({ + entityType: params.entityType, + entityId: params.entityId, + body: params.body, }); return { @@ -8743,29 +6732,10 @@ const executors = { commentId: string; resolved?: boolean; }, ctx: ToolContext) { - const existing = await ctx.db.comment.findUnique({ - where: { id: params.commentId }, - select: { id: true, authorId: true, body: true }, - }); - - if (!existing) return { error: "Comment not found" }; - - // Only the author or an admin can resolve - const dbUser = await ctx.db.user.findUnique({ - where: { id: ctx.userId }, - select: { systemRole: true }, - }); - if (existing.authorId !== ctx.userId && dbUser?.systemRole !== "ADMIN") { - return { error: "Only the comment author or an admin can resolve comments" }; - } - - const resolved = params.resolved !== false; - const updated = await ctx.db.comment.update({ - where: { id: params.commentId }, - data: { resolved }, - include: { - author: { select: { id: true, name: true, email: true } }, - }, + const caller = createCommentCaller(createScopedCallerContext(ctx)); + const updated = await caller.resolve({ + id: params.commentId, + resolved: params.resolved !== false, }); return { @@ -8792,7 +6762,7 @@ const executors = { startDate.setDate(startDate.getDate() - daysBack); const caller = createAuditLogCaller(createScopedCallerContext(ctx)); - const result = await caller.list({ + const result = await caller.listDetail({ ...(params.entityType ? { entityType: params.entityType } : {}), ...(params.userId ? { userId: params.userId } : {}), ...(params.action ? { action: params.action } : {}), @@ -8811,7 +6781,7 @@ const executors = { }, itemCount: result.items.length, nextCursor: result.nextCursor ?? null, - items: result.items.map(formatAuditListEntry), + items: result.items, }; }, @@ -8822,19 +6792,11 @@ const executors = { }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 50, 200); const caller = createAuditLogCaller(createScopedCallerContext(ctx)); - const entries = await caller.getByEntity({ + return caller.getByEntityDetail({ entityType: params.entityType, entityId: params.entityId, limit, }); - - return { - entityType: params.entityType, - entityId: params.entityId, - entityName: entries[0]?.entityName ?? null, - itemCount: entries.length, - items: entries.map(formatAuditDetailEntry), - }; }, async export_resources_csv(_params: Record, ctx: ToolContext) { @@ -8891,6 +6853,310 @@ const executors = { return caller.getImportBatch({ id: params.id }); }, + async stage_dispo_import_batch(params: { + chargeabilityWorkbookPath: string; + costWorkbookPath?: string; + notes?: string | null; + planningWorkbookPath: string; + referenceWorkbookPath: string; + rosterWorkbookPath?: string; + }, ctx: ToolContext) { + const caller = createDispoCaller(createScopedCallerContext(ctx)); + return caller.stageImportBatch({ + chargeabilityWorkbookPath: params.chargeabilityWorkbookPath, + planningWorkbookPath: params.planningWorkbookPath, + referenceWorkbookPath: params.referenceWorkbookPath, + ...(params.costWorkbookPath !== undefined ? { costWorkbookPath: params.costWorkbookPath } : {}), + ...(params.notes !== undefined ? { notes: params.notes } : {}), + ...(params.rosterWorkbookPath !== undefined ? { rosterWorkbookPath: params.rosterWorkbookPath } : {}), + }); + }, + + async validate_dispo_import_batch(params: { + chargeabilityWorkbookPath: string; + costWorkbookPath?: string; + importBatchId?: string; + notes?: string | null; + planningWorkbookPath: string; + referenceWorkbookPath: string; + rosterWorkbookPath?: string; + }, ctx: ToolContext) { + const caller = createDispoCaller(createScopedCallerContext(ctx)); + return caller.validateImportBatch({ + chargeabilityWorkbookPath: params.chargeabilityWorkbookPath, + planningWorkbookPath: params.planningWorkbookPath, + referenceWorkbookPath: params.referenceWorkbookPath, + ...(params.costWorkbookPath !== undefined ? { costWorkbookPath: params.costWorkbookPath } : {}), + ...(params.importBatchId !== undefined ? { importBatchId: params.importBatchId } : {}), + ...(params.notes !== undefined ? { notes: params.notes } : {}), + ...(params.rosterWorkbookPath !== undefined ? { rosterWorkbookPath: params.rosterWorkbookPath } : {}), + }); + }, + + async cancel_dispo_import_batch(params: { + id: string; + }, ctx: ToolContext) { + const caller = createDispoCaller(createScopedCallerContext(ctx)); + return caller.cancelImportBatch({ id: params.id }); + }, + + async list_dispo_staged_resources(params: { + importBatchId: string; + status?: StagedRecordStatus; + limit?: number; + cursor?: string; + }, ctx: ToolContext) { + const caller = createDispoCaller(createScopedCallerContext(ctx)); + return caller.listStagedResources({ + importBatchId: params.importBatchId, + ...(params.status !== undefined ? { status: params.status } : {}), + ...(params.cursor ? { cursor: params.cursor } : {}), + ...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 200) } : {}), + }); + }, + + async list_dispo_staged_projects(params: { + importBatchId: string; + status?: StagedRecordStatus; + isTbd?: boolean; + limit?: number; + cursor?: string; + }, ctx: ToolContext) { + const caller = createDispoCaller(createScopedCallerContext(ctx)); + return caller.listStagedProjects({ + importBatchId: params.importBatchId, + ...(params.status !== undefined ? { status: params.status } : {}), + ...(params.isTbd !== undefined ? { isTbd: params.isTbd } : {}), + ...(params.cursor ? { cursor: params.cursor } : {}), + ...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 200) } : {}), + }); + }, + + async list_dispo_staged_assignments(params: { + importBatchId: string; + status?: StagedRecordStatus; + resourceExternalId?: string; + limit?: number; + cursor?: string; + }, ctx: ToolContext) { + const caller = createDispoCaller(createScopedCallerContext(ctx)); + return caller.listStagedAssignments({ + importBatchId: params.importBatchId, + ...(params.status !== undefined ? { status: params.status } : {}), + ...(params.resourceExternalId !== undefined ? { resourceExternalId: params.resourceExternalId } : {}), + ...(params.cursor ? { cursor: params.cursor } : {}), + ...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 200) } : {}), + }); + }, + + async list_dispo_staged_vacations(params: { + importBatchId: string; + resourceExternalId?: string; + limit?: number; + cursor?: string; + }, ctx: ToolContext) { + const caller = createDispoCaller(createScopedCallerContext(ctx)); + return caller.listStagedVacations({ + importBatchId: params.importBatchId, + ...(params.resourceExternalId !== undefined ? { resourceExternalId: params.resourceExternalId } : {}), + ...(params.cursor ? { cursor: params.cursor } : {}), + ...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 200) } : {}), + }); + }, + + async list_dispo_staged_unresolved_records(params: { + importBatchId: string; + recordType?: DispoStagedRecordType; + limit?: number; + cursor?: string; + }, ctx: ToolContext) { + const caller = createDispoCaller(createScopedCallerContext(ctx)); + return caller.listStagedUnresolvedRecords({ + importBatchId: params.importBatchId, + ...(params.recordType !== undefined ? { recordType: params.recordType } : {}), + ...(params.cursor ? { cursor: params.cursor } : {}), + ...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 200) } : {}), + }); + }, + + async resolve_dispo_staged_record(params: { + action: "APPROVE" | "REJECT" | "SKIP"; + id: string; + recordType: DispoStagedRecordType; + }, ctx: ToolContext) { + const caller = createDispoCaller(createScopedCallerContext(ctx)); + return caller.resolveStagedRecord({ + action: params.action, + id: params.id, + recordType: params.recordType, + }); + }, + + async commit_dispo_import_batch(params: { + allowTbdUnresolved?: boolean; + importBatchId: string; + importTbdProjects?: boolean; + }, ctx: ToolContext) { + const caller = createDispoCaller(createScopedCallerContext(ctx)); + return caller.commitImportBatch({ + importBatchId: params.importBatchId, + ...(params.allowTbdUnresolved !== undefined ? { allowTbdUnresolved: params.allowTbdUnresolved } : {}), + ...(params.importTbdProjects !== undefined ? { importTbdProjects: params.importTbdProjects } : {}), + }); + }, + + async get_system_settings(_params: Record, ctx: ToolContext) { + const caller = createSettingsCaller(createScopedCallerContext(ctx)); + return caller.getSystemSettings(); + }, + + async update_system_settings(params: { + aiProvider?: "openai" | "azure"; + azureOpenAiEndpoint?: string; + azureOpenAiDeployment?: string; + azureOpenAiApiKey?: string; + azureApiVersion?: string; + aiMaxCompletionTokens?: number; + aiTemperature?: number; + aiSummaryPrompt?: string; + scoreWeights?: { + skillDepth: number; + skillBreadth: number; + costEfficiency: number; + chargeability: number; + experience: number; + }; + scoreVisibleRoles?: SystemRole[]; + smtpHost?: string; + smtpPort?: number; + smtpUser?: string; + smtpPassword?: string; + smtpFrom?: string; + smtpTls?: boolean; + anonymizationEnabled?: boolean; + anonymizationDomain?: string; + anonymizationSeed?: string; + anonymizationMode?: "global"; + azureDalleDeployment?: string; + azureDalleEndpoint?: string; + azureDalleApiKey?: string; + geminiApiKey?: string; + geminiModel?: string; + imageProvider?: "dalle" | "gemini"; + vacationDefaultDays?: number; + timelineUndoMaxSteps?: number; + }, ctx: ToolContext) { + const caller = createSettingsCaller(createScopedCallerContext(ctx)); + return caller.updateSystemSettings(params); + }, + + async test_ai_connection(_params: Record, ctx: ToolContext) { + const caller = createSettingsCaller(createScopedCallerContext(ctx)); + return caller.testAiConnection(); + }, + + async test_smtp_connection(_params: Record, ctx: ToolContext) { + const caller = createSettingsCaller(createScopedCallerContext(ctx)); + return caller.testSmtpConnection(); + }, + + async test_gemini_connection(_params: Record, ctx: ToolContext) { + const caller = createSettingsCaller(createScopedCallerContext(ctx)); + return caller.testGeminiConnection(); + }, + + async get_ai_configured(_params: Record, ctx: ToolContext) { + const caller = createSettingsCaller(createScopedCallerContext(ctx)); + return caller.getAiConfigured(); + }, + + async list_system_role_configs(_params: Record, ctx: ToolContext) { + const caller = createSystemRoleConfigCaller(createScopedCallerContext(ctx)); + return caller.list(); + }, + + async update_system_role_config(params: { + role: string; + label?: string; + description?: string | null; + color?: string | null; + defaultPermissions?: string[]; + }, ctx: ToolContext) { + const caller = createSystemRoleConfigCaller(createScopedCallerContext(ctx)); + return caller.update(params); + }, + + async list_webhooks(_params: Record, ctx: ToolContext) { + const caller = createWebhookCaller(createScopedCallerContext(ctx)); + const webhooks = await caller.list(); + return sanitizeWebhookList(webhooks); + }, + + async get_webhook(params: { + id: string; + }, ctx: ToolContext) { + const caller = createWebhookCaller(createScopedCallerContext(ctx)); + const webhook = await caller.getById({ id: params.id }); + return sanitizeWebhook(webhook); + }, + + async create_webhook(params: { + name: string; + url: string; + secret?: string; + events: string[]; + isActive?: boolean; + }, ctx: ToolContext) { + const caller = createWebhookCaller(createScopedCallerContext(ctx)); + const webhook = await caller.create({ + name: params.name, + url: params.url, + events: params.events as [string, ...string[]], + ...(params.secret !== undefined ? { secret: params.secret } : {}), + ...(params.isActive !== undefined ? { isActive: params.isActive } : {}), + }); + return sanitizeWebhook(webhook); + }, + + async update_webhook(params: { + id: string; + data: { + name?: string; + url?: string; + secret?: string | null; + events?: string[]; + isActive?: boolean; + }; + }, ctx: ToolContext) { + const caller = createWebhookCaller(createScopedCallerContext(ctx)); + const webhook = await caller.update({ + id: params.id, + data: { + ...(params.data.name !== undefined ? { name: params.data.name } : {}), + ...(params.data.url !== undefined ? { url: params.data.url } : {}), + ...(params.data.secret !== undefined ? { secret: params.data.secret } : {}), + ...(params.data.events !== undefined ? { events: params.data.events as [string, ...string[]] } : {}), + ...(params.data.isActive !== undefined ? { isActive: params.data.isActive } : {}), + }, + }); + return sanitizeWebhook(webhook); + }, + + async delete_webhook(params: { + id: string; + }, ctx: ToolContext) { + const caller = createWebhookCaller(createScopedCallerContext(ctx)); + await caller.delete({ id: params.id }); + return { ok: true, id: params.id }; + }, + + async test_webhook(params: { + id: string; + }, ctx: ToolContext) { + const caller = createWebhookCaller(createScopedCallerContext(ctx)); + return caller.test({ id: params.id }); + }, + async list_audit_log_entries(params: { entityType?: string; entityId?: string; @@ -8904,7 +7170,7 @@ const executors = { cursor?: string; }, ctx: ToolContext) { const caller = createAuditLogCaller(createScopedCallerContext(ctx)); - const result = await caller.list({ + const result = await caller.listDetail({ ...(params.entityType ? { entityType: params.entityType } : {}), ...(params.entityId ? { entityId: params.entityId } : {}), ...(params.userId ? { userId: params.userId } : {}), @@ -8930,7 +7196,7 @@ const executors = { }, itemCount: result.items.length, nextCursor: result.nextCursor ?? null, - items: result.items.map(formatAuditListEntry), + items: result.items, }; }, @@ -8938,8 +7204,7 @@ const executors = { id: string; }, ctx: ToolContext) { const caller = createAuditLogCaller(createScopedCallerContext(ctx)); - const entry = await caller.getById({ id: params.id }); - return formatAuditDetailEntry(entry); + return caller.getByIdDetail({ id: params.id }); }, async get_audit_log_timeline(params: { @@ -8948,18 +7213,11 @@ const executors = { limit?: number; }, ctx: ToolContext) { const caller = createAuditLogCaller(createScopedCallerContext(ctx)); - const timeline = await caller.getTimeline({ + return caller.getTimelineDetail({ ...(params.startDate ? { startDate: parseIsoDate(params.startDate, "startDate") } : {}), ...(params.endDate ? { endDate: parseIsoDate(params.endDate, "endDate") } : {}), ...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 500) } : {}), }); - - return Object.fromEntries( - Object.entries(timeline).map(([dateKey, entries]) => [ - dateKey, - entries.map(formatAuditDetailEntry), - ]), - ); }, async get_audit_activity_summary(params: { @@ -8974,98 +7232,28 @@ const executors = { }, async get_shoring_ratio(params: { projectId: string }, ctx: ToolContext) { - const sel = { id: true, name: true, shortCode: true, shoringThreshold: true, onshoreCountryCode: true } as const; - let project = await ctx.db.project.findUnique({ where: { id: params.projectId }, select: sel }); - if (!project) { - project = await ctx.db.project.findUnique({ where: { shortCode: params.projectId }, select: sel }); - } - if (!project) return { error: `Project not found: ${params.projectId}` }; + const project = await resolveProjectIdentifier(ctx, params.projectId); + if ("error" in project) return project; - const assignments = await ctx.db.assignment.findMany({ - where: { projectId: project.id, status: { not: "CANCELLED" } }, - include: { - resource: { - include: { - country: { select: { code: true } }, - metroCity: { select: { id: true, name: true } }, - }, - }, - }, - }); + const caller = createProjectCaller(createScopedCallerContext(ctx)); + const result = await caller.getShoringRatio({ projectId: project.id }); - if (assignments.length === 0) { + if (result.totalHours <= 0) { return `Project "${project.name}" (${project.shortCode}): No active assignments — shoring ratio not available.`; } - const { calculateShoringRatio: calcShoring } = await import("@capakraken/engine/allocation"); - - const resourcesById = new Map( - assignments.map((assignment) => [ - assignment.resourceId, - { - id: assignment.resourceId, - availability: assignment.resource.availability as unknown as WeekdayAvailability, - countryId: assignment.resource.countryId, - countryCode: assignment.resource.country?.code, - federalState: assignment.resource.federalState, - metroCityId: assignment.resource.metroCityId, - metroCityName: assignment.resource.metroCity?.name, - }, - ]), - ); - const periodStart = assignments.reduce( - (min, assignment) => assignment.startDate < min ? assignment.startDate : min, - assignments[0]!.startDate, - ); - const periodEnd = assignments.reduce( - (max, assignment) => assignment.endDate > max ? assignment.endDate : max, - assignments[0]!.endDate, - ); - const contexts = await loadResourceDailyAvailabilityContexts( - ctx.db, - [...resourcesById.values()], - periodStart, - periodEnd, - ); - const mapped = assignments.flatMap((a) => { - const availability = a.resource.availability as unknown as WeekdayAvailability; - const context = contexts.get(a.resourceId); - const bookedHours = calculateEffectiveBookedHours({ - availability, - startDate: a.startDate, - endDate: a.endDate, - hoursPerDay: a.hoursPerDay, - periodStart, - periodEnd, - context, - }); - if (bookedHours <= 0 || a.hoursPerDay <= 0) { - return []; - } - return { - resourceId: a.resourceId, - countryCode: a.resource.country?.code ?? null, - hoursPerDay: a.hoursPerDay, - workingDays: bookedHours / a.hoursPerDay, - }; - }); - - const threshold = project.shoringThreshold ?? 55; - const onshoreCode = project.onshoreCountryCode ?? "DE"; - const result = calcShoring(mapped, threshold, onshoreCode); - const countryParts = Object.entries(result.byCountry) .sort((a, b) => b[1].pct - a[1].pct) .map(([code, info]) => `${code} ${info.pct}% (${info.resourceCount} people)`) .join(", "); - const status = result.offshoreRatio >= threshold - ? `Target met (>=${threshold}% offshore)` - : result.offshoreRatio >= threshold - 10 - ? `Close to target (${threshold}% offshore needed)` - : `Below target — only ${result.offshoreRatio}% offshore, need ${threshold}%`; + const status = result.offshoreRatio >= result.threshold + ? `Target met (>=${result.threshold}% offshore)` + : result.offshoreRatio >= result.threshold - 10 + ? `Close to target (${result.threshold}% offshore needed)` + : `Below target — only ${result.offshoreRatio}% offshore, need ${result.threshold}%`; - return `Project "${project.name}" (${project.shortCode}): ${result.onshoreRatio}% onshore (${onshoreCode}), ${result.offshoreRatio}% offshore. ${status}. Breakdown: ${countryParts}.${result.unknownCount > 0 ? ` (${result.unknownCount} resource(s) without country)` : ""}`; + return `Project "${project.name}" (${project.shortCode}): ${result.onshoreRatio}% onshore (${result.onshoreCountryCode}), ${result.offshoreRatio}% offshore. ${status}. Breakdown: ${countryParts}.${result.unknownCount > 0 ? ` (${result.unknownCount} resource(s) without country)` : ""}`; }, }; @@ -9141,6 +7329,15 @@ export async function executeTool( }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); + logger.error( + { + tool: name, + userId: ctx.userId, + userRole: ctx.userRole, + error: err instanceof Error ? { message: err.message, stack: err.stack } : err, + }, + "AI assistant tool execution failed", + ); return { content: JSON.stringify({ error: msg }) }; } } diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index 7f2d11e..97b167a 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -5,7 +5,7 @@ import { z } from "zod"; 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 { createTRPCRouter, protectedProcedure } from "../trpc.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 PENDING_APPROVAL_TTL_MS = 15 * 60 * 1000; 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 AssistantApprovalStore = Pick; +class AssistantApprovalStorageUnavailableError extends Error { + constructor() { + super("Assistant approval storage is unavailable."); + this.name = "AssistantApprovalStorageUnavailableError"; + } +} + export interface PendingAssistantApproval { id: string; userId: string; @@ -83,29 +164,32 @@ Datenmodell: - 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) - 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 `; /** Map tool names to the permission required to use them */ const TOOL_PERMISSION_MAP: Record = { - list_users: PermissionKey.MANAGE_USERS, // Resource management update_resource: "manageResources", create_resource: "manageResources", deactivate_resource: "manageResources", - create_role: "manageResources", - update_role: "manageResources", - delete_role: "manageResources", - create_org_unit: "manageResources", - update_org_unit: "manageResources", + create_role: PermissionKey.MANAGE_ROLES, + update_role: PermissionKey.MANAGE_ROLES, + delete_role: PermissionKey.MANAGE_ROLES, // Project management update_project: "manageProjects", create_project: "manageProjects", delete_project: "manageProjects", - create_client: "manageProjects", - update_client: "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", remove_project_cover: "manageProjects", import_csv_data: PermissionKey.IMPORT_DATA, @@ -120,15 +204,9 @@ const TOOL_PERMISSION_MAP: Record = { batch_shift_timeline_allocations: "manageAllocations", create_demand: "manageAllocations", fill_demand: "manageAllocations", + create_estimate_planning_handoff: "manageAllocations", // Vacation management - create_vacation: "manageVacations", - approve_vacation: "manageVacations", - reject_vacation: "manageVacations", - cancel_vacation: "manageVacations", - set_entitlement: "manageVacations", // Task management - create_task_for_user: "manageProjects", - send_broadcast: "manageProjects", execute_task_action: "manageAllocations", }; @@ -142,6 +220,7 @@ const COST_TOOLS = new Set([ "resolve_rate", "list_rate_cards", "get_estimate_detail", + "get_estimate_version_snapshot", "find_best_project_resource", ]); @@ -158,22 +237,90 @@ const CONTROLLER_ONLY_TOOLS = new Set([ "get_chargeability_report", "get_resource_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. */ const MANAGER_ONLY_TOOLS = new Set([ "import_csv_data", + "list_assignable_users", + "create_notification", "update_timeline_allocation_inline", "apply_timeline_project_shift", "quick_assign_timeline_resource", "batch_quick_assign_timeline_resources", "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. */ 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", "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", "update_country", "create_metro_city", @@ -220,6 +367,96 @@ export function getAvailableAssistantTools(permissions: Set, 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(); + 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[] { const duplicateIndex = existing.findIndex((item) => item.kind === next.kind && item.title === next.title && item.subtitle === next.subtitle); 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( + operation: () => Promise, + fallback: () => T, +): Promise { + try { + return await operation(); + } catch (error) { + if (!isAssistantApprovalTableMissingError(error)) throw error; + logAssistantApprovalStorageUnavailable(error); + return fallback(); + } +} + export async function listPendingAssistantApprovals( db: AssistantApprovalStore, userId: string, ): Promise { - await db.assistantApproval.updateMany({ - where: { - userId, - status: AssistantApprovalStatus.PENDING, - expiresAt: { lte: new Date() }, - }, - data: { - status: AssistantApprovalStatus.EXPIRED, - }, - }); + return withAssistantApprovalFallback(async () => { + await db.assistantApproval.updateMany({ + where: { + userId, + status: AssistantApprovalStatus.PENDING, + expiresAt: { lte: new Date() }, + }, + data: { + status: AssistantApprovalStatus.EXPIRED, + }, + }); - const approvals = await db.assistantApproval.findMany({ - where: { - userId, - status: AssistantApprovalStatus.PENDING, - expiresAt: { gt: new Date() }, - }, - orderBy: { createdAt: "desc" }, - }); + const approvals = await db.assistantApproval.findMany({ + where: { + userId, + status: AssistantApprovalStatus.PENDING, + expiresAt: { gt: new Date() }, + }, + orderBy: { createdAt: "desc" }, + }); - return approvals.map(mapPendingApproval); + return approvals.map(mapPendingApproval); + }, () => []); } export async function clearPendingAssistantApproval( @@ -339,17 +632,19 @@ export async function clearPendingAssistantApproval( userId: string, conversationId: string, ): Promise { - await db.assistantApproval.updateMany({ - where: { - userId, - conversationId, - status: AssistantApprovalStatus.PENDING, - }, - data: { - status: AssistantApprovalStatus.CANCELLED, - cancelledAt: new Date(), - }, - }); + await withAssistantApprovalFallback(async () => { + await db.assistantApproval.updateMany({ + where: { + userId, + conversationId, + status: AssistantApprovalStatus.PENDING, + }, + data: { + status: AssistantApprovalStatus.CANCELLED, + cancelledAt: new Date(), + }, + }); + }, () => undefined); } export async function peekPendingAssistantApproval( @@ -357,28 +652,30 @@ export async function peekPendingAssistantApproval( userId: string, conversationId: string, ): Promise { - await db.assistantApproval.updateMany({ - where: { - userId, - conversationId, - status: AssistantApprovalStatus.PENDING, - expiresAt: { lte: new Date() }, - }, - data: { - status: AssistantApprovalStatus.EXPIRED, - }, - }); + return withAssistantApprovalFallback(async () => { + await db.assistantApproval.updateMany({ + where: { + userId, + conversationId, + status: AssistantApprovalStatus.PENDING, + expiresAt: { lte: new Date() }, + }, + data: { + status: AssistantApprovalStatus.EXPIRED, + }, + }); - const pending = await db.assistantApproval.findFirst({ - where: { - userId, - conversationId, - status: AssistantApprovalStatus.PENDING, - }, - orderBy: { createdAt: "desc" }, - }); - if (!pending) return null; - return mapPendingApproval(pending); + const pending = await db.assistantApproval.findFirst({ + where: { + userId, + conversationId, + status: AssistantApprovalStatus.PENDING, + }, + orderBy: { createdAt: "desc" }, + }); + if (!pending) return null; + return mapPendingApproval(pending); + }, () => null); } export async function consumePendingAssistantApproval( @@ -426,19 +723,25 @@ export async function createPendingAssistantApproval( const now = new Date(); const expiresAt = new Date(now.getTime() + (options?.ttlMs ?? PENDING_APPROVAL_TTL_MS)); const summary = options?.summary ?? buildApprovalSummary(toolName, toolArguments); - await clearPendingAssistantApproval(db, userId, conversationId); - const pendingApproval = await db.assistantApproval.create({ - data: { - userId, - conversationId, - toolName, - toolArguments, - summary, - createdAt: now, - expiresAt, - }, - }); - return mapPendingApproval(pendingApproval); + try { + await clearPendingAssistantApproval(db, userId, conversationId); + const pendingApproval = await db.assistantApproval.create({ + data: { + userId, + conversationId, + toolName, + toolArguments, + summary, + createdAt: now, + expiresAt, + }, + }); + return mapPendingApproval(pendingApproval); + } catch (error) { + if (!isAssistantApprovalTableMissingError(error)) throw error; + logAssistantApprovalStorageUnavailable(error); + throw new AssistantApprovalStorageUnavailableError(); + } } function isAffirmativeConfirmationReply(content: string): boolean { @@ -669,7 +972,11 @@ export const assistantRouter = createTRPCRouter({ } // 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 const toolCtx: ToolContext = { @@ -799,13 +1106,26 @@ export const assistantRouter = createTRPCRouter({ // 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 } }>) { if (MUTATION_TOOLS.has(toolCall.function.name)) { - const approval = await createPendingAssistantApproval( - ctx.db, - userId, - conversationId, - toolCall.function.name, - toolCall.function.arguments, - ); + let approval: PendingAssistantApproval; + try { + approval = await createPendingAssistantApproval( + ctx.db, + userId, + 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({ db: ctx.db, diff --git a/packages/api/src/router/audit-log.ts b/packages/api/src/router/audit-log.ts index 5de9f09..c1a8b61 100644 --- a/packages/api/src/router/audit-log.ts +++ b/packages/api/src/router/audit-log.ts @@ -1,6 +1,235 @@ import { z } from "zod"; 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) { + const { entityType, entityId, userId, action, source, startDate, endDate, search } = input; + const where: Record = {}; + + 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 = {}; + 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 ?? {}), 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 = {}; + + if (input.startDate || input.endDate) { + const createdAt: Record = {}; + 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 = {}; + 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 ─────────────────────────────────────────────────────────────────── export const auditLogRouter = createTRPCRouter({ @@ -24,65 +253,52 @@ export const auditLogRouter = createTRPCRouter({ }), ) .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 = {}; - - 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 = {}; - 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" } }, - ]; - } - - // Default to last 30 days if no date filter to avoid full table scan - if (!startDate && !endDate && !entityId) { - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - where.createdAt = { ...(where.createdAt as Record ?? {}), gte: thirtyDaysAgo }; - } - - const items = await ctx.db.auditLog.findMany({ - 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 }; + listDetail: controllerProcedure + .input( + z.object({ + entityType: z.string().optional(), + entityId: z.string().optional(), + userId: z.string().optional(), + action: z.string().optional(), + source: z.string().optional(), + startDate: z.date().optional(), + endDate: z.date().optional(), + search: z.string().optional(), + limit: z.number().min(1).max(100).default(50), + cursor: z.string().optional(), + }), + ) + .query(async ({ ctx, input }) => { + const result = await 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, + })); + return { + items: result.items.map(formatAuditListEntry), + nextCursor: result.nextCursor ?? null, + }; }), /** @@ -91,10 +307,14 @@ export const auditLogRouter = createTRPCRouter({ getById: controllerProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { - return ctx.db.auditLog.findUniqueOrThrow({ - where: { id: input.id }, - include: { user: { select: { id: true, name: true, email: true } } }, - }); + return getAuditEntryById(ctx.db, input.id); + }), + + 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 }) => { - return ctx.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, - }); + return getAuditEntriesByEntity(ctx.db, input); + }), + + getByEntityDetail: controllerProcedure + .input( + z.object({ + entityType: z.string(), + entityId: z.string(), + limit: z.number().min(1).max(200).default(50), + }), + ) + .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 }) => { - const where: Record = {}; + return getAuditTimeline(ctx.db, toAuditTimelineInput({ + startDate: input.startDate, + endDate: input.endDate, + limit: input.limit, + })); + }), - if (input.startDate || input.endDate) { - const createdAt: Record = {}; - if (input.startDate) createdAt.gte = input.startDate; - if (input.endDate) createdAt.lte = input.endDate; - where.createdAt = createdAt; - } - - const entries = await ctx.db.auditLog.findMany({ - where, - include: { - user: { select: { id: true, name: true, email: true } }, - }, - orderBy: { createdAt: "desc" }, - take: input.limit, - }); - - // Group by date string (YYYY-MM-DD) - const grouped: Record = {}; - for (const entry of entries) { - const dateKey = entry.createdAt.toISOString().slice(0, 10); - if (!grouped[dateKey]) grouped[dateKey] = []; - grouped[dateKey].push(entry); - } - - return grouped; + getTimelineDetail: controllerProcedure + .input( + z.object({ + startDate: z.date().optional(), + endDate: z.date().optional(), + limit: z.number().min(1).max(500).default(200), + }), + ) + .query(async ({ ctx, input }) => { + const timeline = await getAuditTimeline(ctx.db, toAuditTimelineInput({ + startDate: input.startDate, + endDate: input.endDate, + limit: input.limit, + })); + return Object.fromEntries( + Object.entries(timeline).map(([dateKey, entries]) => [ + dateKey, + entries.map(formatAuditDetailEntry), + ]), + ); }), /** diff --git a/packages/api/src/router/blueprint.ts b/packages/api/src/router/blueprint.ts index ae72550..3ccf1df 100644 --- a/packages/api/src/router/blueprint.ts +++ b/packages/api/src/router/blueprint.ts @@ -6,6 +6,18 @@ import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js import { createAuditEntry } from "../lib/audit.js"; 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 .input( z.object({ @@ -33,6 +45,70 @@ export const blueprintRouter = createTRPCRouter({ 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 .input(CreateBlueprintSchema) .mutation(async ({ ctx, input }) => { diff --git a/packages/api/src/router/chargeability-report.ts b/packages/api/src/router/chargeability-report.ts index f43c616..dee4294 100644 --- a/packages/api/src/router/chargeability-report.ts +++ b/packages/api/src/router/chargeability-report.ts @@ -7,10 +7,12 @@ import { getMonthKeys, type AssignmentSlice, } from "@capakraken/engine"; +import type { PrismaClient } from "@capakraken/db"; import type { WeekdayAvailability } from "@capakraken/shared"; +import { PermissionKey } from "@capakraken/shared"; import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application"; 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 { calculateEffectiveAvailableHours, @@ -18,221 +20,299 @@ import { loadResourceDailyAvailabilityContexts, } from "../lib/resource-capacity.js"; -export const chargeabilityReportRouter = createTRPCRouter({ - getReport: controllerProcedure - .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; +function round1(value: number): number { + return Math.round(value * 10) / 10; +} - // Parse month range - const [startYear, startMo] = startMonth.split("-").map(Number) as [number, number]; - const [endYear, endMo] = endMonth.split("-").map(Number) as [number, number]; - const rangeStart = getMonthRange(startYear, startMo).start; - const rangeEnd = getMonthRange(endYear, endMo).end; - const monthKeys = getMonthKeys(rangeStart, rangeEnd); +const reportInputSchema = z.object({ + startMonth: z.string().regex(/^\d{4}-\d{2}$/), + 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), +}); - // Fetch resources with filters - const resourceWhere = { - isActive: true, - chgResponsibility: true, - departed: false, - rolledOff: false, - ...(input.orgUnitId ? { orgUnitId: input.orgUnitId } : {}), - ...(input.managementLevelGroupId ? { managementLevelGroupId: input.managementLevelGroupId } : {}), - ...(input.countryId ? { countryId: input.countryId } : {}), - }; +const detailedReportInputSchema = reportInputSchema.extend({ + resourceQuery: z.string().optional(), + resourceLimit: z.number().int().min(1).max(100).optional(), +}); - const resources = await ctx.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" }, +type ChargeabilityReportDbClient = Pick< + PrismaClient, + "assignment" | "resource" | "project" | "vacation" | "holidayCalendar" | "systemSettings" +>; + +async function queryChargeabilityReport( + db: ChargeabilityReportDbClient, + input: z.infer, +) { + const { startMonth, endMonth, includeProposed } = input; + + const [startYear, startMo] = startMonth.split("-").map(Number) as [number, number]; + const [endYear, endMo] = endMonth.split("-").map(Number) as [number, number]; + const rangeStart = getMonthRange(startYear, startMo).start; + const rangeEnd = getMonthRange(endYear, endMo).end; + const monthKeys = getMonthKeys(rangeStart, rangeEnd); + + const resourceWhere = { + 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) { - 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, - }; - })); + const categoryCode = assignment.project.utilizationCategory?.code; 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, - }; - })); - - // 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, + hoursPerDay: assignment.hoursPerDay, + workingDays: 0, + categoryCode: typeof categoryCode === "string" && categoryCode.length > 0 ? categoryCode : "Chg", + totalChargeableHours, }; }); - const directory = await getAnonymizationDirectory(ctx.db); + const forecast = deriveResourceForecast({ + fte: resource.fte, + targetPercentage: targetPct, + assignments: slices, + sah: availableHours, + }); return { - monthKeys, - resources: anonymizeResources(resourceRows, directory), - groupTotals, + monthKey: key, + sah: availableHours, + ...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>, + input: z.infer, +) { + 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); }), }); diff --git a/packages/api/src/router/client.ts b/packages/api/src/router/client.ts index 8f8fcbb..458c4cb 100644 --- a/packages/api/src/router/client.ts +++ b/packages/api/src/router/client.ts @@ -44,7 +44,12 @@ export const clientRouter = createTRPCRouter({ ...(input?.parentId !== undefined ? { parentId: input.parentId } : {}), ...(input?.isActive !== undefined ? { isActive: input.isActive } : {}), ...(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 } } }, @@ -81,6 +86,98 @@ export const clientRouter = createTRPCRouter({ 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 .input(CreateClientSchema) .mutation(async ({ ctx, input }) => { diff --git a/packages/api/src/router/computation-graph.ts b/packages/api/src/router/computation-graph.ts index eddbc78..8934205 100644 --- a/packages/api/src/router/computation-graph.ts +++ b/packages/api/src/router/computation-graph.ts @@ -13,7 +13,7 @@ import { import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared"; import { VacationStatus } from "@capakraken/db"; import { z } from "zod"; -import { createTRPCRouter, controllerProcedure } from "../trpc.js"; +import { createTRPCRouter, controllerProcedure, type TRPCContext } from "../trpc.js"; import { fmtEur } from "../lib/format-utils.js"; import { asHolidayResolverDb, @@ -86,6 +86,57 @@ function sumAvailabilityHoursForDates( return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0); } +function filterGraphData< + TNode extends { id: string; domain: string }, + TLink extends { source: string; target: string }, +>(input: { + nodes: TNode[]; + links: TLink[]; + domain?: string; + includeLinks?: boolean; +}) { + const requestedDomain = input.domain?.trim().toUpperCase(); + const nodes = requestedDomain + ? input.nodes.filter((node) => node.domain === requestedDomain) + : input.nodes; + const selectedNodeIds = new Set(nodes.map((node) => node.id)); + const links = input.includeLinks + ? input.links.filter((link) => selectedNodeIds.has(link.source) && selectedNodeIds.has(link.target)) + : []; + + return { + requestedDomain: requestedDomain ?? null, + includedLinks: input.includeLinks ?? false, + selectedNodeCount: nodes.length, + selectedLinkCount: links.length, + nodes, + ...(input.includeLinks ? { links } : {}), + }; +} + +function getAvailableDomains(nodes: Array<{ domain: Domain }>): Domain[] { + return [...new Set(nodes.map((node) => node.domain))]; +} + +const resourceGraphInputSchema = z.object({ + resourceId: z.string(), + month: z.string().regex(/^\d{4}-\d{2}$/), +}); + +const resourceGraphDetailInputSchema = resourceGraphInputSchema.extend({ + domain: z.string().trim().min(1).optional(), + includeLinks: z.boolean().optional(), +}); + +const projectGraphInputSchema = z.object({ + projectId: z.string(), +}); + +const projectGraphDetailInputSchema = projectGraphInputSchema.extend({ + domain: z.string().trim().min(1).optional(), + includeLinks: z.boolean().optional(), +}); + // ─── Router ───────────────────────────────────────────────────────────────── export const computationGraphRouter = createTRPCRouter({ @@ -94,964 +145,1026 @@ export const computationGraphRouter = createTRPCRouter({ * for a single resource in a single month. */ getResourceData: controllerProcedure - .input(z.object({ - resourceId: z.string(), - month: z.string().regex(/^\d{4}-\d{2}$/), - })) + .input(resourceGraphInputSchema) + .query(({ ctx, input }) => readResourceGraphSnapshot(ctx, input)), + + getResourceDataDetail: controllerProcedure + .input(resourceGraphDetailInputSchema) .query(async ({ ctx, input }) => { - const [year, month] = input.month.split("-").map(Number) as [number, number]; - const { start: monthStart, end: monthEnd } = getMonthRange(year, month); - - // ── 1. Load resource ── - const resource = await ctx.db.resource.findUniqueOrThrow({ - where: { id: input.resourceId }, - select: { - id: true, - displayName: true, - eid: true, - fte: true, - lcrCents: true, - chargeabilityTarget: true, - countryId: true, - federalState: true, - metroCityId: true, - availability: true, - country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } }, - metroCity: { select: { id: true, name: true } }, - managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } }, - }, + const graph = await readResourceGraphSnapshot(ctx, input); + return formatResourceGraphDetail({ + resourceId: input.resourceId, + graph, + ...(input.domain ? { domain: input.domain } : {}), + ...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}), }); - - const dailyHours = resource.country?.dailyWorkingHours ?? 8; - const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null; - const targetPct = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100); - - // Resource weekly availability (per-day hours) - const avail = resource.availability as WeekdayAvailability | null; - const weeklyAvailability: WeekdayAvailability = avail ?? { - monday: dailyHours, tuesday: dailyHours, wednesday: dailyHours, - thursday: dailyHours, friday: dailyHours, saturday: 0, sunday: 0, - }; - - // ── 2. Load assignments in month ── - const assignments = await ctx.db.assignment.findMany({ - where: { - resourceId: input.resourceId, - startDate: { lte: monthEnd }, - endDate: { gte: monthStart }, - status: { in: ["CONFIRMED", "ACTIVE", "PROPOSED"] }, - }, - select: { - id: true, - hoursPerDay: true, - startDate: true, - endDate: true, - dailyCostCents: true, - status: true, - project: { - select: { - id: true, - name: true, - shortCode: true, - budgetCents: true, - winProbability: true, - utilizationCategory: { select: { code: true } }, - }, - }, - }, - }); - - // ── 3. Load absences + holiday context ── - const vacations = await ctx.db.vacation.findMany({ - where: { - resourceId: input.resourceId, - status: VacationStatus.APPROVED, - startDate: { lte: monthEnd }, - endDate: { gte: monthStart }, - }, - select: { startDate: true, endDate: true, type: true, isHalfDay: true }, - }); - const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { - periodStart: monthStart, - periodEnd: monthEnd, - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - }); - const holidayAvailability = collectHolidayAvailability({ - vacations, - periodStart: monthStart, - periodEnd: monthEnd, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityName: resource.metroCity?.name, - resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date), - }); - const publicHolidayStrings = holidayAvailability.publicHolidayStrings; - const absenceDateStrings = holidayAvailability.absenceDateStrings; - const absenceDays = holidayAvailability.absenceDays; - const halfDayCount = absenceDays.filter((absence) => absence.isHalfDay).length; - const vacationDayCount = absenceDays.filter((absence) => absence.type === "VACATION").length; - const sickDayCount = absenceDays.filter((absence) => absence.type === "SICK").length; - const publicHolidayCount = resolvedHolidays.length; - - const contexts = await loadResourceDailyAvailabilityContexts( - ctx.db, - [{ - id: resource.id, - availability: weeklyAvailability, - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - }], - monthStart, - monthEnd, - ); - const availabilityContext = contexts.get(resource.id); - - // ── 4. Load calculation rules ── - let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES; - try { - const dbRules = await ctx.db.calculationRule.findMany({ - where: { isActive: true }, - orderBy: [{ priority: "desc" }], - }); - if (dbRules.length > 0) { - calcRules = dbRules as unknown as CalculationRule[]; - } - } catch { - // table may not exist yet - } - - // ── 5. Calculate SAH / effective capacity ── - const sahResult = calculateSAH({ - dailyWorkingHours: dailyHours, - scheduleRules, - fte: resource.fte, - periodStart: monthStart, - periodEnd: monthEnd, - publicHolidays: publicHolidayStrings, - absenceDays: absenceDateStrings, - }); - const baseWorkingDays = countEffectiveWorkingDays({ - availability: weeklyAvailability, - periodStart: monthStart, - periodEnd: monthEnd, - context: undefined, - }); - const effectiveWorkingDays = countEffectiveWorkingDays({ - availability: weeklyAvailability, - periodStart: monthStart, - periodEnd: monthEnd, - context: availabilityContext, - }); - const baseAvailableHours = calculateEffectiveAvailableHours({ - availability: weeklyAvailability, - periodStart: monthStart, - periodEnd: monthEnd, - context: undefined, - }); - const effectiveAvailableHours = calculateEffectiveAvailableHours({ - availability: weeklyAvailability, - periodStart: monthStart, - periodEnd: monthEnd, - context: availabilityContext, - }); - const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`)); - const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => ( - count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0) - ), 0); - const publicHolidayHoursDeduction = sumAvailabilityHoursForDates( - weeklyAvailability, - publicHolidayDates, - ); - const absenceHoursDeduction = absenceDays.reduce((sum, absence) => { - if (absence.type === "PUBLIC_HOLIDAY") { - return sum; - } - const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date); - return sum + baseHours * (absence.isHalfDay ? 0.5 : 1); - }, 0); - const effectiveHoursPerWorkingDay = effectiveWorkingDays > 0 - ? effectiveAvailableHours / effectiveWorkingDays - : 0; - const holidayScopeSummary = [ - resource.country?.code ?? "—", - resource.federalState ?? "—", - resource.metroCity?.name ?? "—", - ].join(" / "); - const holidayExamples = resolvedHolidays.length > 0 - ? resolvedHolidays.slice(0, 4).map((holiday) => `${holiday.date} ${holiday.name}`).join(", ") - : "none"; - const holidayScopeBreakdown = resolvedHolidays.reduce>((counts, holiday) => { - counts[holiday.scope] = (counts[holiday.scope] ?? 0) + 1; - return counts; - }, {}); - - // ── 6. Calculate allocations + chargeability slices ── - const slices: AssignmentSlice[] = []; - let totalAllocHours = 0; - let totalAllocCostCents = 0; - let totalChargeableHours = 0; - let totalProjectCostCents = 0; - let hasRulesEffect = false; - - for (const a of assignments) { - const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime())); - const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime())); - const categoryCode = a.project.utilizationCategory?.code ?? "Chg"; - - const calcResult = calculateAllocation({ - lcrCents: resource.lcrCents, - hoursPerDay: a.hoursPerDay, - startDate: overlapStart, - endDate: overlapEnd, - availability: weeklyAvailability, - absenceDays, - calculationRules: calcRules, - }); - if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) continue; - - totalAllocHours += calcResult.totalHours; - totalAllocCostCents += calcResult.totalCostCents; - if (calcResult.totalChargeableHours !== undefined) { - totalChargeableHours += calcResult.totalChargeableHours; - totalProjectCostCents += calcResult.totalProjectCostCents ?? calcResult.totalCostCents; - hasRulesEffect = true; - } else { - totalChargeableHours += calcResult.totalHours; - totalProjectCostCents += calcResult.totalCostCents; - } - - slices.push({ - hoursPerDay: a.hoursPerDay, - workingDays: calcResult.workingDays, - categoryCode, - ...(calcResult.totalChargeableHours !== undefined - ? { totalChargeableHours: calcResult.totalChargeableHours } - : {}), - }); - } - - // ── 7. Calculate chargeability forecast ── - const forecast = deriveResourceForecast({ - fte: resource.fte, - targetPercentage: targetPct, - assignments: slices, - sah: effectiveAvailableHours, - }); - - // ── 8. Build budget status for first project with budget ── - const budgetProject = assignments.find((a) => a.project.budgetCents != null && a.project.budgetCents > 0)?.project; - let budgetNodes: GraphNode[] = []; - let budgetLinks: GraphLink[] = []; - if (budgetProject && budgetProject.budgetCents != null) { - // Load all allocations for this project to compute budget - const projectAllocs = await ctx.db.assignment.findMany({ - where: { projectId: budgetProject.id }, - select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true }, - }); - const budgetStatus = computeBudgetStatus( - budgetProject.budgetCents, - budgetProject.winProbability, - projectAllocs.map((pa) => ({ - status: pa.status as unknown as string, - dailyCostCents: pa.dailyCostCents, - startDate: pa.startDate, - endDate: pa.endDate, - hoursPerDay: pa.hoursPerDay, - })) as Parameters[2], - monthStart, - monthEnd, - ); - - budgetNodes = [ - n("input.budgetCents", "Project Budget", fmtEur(budgetProject.budgetCents), "EUR", "INPUT", `Budget for ${budgetProject.name}`, 0), - n("input.winProbability", "Win Probability", `${budgetProject.winProbability}%`, "%", "INPUT", "Project win probability", 0), - n("budget.confirmedCents", "Confirmed", fmtEur(budgetStatus.confirmedCents), "EUR", "BUDGET", "Sum of CONFIRMED/ACTIVE allocation costs", 2, "Σ(confirmed allocs)"), - n("budget.proposedCents", "Proposed", fmtEur(budgetStatus.proposedCents), "EUR", "BUDGET", "Sum of PROPOSED allocation costs", 2, "Σ(proposed allocs)"), - n("budget.allocatedCents", "Allocated", fmtEur(budgetStatus.allocatedCents), "EUR", "BUDGET", "Total allocated budget", 2, "confirmed + proposed"), - n("budget.remainingCents", "Remaining", fmtEur(budgetStatus.remainingCents), "EUR", "BUDGET", "Remaining budget", 3, "budget - allocated"), - n("budget.utilizationPct", "Utilization", `${budgetStatus.utilizationPercent.toFixed(1)}%`, "%", "BUDGET", "Budget utilization percentage", 3, "allocated / budget × 100"), - n("budget.weightedCents", "Win-Weighted", fmtEur(budgetStatus.winProbabilityWeightedCents), "EUR", "BUDGET", "Win-probability-weighted cost", 3, "allocated × winProb / 100"), - ]; - budgetLinks = [ - l("alloc.totalCostCents", "budget.confirmedCents", "per assignment", 1), - l("budget.confirmedCents", "budget.allocatedCents", "+", 2), - l("budget.proposedCents", "budget.allocatedCents", "+", 2), - l("input.budgetCents", "budget.remainingCents", "−", 2), - l("budget.allocatedCents", "budget.remainingCents", "−", 2), - l("budget.allocatedCents", "budget.utilizationPct", "÷ budget × 100", 2), - l("input.budgetCents", "budget.utilizationPct", "÷", 1), - l("budget.allocatedCents", "budget.weightedCents", "× winProb / 100", 1), - l("input.winProbability", "budget.weightedCents", "×", 1), - ]; - } - - // ── 9. Build graph nodes + links ── - - const dailyCostCents = assignments.length > 0 - ? Math.round(assignments[0]!.hoursPerDay * resource.lcrCents) - : 0; - const avgHoursPerDay = assignments.length > 0 - ? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length - : 0; - const totalWorkingDaysInMonth = assignments.reduce((sum, a) => { - const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime())); - const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime())); - const calcResult = calculateAllocation({ - lcrCents: resource.lcrCents, - hoursPerDay: a.hoursPerDay, - startDate: overlapStart, - endDate: overlapEnd, - availability: weeklyAvailability, - absenceDays, - calculationRules: calcRules, - }); - return sum + calcResult.workingDays; - }, 0); - - // Format weekly availability for display - const weekdayLabels = ["Mo", "Tu", "We", "Th", "Fr"]; - const weekdayValues = [weeklyAvailability.monday, weeklyAvailability.tuesday, weeklyAvailability.wednesday, weeklyAvailability.thursday, weeklyAvailability.friday]; - const weeklyTotalHours = weekdayValues.reduce((s, v) => s + v, 0); - const allSame = weekdayValues.every((v) => v === weekdayValues[0]); - const availabilityLabel = allSame - ? `${weekdayValues[0]}h/day` - : weekdayLabels.map((d, i) => `${d}:${weekdayValues[i]}`).join(" "); - - // Derived utilization ratio - const utilizationPct = effectiveAvailableHours > 0 - ? (totalAllocHours / effectiveAvailableHours) * 100 - : 0; - const chargeableHours = forecast.chg * effectiveAvailableHours; - - // Has schedule rules (Spain variable hours)? - const hasScheduleRules = !!scheduleRules; - - const nodes: GraphNode[] = [ - // INPUT - n("input.fte", "FTE", fmtNum(resource.fte, 2), "ratio", "INPUT", `Resource FTE factor`, 0), - n("input.country", "Country", resource.country?.name ?? resource.country?.code ?? "—", "text", "INPUT", "Country used for base working-time and national holiday rules", 0), - n("input.state", "State", resource.federalState ?? "—", "text", "INPUT", "Federal state / region used for regional holidays", 0), - n("input.city", "City", resource.metroCity?.name ?? "—", "text", "INPUT", "City / metro used for local holidays", 0), - n("input.holidayContext", "Holiday Context", holidayScopeSummary, "text", "INPUT", "Resolved holiday scope chain: country / state / city", 0), - n("input.holidayExamples", "Holiday Dates", holidayExamples, "text", "INPUT", `Resolved holidays in ${input.month}; scopes: COUNTRY ${holidayScopeBreakdown.COUNTRY ?? 0}, STATE ${holidayScopeBreakdown.STATE ?? 0}, CITY ${holidayScopeBreakdown.CITY ?? 0}`, 0), - n("input.dailyHours", "Country Hours", `${dailyHours} h`, "hours", "INPUT", `Base daily working hours (${resource.country?.code ?? "?"})`, 0), - ...(hasScheduleRules ? [ - n("input.scheduleRules", "Schedule Rules", "Spain", "—", "INPUT", "Variable daily hours (regular/friday/summer)", 0), - ] : []), - n("input.weeklyAvail", "Weekly Avail.", `${weeklyTotalHours}h`, "h/week", "INPUT", `Resource availability: ${availabilityLabel}`, 0), - n("input.lcrCents", "LCR", fmtEur(resource.lcrCents), "cents/h", "INPUT", "Loaded Cost Rate per hour", 0), - n("input.hoursPerDay", "Hours/Day", fmtNum(avgHoursPerDay), "hours", "INPUT", "Average hours/day across assignments", 0), - n("input.absences", "Absences", `${absenceDays.length}`, "count", "INPUT", `Absence days in ${input.month} (${vacationDayCount} vacation, ${sickDayCount} sick${halfDayCount > 0 ? `, ${halfDayCount} half-day` : ""})`, 0), - n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Resolved holidays in ${input.month}; ${publicHolidayWorkdayCount} hit configured working days`, 0), - n("input.calcRules", "Active Rules", `${calcRules.length}`, "count", "INPUT", "Active calculation rules", 0), - n("input.targetPct", "Target", fmtPct(targetPct), "%", "INPUT", `Chargeability target (${resource.managementLevelGroup?.name ?? "legacy"})`, 0), - n("input.assignmentCount", "Assignments", `${assignments.length}`, "count", "INPUT", `Active assignments in ${input.month}`, 0), - - // SAH - n("sah.calendarDays", "Calendar Days", `${sahResult.calendarDays}`, "days", "SAH", "Total calendar days in period", 1), - n("sah.weekendDays", "Weekend Days", `${sahResult.weekendDays}`, "days", "SAH", "Saturday + Sunday count", 1), - n("sah.grossWorkingDays", "Gross Work Days", `${baseWorkingDays}`, "days", "SAH", "Working days from the resource-specific weekly availability before holidays/absences", 1, "count(availability > 0)"), - n("sah.baseHours", "Base Hours", fmtNum(baseAvailableHours), "hours", "SAH", "Available hours from weekly availability before holiday/absence deductions", 1, "Σ(daily availability)"), - n("sah.publicHolidayDays", "Holiday Ded.", `${publicHolidayWorkdayCount}`, "days", "SAH", "Holiday workdays deducted after applying country/state/city scope and weekday availability", 1), - n("sah.publicHolidayHours", "Holiday Hrs Ded.", fmtNum(publicHolidayHoursDeduction), "hours", "SAH", "Hours removed by resolved public holidays", 1, "Σ(availability on holiday dates)"), - n("sah.absenceDays", "Absence Ded.", `${absenceDateStrings.length}`, "days", "SAH", "Vacation/sick days that hit working days and are not already public holidays", 1), - n("sah.absenceHours", "Absence Hrs Ded.", fmtNum(absenceHoursDeduction), "hours", "SAH", "Hours removed by vacation/sick absences", 1, "Σ(availability × absence fraction)"), - n("sah.netWorkingDays", "Net Work Days", `${effectiveWorkingDays}`, "days", "SAH", "Remaining working days after holiday and absence deductions", 2, "gross - holidays - absences"), - n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(effectiveHoursPerWorkingDay), "hours", "SAH", "Average effective hours per remaining working day", 2, "SAH / net work days"), - n("sah.sah", "SAH", fmtNum(effectiveAvailableHours), "hours", "SAH", "Effective available hours after weekly availability, local holidays and absences", 2, "base hours - holiday hours - absence hours"), - - // ALLOCATION - n("alloc.workingDays", "Work Days", `${totalWorkingDaysInMonth}`, "days", "ALLOCATION", "Working days covered by assignments in period", 1, "Σ(overlap workdays)"), - n("alloc.totalHours", "Total Hours", fmtNum(totalAllocHours), "hours", "ALLOCATION", "Sum of effective hours across assignments", 2, "Σ(min(h/day, avail) × workdays)"), - n("alloc.dailyCostCents", "Daily Cost", fmtEur(dailyCostCents), "EUR", "ALLOCATION", "Cost per working day", 1, "hoursPerDay × LCR"), - n("alloc.totalCostCents", "Total Cost", fmtEur(totalAllocCostCents), "EUR", "ALLOCATION", "Sum of daily costs", 2, "Σ(dailyCost × workdays)"), - n("alloc.utilizationPct", "Utilization", `${utilizationPct.toFixed(1)}%`, "%", "ALLOCATION", "Allocation utilization: allocated hours / SAH", 3, "totalHours / SAH × 100"), - ...(hasRulesEffect ? [ - n("alloc.chargeableHours", "Chargeable Hrs", fmtNum(totalChargeableHours), "hours", "ALLOCATION", "Rules-adjusted chargeable hours", 2, "rules-adjusted"), - n("alloc.projectCostCents", "Project Cost", fmtEur(totalProjectCostCents), "EUR", "ALLOCATION", "Rules-adjusted project cost", 2, "rules-adjusted"), - ] : []), - - // RULES (only if absences exist) - ...(absenceDays.length > 0 ? [ - n("rules.activeRules", "Matched Rules", `${calcRules.length} rules`, "—", "RULES", "Rules evaluated for absence days", 1), - n("rules.costEffect", "Cost Effect", hasRulesEffect ? "ZERO" : "—", "—", "RULES", "How absent days affect project cost", 1, "CHARGE / ZERO / REDUCE"), - n("rules.chgEffect", "Chg Effect", hasRulesEffect ? "COUNT" : "—", "—", "RULES", "How absent days affect chargeability", 1, "COUNT / SKIP"), - ...(hasRulesEffect ? [ - n("rules.costReduction", "Cost Reduction", "per rule", "—", "RULES", "Cost reduction percentage applied to absent hours", 2, "normalCost × (100 - reductionPct) / 100"), - ] : []), - ] : []), - - // CHARGEABILITY — full breakdown from deriveResourceForecast - n("chg.chgHours", "Chg Hours", fmtNum(chargeableHours), "hours", "CHARGEABILITY", "Total chargeable hours against effective SAH", 2, "chargeability × SAH"), - n("chg.chg", "Chargeability", fmtPct(forecast.chg), "%", "CHARGEABILITY", "Chargeability ratio", 3, "chgHours / SAH"), - ...(forecast.bd > 0 ? [ - n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * effectiveAvailableHours)}h`, 3, "bdHours / SAH"), - ] : []), - ...(forecast.mdi > 0 ? [ - n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * effectiveAvailableHours)}h`, 3, "mdiHours / SAH"), - ] : []), - ...(forecast.mo > 0 ? [ - n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * effectiveAvailableHours)}h`, 3, "moHours / SAH"), - ] : []), - ...(forecast.pdr > 0 ? [ - n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * effectiveAvailableHours)}h`, 3, "pdrHours / SAH"), - ] : []), - ...(forecast.absence > 0 ? [ - n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * effectiveAvailableHours)}h`, 3, "absenceHours / SAH"), - ] : []), - n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * effectiveAvailableHours)}h of ${fmtNum(effectiveAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"), - n("chg.target", "Target", fmtPct(targetPct), "%", "CHARGEABILITY", "Chargeability target from management level", 3), - n("chg.gap", "Gap to Target", `${forecast.chg - targetPct >= 0 ? "+" : ""}${((forecast.chg - targetPct) * 100).toFixed(1)} pp`, "pp", "CHARGEABILITY", `Chargeability (${fmtPct(forecast.chg)}) vs. target (${fmtPct(targetPct)})`, 3, "chargeability − target"), - - // Budget nodes (conditionally added above) - ...budgetNodes, - ]; - - const links: GraphLink[] = [ - // INPUT → SAH - l("input.country", "input.holidayContext", "holiday base", 1), - l("input.state", "input.holidayContext", "regional scope", 1), - l("input.city", "input.holidayContext", "local scope", 1), - l("input.holidayContext", "input.holidayExamples", "resolve holidays", 1), - l("input.dailyHours", "sah.grossWorkingDays", "base hours", 1), - l("input.weeklyAvail", "sah.grossWorkingDays", "working-day pattern", 2), - l("input.weeklyAvail", "sah.baseHours", "sum by weekday", 2), - l("input.holidayExamples", "sah.publicHolidayDays", "resolved dates", 2), - l("input.holidayExamples", "sah.publicHolidayHours", "remove matching day hours", 2), - l("input.absences", "sah.absenceHours", "remove absence fractions", 1), - ...(hasScheduleRules ? [ - l("input.scheduleRules", "sah.effectiveHoursPerDay", "variable h/day", 1), - ] : []), - l("sah.calendarDays", "sah.grossWorkingDays", "− weekends", 2), - l("sah.weekendDays", "sah.grossWorkingDays", "−", 1), - l("input.publicHolidays", "sah.publicHolidayDays", "∩ workdays", 1), - l("input.absences", "sah.absenceDays", "∩ workdays", 1), - l("sah.grossWorkingDays", "sah.netWorkingDays", "− holiday/absence days", 2), - l("sah.publicHolidayDays", "sah.netWorkingDays", "−", 1), - l("sah.absenceDays", "sah.netWorkingDays", "−", 1), - l("sah.baseHours", "sah.sah", "start from base capacity", 2), - l("sah.publicHolidayHours", "sah.sah", "− holiday hours", 2), - l("sah.absenceHours", "sah.sah", "− absence hours", 2), - l("sah.sah", "sah.effectiveHoursPerDay", "÷", 1), - l("sah.netWorkingDays", "sah.effectiveHoursPerDay", "÷", 1), - - // INPUT → ALLOCATION - l("input.weeklyAvail", "alloc.totalHours", "caps h/day", 2), - l("input.hoursPerDay", "alloc.dailyCostCents", "×", 1), - l("input.lcrCents", "alloc.dailyCostCents", "× LCR", 2), - l("input.hoursPerDay", "alloc.workingDays", "per assignment", 1), - l("input.assignmentCount", "alloc.workingDays", "× overlap", 1), - l("alloc.workingDays", "alloc.totalHours", "× h/day", 2), - l("input.hoursPerDay", "alloc.totalHours", "× workdays", 1), - l("alloc.dailyCostCents", "alloc.totalCostCents", "× workdays", 2), - l("alloc.workingDays", "alloc.totalCostCents", "×", 1), - l("alloc.totalHours", "alloc.utilizationPct", "÷ SAH × 100", 2), - l("sah.sah", "alloc.utilizationPct", "÷", 1), - - // RULES → ALLOCATION (if absences) - ...(absenceDays.length > 0 ? [ - l("input.calcRules", "rules.activeRules", "filter active", 1), - l("input.absences", "rules.activeRules", "match trigger", 1), - l("rules.activeRules", "rules.costEffect", "→ effect", 1), - l("rules.activeRules", "rules.chgEffect", "→ effect", 1), - ] : []), - - ...(hasRulesEffect ? [ - l("rules.costEffect", "alloc.projectCostCents", "apply", 2), - l("alloc.totalCostCents", "alloc.projectCostCents", "adjust", 1), - l("rules.chgEffect", "alloc.chargeableHours", "apply", 2), - l("alloc.totalHours", "alloc.chargeableHours", "adjust", 1), - ...(absenceDays.length > 0 ? [ - l("rules.costEffect", "rules.costReduction", "reduce %", 1), - ] : []), - ] : []), - - // ALLOCATION + SAH → CHARGEABILITY - l(hasRulesEffect ? "alloc.chargeableHours" : "alloc.totalHours", "chg.chgHours", "Σ Chg", 2), - l("chg.chgHours", "chg.chg", "÷ SAH", 2), - l("sah.sah", "chg.chg", "÷", 2), - ...(forecast.bd > 0 ? [l("sah.sah", "chg.bd", "÷", 1)] : []), - ...(forecast.mdi > 0 ? [l("sah.sah", "chg.mdi", "÷", 1)] : []), - ...(forecast.mo > 0 ? [l("sah.sah", "chg.mo", "÷", 1)] : []), - ...(forecast.pdr > 0 ? [l("sah.sah", "chg.pdr", "÷", 1)] : []), - ...(forecast.absence > 0 ? [l("sah.sah", "chg.absence", "÷", 1)] : []), - l("sah.sah", "chg.unassigned", "− assigned ÷ SAH", 1), - l("chg.chgHours", "chg.unassigned", "SAH − Σ", 1), - l("input.targetPct", "chg.target", "=", 1), - l("chg.chg", "chg.gap", "−", 2), - l("chg.target", "chg.gap", "−", 1), - - // Budget links (conditionally added above) - ...budgetLinks, - ]; - - return { - nodes, - links, - meta: { - resourceName: resource.displayName, - resourceEid: resource.eid, - month: input.month, - assignmentCount: assignments.length, - countryCode: resource.country?.code ?? null, - countryName: resource.country?.name ?? null, - federalState: resource.federalState ?? null, - metroCityName: resource.metroCity?.name ?? null, - resolvedHolidays: resolvedHolidays.map((holiday) => ({ - date: holiday.date, - name: holiday.name, - scope: holiday.scope, - calendarName: holiday.calendarName, - })), - factors: { - weeklyAvailability, - baseWorkingDays, - effectiveWorkingDays, - baseAvailableHours, - effectiveAvailableHours, - publicHolidayCount, - publicHolidayWorkdayCount, - publicHolidayHoursDeduction, - absenceDayCount: absenceDateStrings.length, - absenceHoursDeduction, - chargeableHours, - utilizationPct, - }, - }, - }; }), /** * Project View: Estimate, Commercial, Experience, Effort, Spread, Budget */ getProjectData: controllerProcedure - .input(z.object({ - projectId: z.string(), - })) + .input(projectGraphInputSchema) + .query(({ ctx, input }) => readProjectGraphSnapshot(ctx, input)), + + getProjectDataDetail: controllerProcedure + .input(projectGraphDetailInputSchema) .query(async ({ ctx, input }) => { - const project = await ctx.db.project.findUniqueOrThrow({ - where: { id: input.projectId }, + const graph = await readProjectGraphSnapshot(ctx, input); + return formatProjectGraphDetail({ + projectId: input.projectId, + graph, + ...(input.domain ? { domain: input.domain } : {}), + ...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}), + }); + }), +}); + +async function readResourceGraphSnapshot( + ctx: { db: TRPCContext["db"] }, + input: z.infer, +) { + const [year, month] = input.month.split("-").map(Number) as [number, number]; + const { start: monthStart, end: monthEnd } = getMonthRange(year, month); + + const resource = await ctx.db.resource.findUniqueOrThrow({ + where: { id: input.resourceId }, + select: { + id: true, + displayName: true, + eid: true, + fte: true, + lcrCents: true, + chargeabilityTarget: true, + countryId: true, + federalState: true, + metroCityId: true, + availability: true, + country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } }, + metroCity: { select: { id: true, name: true } }, + managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } }, + }, + }); + + const dailyHours = resource.country?.dailyWorkingHours ?? 8; + const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null; + const targetPct = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100); + + const avail = resource.availability as WeekdayAvailability | null; + const weeklyAvailability: WeekdayAvailability = avail ?? { + monday: dailyHours, tuesday: dailyHours, wednesday: dailyHours, + thursday: dailyHours, friday: dailyHours, saturday: 0, sunday: 0, + }; + + const assignments = await ctx.db.assignment.findMany({ + where: { + resourceId: input.resourceId, + startDate: { lte: monthEnd }, + endDate: { gte: monthStart }, + status: { in: ["CONFIRMED", "ACTIVE", "PROPOSED"] }, + }, + select: { + id: true, + hoursPerDay: true, + startDate: true, + endDate: true, + dailyCostCents: true, + status: true, + project: { select: { id: true, name: true, shortCode: true, budgetCents: true, winProbability: true, - startDate: true, - endDate: true, + utilizationCategory: { select: { code: true } }, }, - }); + }, + }, + }); - // Load latest estimate version with demand lines + scope items - const estimate = await ctx.db.estimate.findFirst({ - where: { projectId: input.projectId }, + const vacations = await ctx.db.vacation.findMany({ + where: { + resourceId: input.resourceId, + status: VacationStatus.APPROVED, + startDate: { lte: monthEnd }, + endDate: { gte: monthStart }, + }, + select: { startDate: true, endDate: true, type: true, isHalfDay: true }, + }); + const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { + periodStart: monthStart, + periodEnd: monthEnd, + countryId: resource.countryId, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name, + }); + const holidayAvailability = collectHolidayAvailability({ + vacations, + periodStart: monthStart, + periodEnd: monthEnd, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityName: resource.metroCity?.name, + resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date), + }); + const publicHolidayStrings = holidayAvailability.publicHolidayStrings; + const absenceDateStrings = holidayAvailability.absenceDateStrings; + const absenceDays = holidayAvailability.absenceDays; + const halfDayCount = absenceDays.filter((absence) => absence.isHalfDay).length; + const vacationDayCount = absenceDays.filter((absence) => absence.type === "VACATION").length; + const sickDayCount = absenceDays.filter((absence) => absence.type === "SICK").length; + const publicHolidayCount = resolvedHolidays.length; + const absenceDayEquivalent = absenceDays.reduce((sum, absence) => { + if (absence.type === "PUBLIC_HOLIDAY") { + return sum; + } + return sum + (absence.isHalfDay ? 0.5 : 1); + }, 0); + + const contexts = await loadResourceDailyAvailabilityContexts( + ctx.db, + [{ + id: resource.id, + availability: weeklyAvailability, + countryId: resource.countryId, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name, + }], + monthStart, + monthEnd, + ); + const availabilityContext = contexts.get(resource.id); + + let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES; + try { + const dbRules = await ctx.db.calculationRule.findMany({ + where: { isActive: true }, + orderBy: [{ priority: "desc" }], + }); + if (dbRules.length > 0) { + calcRules = dbRules as unknown as CalculationRule[]; + } + } catch { + // table may not exist yet + } + + const sahResult = calculateSAH({ + dailyWorkingHours: dailyHours, + scheduleRules, + fte: resource.fte, + periodStart: monthStart, + periodEnd: monthEnd, + publicHolidays: publicHolidayStrings, + absenceDays: absenceDateStrings, + }); + const baseWorkingDays = countEffectiveWorkingDays({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: undefined, + }); + const effectiveWorkingDays = countEffectiveWorkingDays({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: availabilityContext, + }); + const baseAvailableHours = calculateEffectiveAvailableHours({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: undefined, + }); + const effectiveAvailableHours = calculateEffectiveAvailableHours({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: availabilityContext, + }); + const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`)); + const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => ( + count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0) + ), 0); + const publicHolidayHoursDeduction = sumAvailabilityHoursForDates( + weeklyAvailability, + publicHolidayDates, + ); + const absenceHoursDeduction = absenceDays.reduce((sum, absence) => { + if (absence.type === "PUBLIC_HOLIDAY") { + return sum; + } + const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date); + return sum + baseHours * (absence.isHalfDay ? 0.5 : 1); + }, 0); + const effectiveHoursPerWorkingDay = effectiveWorkingDays > 0 + ? effectiveAvailableHours / effectiveWorkingDays + : 0; + const holidayScopeSummary = [ + resource.country?.code ?? "—", + resource.federalState ?? "—", + resource.metroCity?.name ?? "—", + ].join(" / "); + const holidayExamples = resolvedHolidays.length > 0 + ? resolvedHolidays.slice(0, 4).map((holiday) => `${holiday.date} ${holiday.name}`).join(", ") + : "none"; + const holidayScopeBreakdown = resolvedHolidays.reduce>((counts, holiday) => { + counts[holiday.scope] = (counts[holiday.scope] ?? 0) + 1; + return counts; + }, {}); + + const slices: AssignmentSlice[] = []; + const assignmentBreakdown: Array<{ + id: string; + projectId: string; + projectName: string; + projectCode: string; + status: string; + bookedHours: number; + }> = []; + let totalAllocHours = 0; + let totalAllocCostCents = 0; + let totalChargeableHours = 0; + let totalProjectCostCents = 0; + let hasRulesEffect = false; + + for (const a of assignments) { + const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime())); + const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime())); + const categoryCode = a.project.utilizationCategory?.code ?? "Chg"; + + const calcResult = calculateAllocation({ + lcrCents: resource.lcrCents, + hoursPerDay: a.hoursPerDay, + startDate: overlapStart, + endDate: overlapEnd, + availability: weeklyAvailability, + absenceDays, + calculationRules: calcRules, + }); + if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) continue; + + totalAllocHours += calcResult.totalHours; + totalAllocCostCents += calcResult.totalCostCents; + assignmentBreakdown.push({ + id: a.id, + projectId: a.project.id, + projectName: a.project.name, + projectCode: a.project.shortCode, + status: a.status, + bookedHours: calcResult.totalHours, + }); + if (calcResult.totalChargeableHours !== undefined) { + totalChargeableHours += calcResult.totalChargeableHours; + totalProjectCostCents += calcResult.totalProjectCostCents ?? calcResult.totalCostCents; + hasRulesEffect = true; + } else { + totalChargeableHours += calcResult.totalHours; + totalProjectCostCents += calcResult.totalCostCents; + } + + slices.push({ + hoursPerDay: a.hoursPerDay, + workingDays: calcResult.workingDays, + categoryCode, + ...(calcResult.totalChargeableHours !== undefined + ? { totalChargeableHours: calcResult.totalChargeableHours } + : {}), + }); + } + + const forecast = deriveResourceForecast({ + fte: resource.fte, + targetPercentage: targetPct, + assignments: slices, + sah: effectiveAvailableHours, + }); + + const budgetProject = assignments.find((a) => a.project.budgetCents != null && a.project.budgetCents > 0)?.project; + let budgetNodes: GraphNode[] = []; + let budgetLinks: GraphLink[] = []; + if (budgetProject && budgetProject.budgetCents != null) { + const projectAllocs = await ctx.db.assignment.findMany({ + where: { projectId: budgetProject.id }, + select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true }, + }); + const budgetStatus = computeBudgetStatus( + budgetProject.budgetCents, + budgetProject.winProbability, + projectAllocs.map((pa) => ({ + status: pa.status as unknown as string, + dailyCostCents: pa.dailyCostCents, + startDate: pa.startDate, + endDate: pa.endDate, + hoursPerDay: pa.hoursPerDay, + })) as Parameters[2], + monthStart, + monthEnd, + ); + + budgetNodes = [ + n("input.budgetCents", "Project Budget", fmtEur(budgetProject.budgetCents), "EUR", "INPUT", `Budget for ${budgetProject.name}`, 0), + n("input.winProbability", "Win Probability", `${budgetProject.winProbability}%`, "%", "INPUT", "Project win probability", 0), + n("budget.confirmedCents", "Confirmed", fmtEur(budgetStatus.confirmedCents), "EUR", "BUDGET", "Sum of CONFIRMED/ACTIVE allocation costs", 2, "Σ(confirmed allocs)"), + n("budget.proposedCents", "Proposed", fmtEur(budgetStatus.proposedCents), "EUR", "BUDGET", "Sum of PROPOSED allocation costs", 2, "Σ(proposed allocs)"), + n("budget.allocatedCents", "Allocated", fmtEur(budgetStatus.allocatedCents), "EUR", "BUDGET", "Total allocated budget", 2, "confirmed + proposed"), + n("budget.remainingCents", "Remaining", fmtEur(budgetStatus.remainingCents), "EUR", "BUDGET", "Remaining budget", 3, "budget - allocated"), + n("budget.utilizationPct", "Utilization", `${budgetStatus.utilizationPercent.toFixed(1)}%`, "%", "BUDGET", "Budget utilization percentage", 3, "allocated / budget × 100"), + n("budget.weightedCents", "Win-Weighted", fmtEur(budgetStatus.winProbabilityWeightedCents), "EUR", "BUDGET", "Win-probability-weighted cost", 3, "allocated × winProb / 100"), + ]; + budgetLinks = [ + l("alloc.totalCostCents", "budget.confirmedCents", "per assignment", 1), + l("budget.confirmedCents", "budget.allocatedCents", "+", 2), + l("budget.proposedCents", "budget.allocatedCents", "+", 2), + l("input.budgetCents", "budget.remainingCents", "−", 2), + l("budget.allocatedCents", "budget.remainingCents", "−", 2), + l("budget.allocatedCents", "budget.utilizationPct", "÷ budget × 100", 2), + l("input.budgetCents", "budget.utilizationPct", "÷", 1), + l("budget.allocatedCents", "budget.weightedCents", "× winProb / 100", 1), + l("input.winProbability", "budget.weightedCents", "×", 1), + ]; + } + + const dailyCostCents = assignments.length > 0 + ? Math.round(assignments[0]!.hoursPerDay * resource.lcrCents) + : 0; + const avgHoursPerDay = assignments.length > 0 + ? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length + : 0; + const totalWorkingDaysInMonth = assignments.reduce((sum, a) => { + const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime())); + const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime())); + const calcResult = calculateAllocation({ + lcrCents: resource.lcrCents, + hoursPerDay: a.hoursPerDay, + startDate: overlapStart, + endDate: overlapEnd, + availability: weeklyAvailability, + absenceDays, + calculationRules: calcRules, + }); + return sum + calcResult.workingDays; + }, 0); + + const weekdayLabels = ["Mo", "Tu", "We", "Th", "Fr"]; + const weekdayValues = [weeklyAvailability.monday, weeklyAvailability.tuesday, weeklyAvailability.wednesday, weeklyAvailability.thursday, weeklyAvailability.friday]; + const weeklyTotalHours = weekdayValues.reduce((s, v) => s + v, 0); + const allSame = weekdayValues.every((v) => v === weekdayValues[0]); + const availabilityLabel = allSame + ? `${weekdayValues[0]}h/day` + : weekdayLabels.map((d, i) => `${d}:${weekdayValues[i]}`).join(" "); + + const utilizationPct = effectiveAvailableHours > 0 + ? (totalAllocHours / effectiveAvailableHours) * 100 + : 0; + const chargeableHours = forecast.chg * effectiveAvailableHours; + const hasScheduleRules = !!scheduleRules; + + const nodes: GraphNode[] = [ + n("input.fte", "FTE", fmtNum(resource.fte, 2), "ratio", "INPUT", "Resource FTE factor", 0), + n("input.country", "Country", resource.country?.name ?? resource.country?.code ?? "—", "text", "INPUT", "Country used for base working-time and national holiday rules", 0), + n("input.state", "State", resource.federalState ?? "—", "text", "INPUT", "Federal state / region used for regional holidays", 0), + n("input.city", "City", resource.metroCity?.name ?? "—", "text", "INPUT", "City / metro used for local holidays", 0), + n("input.holidayContext", "Holiday Context", holidayScopeSummary, "text", "INPUT", "Resolved holiday scope chain: country / state / city", 0), + n("input.holidayExamples", "Holiday Dates", holidayExamples, "text", "INPUT", `Resolved holidays in ${input.month}; scopes: COUNTRY ${holidayScopeBreakdown.COUNTRY ?? 0}, STATE ${holidayScopeBreakdown.STATE ?? 0}, CITY ${holidayScopeBreakdown.CITY ?? 0}`, 0), + n("input.dailyHours", "Country Hours", `${dailyHours} h`, "hours", "INPUT", `Base daily working hours (${resource.country?.code ?? "?"})`, 0), + ...(hasScheduleRules ? [ + n("input.scheduleRules", "Schedule Rules", "Spain", "—", "INPUT", "Variable daily hours (regular/friday/summer)", 0), + ] : []), + n("input.weeklyAvail", "Weekly Avail.", `${weeklyTotalHours}h`, "h/week", "INPUT", `Resource availability: ${availabilityLabel}`, 0), + n("input.lcrCents", "LCR", fmtEur(resource.lcrCents), "cents/h", "INPUT", "Loaded Cost Rate per hour", 0), + n("input.hoursPerDay", "Hours/Day", fmtNum(avgHoursPerDay), "hours", "INPUT", "Average hours/day across assignments", 0), + n("input.absences", "Absences", `${absenceDays.length}`, "count", "INPUT", `Absence days in ${input.month} (${vacationDayCount} vacation, ${sickDayCount} sick${halfDayCount > 0 ? `, ${halfDayCount} half-day` : ""})`, 0), + n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Resolved holidays in ${input.month}; ${publicHolidayWorkdayCount} hit configured working days`, 0), + n("input.calcRules", "Active Rules", `${calcRules.length}`, "count", "INPUT", "Active calculation rules", 0), + n("input.targetPct", "Target", fmtPct(targetPct), "%", "INPUT", `Chargeability target (${resource.managementLevelGroup?.name ?? "legacy"})`, 0), + n("input.assignmentCount", "Assignments", `${assignments.length}`, "count", "INPUT", `Active assignments in ${input.month}`, 0), + + n("sah.calendarDays", "Calendar Days", `${sahResult.calendarDays}`, "days", "SAH", "Total calendar days in period", 1), + n("sah.weekendDays", "Weekend Days", `${sahResult.weekendDays}`, "days", "SAH", "Saturday + Sunday count", 1), + n("sah.grossWorkingDays", "Gross Work Days", `${baseWorkingDays}`, "days", "SAH", "Working days from the resource-specific weekly availability before holidays/absences", 1, "count(availability > 0)"), + n("sah.baseHours", "Base Hours", fmtNum(baseAvailableHours), "hours", "SAH", "Available hours from weekly availability before holiday/absence deductions", 1, "Σ(daily availability)"), + n("sah.publicHolidayDays", "Holiday Ded.", `${publicHolidayWorkdayCount}`, "days", "SAH", "Holiday workdays deducted after applying country/state/city scope and weekday availability", 1), + n("sah.publicHolidayHours", "Holiday Hrs Ded.", fmtNum(publicHolidayHoursDeduction), "hours", "SAH", "Hours removed by resolved public holidays", 1, "Σ(availability on holiday dates)"), + n("sah.absenceDays", "Absence Ded.", `${absenceDateStrings.length}`, "days", "SAH", "Vacation/sick days that hit working days and are not already public holidays", 1), + n("sah.absenceHours", "Absence Hrs Ded.", fmtNum(absenceHoursDeduction), "hours", "SAH", "Hours removed by vacation/sick absences", 1, "Σ(availability × absence fraction)"), + n("sah.netWorkingDays", "Net Work Days", `${effectiveWorkingDays}`, "days", "SAH", "Remaining working days after holiday and absence deductions", 2, "gross - holidays - absences"), + n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(effectiveHoursPerWorkingDay), "hours", "SAH", "Average effective hours per remaining working day", 2, "SAH / net work days"), + n("sah.sah", "SAH", fmtNum(effectiveAvailableHours), "hours", "SAH", "Effective available hours after weekly availability, local holidays and absences", 2, "base hours - holiday hours - absence hours"), + + n("alloc.workingDays", "Work Days", `${totalWorkingDaysInMonth}`, "days", "ALLOCATION", "Working days covered by assignments in period", 1, "Σ(overlap workdays)"), + n("alloc.totalHours", "Total Hours", fmtNum(totalAllocHours), "hours", "ALLOCATION", "Sum of effective hours across assignments", 2, "Σ(min(h/day, avail) × workdays)"), + n("alloc.dailyCostCents", "Daily Cost", fmtEur(dailyCostCents), "EUR", "ALLOCATION", "Cost per working day", 1, "hoursPerDay × LCR"), + n("alloc.totalCostCents", "Total Cost", fmtEur(totalAllocCostCents), "EUR", "ALLOCATION", "Sum of daily costs", 2, "Σ(dailyCost × workdays)"), + n("alloc.utilizationPct", "Utilization", `${utilizationPct.toFixed(1)}%`, "%", "ALLOCATION", "Allocation utilization: allocated hours / SAH", 3, "totalHours / SAH × 100"), + ...(hasRulesEffect ? [ + n("alloc.chargeableHours", "Chargeable Hrs", fmtNum(totalChargeableHours), "hours", "ALLOCATION", "Rules-adjusted chargeable hours", 2, "rules-adjusted"), + n("alloc.projectCostCents", "Project Cost", fmtEur(totalProjectCostCents), "EUR", "ALLOCATION", "Rules-adjusted project cost", 2, "rules-adjusted"), + ] : []), + + ...(absenceDays.length > 0 ? [ + n("rules.activeRules", "Matched Rules", `${calcRules.length} rules`, "—", "RULES", "Rules evaluated for absence days", 1), + n("rules.costEffect", "Cost Effect", hasRulesEffect ? "ZERO" : "—", "—", "RULES", "How absent days affect project cost", 1, "CHARGE / ZERO / REDUCE"), + n("rules.chgEffect", "Chg Effect", hasRulesEffect ? "COUNT" : "—", "—", "RULES", "How absent days affect chargeability", 1, "COUNT / SKIP"), + ...(hasRulesEffect ? [ + n("rules.costReduction", "Cost Reduction", "per rule", "—", "RULES", "Cost reduction percentage applied to absent hours", 2, "normalCost × (100 - reductionPct) / 100"), + ] : []), + ] : []), + + n("chg.chgHours", "Chg Hours", fmtNum(chargeableHours), "hours", "CHARGEABILITY", "Total chargeable hours against effective SAH", 2, "chargeability × SAH"), + n("chg.chg", "Chargeability", fmtPct(forecast.chg), "%", "CHARGEABILITY", "Chargeability ratio", 3, "chgHours / SAH"), + ...(forecast.bd > 0 ? [ + n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * effectiveAvailableHours)}h`, 3, "bdHours / SAH"), + ] : []), + ...(forecast.mdi > 0 ? [ + n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * effectiveAvailableHours)}h`, 3, "mdiHours / SAH"), + ] : []), + ...(forecast.mo > 0 ? [ + n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * effectiveAvailableHours)}h`, 3, "moHours / SAH"), + ] : []), + ...(forecast.pdr > 0 ? [ + n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * effectiveAvailableHours)}h`, 3, "pdrHours / SAH"), + ] : []), + ...(forecast.absence > 0 ? [ + n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * effectiveAvailableHours)}h`, 3, "absenceHours / SAH"), + ] : []), + n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * effectiveAvailableHours)}h of ${fmtNum(effectiveAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"), + n("chg.target", "Target", fmtPct(targetPct), "%", "CHARGEABILITY", "Chargeability target from management level", 3), + n("chg.gap", "Gap to Target", `${forecast.chg - targetPct >= 0 ? "+" : ""}${((forecast.chg - targetPct) * 100).toFixed(1)} pp`, "pp", "CHARGEABILITY", `Chargeability (${fmtPct(forecast.chg)}) vs. target (${fmtPct(targetPct)})`, 3, "chargeability − target"), + + ...budgetNodes, + ]; + + const links: GraphLink[] = [ + l("input.country", "input.holidayContext", "holiday base", 1), + l("input.state", "input.holidayContext", "regional scope", 1), + l("input.city", "input.holidayContext", "local scope", 1), + l("input.holidayContext", "input.holidayExamples", "resolve holidays", 1), + l("input.dailyHours", "sah.grossWorkingDays", "base hours", 1), + l("input.weeklyAvail", "sah.grossWorkingDays", "working-day pattern", 2), + l("input.weeklyAvail", "sah.baseHours", "sum by weekday", 2), + l("input.holidayExamples", "sah.publicHolidayDays", "resolved dates", 2), + l("input.holidayExamples", "sah.publicHolidayHours", "remove matching day hours", 2), + l("input.absences", "sah.absenceHours", "remove absence fractions", 1), + ...(hasScheduleRules ? [ + l("input.scheduleRules", "sah.effectiveHoursPerDay", "variable h/day", 1), + ] : []), + l("sah.calendarDays", "sah.grossWorkingDays", "− weekends", 2), + l("sah.weekendDays", "sah.grossWorkingDays", "−", 1), + l("input.publicHolidays", "sah.publicHolidayDays", "∩ workdays", 1), + l("input.absences", "sah.absenceDays", "∩ workdays", 1), + l("sah.grossWorkingDays", "sah.netWorkingDays", "− holiday/absence days", 2), + l("sah.publicHolidayDays", "sah.netWorkingDays", "−", 1), + l("sah.absenceDays", "sah.netWorkingDays", "−", 1), + l("sah.baseHours", "sah.sah", "start from base capacity", 2), + l("sah.publicHolidayHours", "sah.sah", "− holiday hours", 2), + l("sah.absenceHours", "sah.sah", "− absence hours", 2), + l("sah.sah", "sah.effectiveHoursPerDay", "÷", 1), + l("sah.netWorkingDays", "sah.effectiveHoursPerDay", "÷", 1), + + l("input.weeklyAvail", "alloc.totalHours", "caps h/day", 2), + l("input.hoursPerDay", "alloc.dailyCostCents", "×", 1), + l("input.lcrCents", "alloc.dailyCostCents", "× LCR", 2), + l("input.hoursPerDay", "alloc.workingDays", "per assignment", 1), + l("input.assignmentCount", "alloc.workingDays", "× overlap", 1), + l("alloc.workingDays", "alloc.totalHours", "× h/day", 2), + l("input.hoursPerDay", "alloc.totalHours", "× workdays", 1), + l("alloc.dailyCostCents", "alloc.totalCostCents", "× workdays", 2), + l("alloc.workingDays", "alloc.totalCostCents", "×", 1), + l("alloc.totalHours", "alloc.utilizationPct", "÷ SAH × 100", 2), + l("sah.sah", "alloc.utilizationPct", "÷", 1), + + ...(absenceDays.length > 0 ? [ + l("input.calcRules", "rules.activeRules", "filter active", 1), + l("input.absences", "rules.activeRules", "match trigger", 1), + l("rules.activeRules", "rules.costEffect", "→ effect", 1), + l("rules.activeRules", "rules.chgEffect", "→ effect", 1), + ] : []), + + ...(hasRulesEffect ? [ + l("rules.costEffect", "alloc.projectCostCents", "apply", 2), + l("alloc.totalCostCents", "alloc.projectCostCents", "adjust", 1), + l("rules.chgEffect", "alloc.chargeableHours", "apply", 2), + l("alloc.totalHours", "alloc.chargeableHours", "adjust", 1), + ...(absenceDays.length > 0 ? [ + l("rules.costEffect", "rules.costReduction", "reduce %", 1), + ] : []), + ] : []), + + l(hasRulesEffect ? "alloc.chargeableHours" : "alloc.totalHours", "chg.chgHours", "Σ Chg", 2), + l("chg.chgHours", "chg.chg", "÷ SAH", 2), + l("sah.sah", "chg.chg", "÷", 2), + ...(forecast.bd > 0 ? [l("sah.sah", "chg.bd", "÷", 1)] : []), + ...(forecast.mdi > 0 ? [l("sah.sah", "chg.mdi", "÷", 1)] : []), + ...(forecast.mo > 0 ? [l("sah.sah", "chg.mo", "÷", 1)] : []), + ...(forecast.pdr > 0 ? [l("sah.sah", "chg.pdr", "÷", 1)] : []), + ...(forecast.absence > 0 ? [l("sah.sah", "chg.absence", "÷", 1)] : []), + l("sah.sah", "chg.unassigned", "− assigned ÷ SAH", 1), + l("chg.chgHours", "chg.unassigned", "SAH − Σ", 1), + l("input.targetPct", "chg.target", "=", 1), + l("chg.chg", "chg.gap", "−", 2), + l("chg.target", "chg.gap", "−", 1), + + ...budgetLinks, + ]; + + return { + nodes, + links, + meta: { + resourceName: resource.displayName, + resourceEid: resource.eid, + month: input.month, + assignmentCount: assignments.length, + countryCode: resource.country?.code ?? null, + countryName: resource.country?.name ?? null, + federalState: resource.federalState ?? null, + metroCityName: resource.metroCity?.name ?? null, + resolvedHolidays: resolvedHolidays.map((holiday) => ({ + date: holiday.date, + name: holiday.name, + scope: holiday.scope, + calendarName: holiday.calendarName, + sourceType: holiday.sourceType, + })), + factors: { + fte: resource.fte, + targetPct: targetPct * 100, + weeklyAvailability, + baseWorkingDays, + effectiveWorkingDays, + baseAvailableHours, + effectiveAvailableHours, + publicHolidayCount, + publicHolidayWorkdayCount, + publicHolidayHoursDeduction, + absenceDayCount: absenceDateStrings.length, + absenceDayEquivalent, + absenceHoursDeduction, + bookedHours: totalAllocHours, + chargeableHours, + chargeabilityPct: forecast.chg * 100, + utilizationPct, + }, + assignments: assignmentBreakdown.map((assignment) => ({ + ...assignment, + bookedHours: Number(assignment.bookedHours.toFixed(1)), + })), + }, + }; +} + +async function readProjectGraphSnapshot( + ctx: { db: TRPCContext["db"] }, + input: z.infer, +) { + const project = await ctx.db.project.findUniqueOrThrow({ + where: { id: input.projectId }, + select: { + id: true, + name: true, + shortCode: true, + budgetCents: true, + winProbability: true, + startDate: true, + endDate: true, + }, + }); + + const estimate = await ctx.db.estimate.findFirst({ + where: { projectId: input.projectId }, + select: { + id: true, + versions: { + orderBy: { versionNumber: "desc" }, + take: 1, select: { id: true, - versions: { - orderBy: { versionNumber: "desc" }, - take: 1, + commercialTerms: true, + demandLines: { select: { id: true, - commercialTerms: true, - demandLines: { - select: { - id: true, - hours: true, - costRateCents: true, - billRateCents: true, - costTotalCents: true, - priceTotalCents: true, - chapter: true, - monthlySpread: true, - scopeItemId: true, - resourceId: true, - }, - }, - scopeItems: { - select: { - id: true, - name: true, - scopeType: true, - frameCount: true, - itemCount: true, - unitMode: true, - }, - }, - resourceSnapshots: { - select: { - id: true, - resourceId: true, - displayName: true, - chapter: true, - lcrCents: true, - ucrCents: true, - location: true, - level: true, - }, - }, + hours: true, + costRateCents: true, + billRateCents: true, + costTotalCents: true, + priceTotalCents: true, + chapter: true, + monthlySpread: true, + scopeItemId: true, + resourceId: true, + }, + }, + scopeItems: { + select: { + id: true, + name: true, + scopeType: true, + frameCount: true, + itemCount: true, + unitMode: true, + }, + }, + resourceSnapshots: { + select: { + id: true, + resourceId: true, + displayName: true, + chapter: true, + lcrCents: true, + ucrCents: true, + location: true, + level: true, }, }, }, - orderBy: { updatedAt: "desc" }, - }); - const latestVersion = estimate?.versions[0]; + }, + }, + orderBy: { updatedAt: "desc" }, + }); + const latestVersion = estimate?.versions[0]; - // Load effort rule sets and experience multiplier sets for this project - let effortRuleCount = 0; - let experienceRuleCount = 0; - try { - effortRuleCount = await ctx.db.effortRule.count(); - experienceRuleCount = await ctx.db.experienceMultiplierRule.count(); - } catch { - // tables may not exist yet - } + let effortRuleCount = 0; + let experienceRuleCount = 0; + try { + effortRuleCount = await ctx.db.effortRule.count(); + experienceRuleCount = await ctx.db.experienceMultiplierRule.count(); + } catch { + // tables may not exist yet + } - const nodes: GraphNode[] = []; - const links: GraphLink[] = []; + const nodes: GraphNode[] = []; + const links: GraphLink[] = []; + + const hasBudget = project.budgetCents > 0; + const hasDateRange = !!(project.startDate && project.endDate); + nodes.push( + n("input.budgetCents", "Project Budget", hasBudget ? fmtEur(project.budgetCents) : "Not set", hasBudget ? "EUR" : "—", "INPUT", hasBudget ? `Budget for ${project.name}` : `No budget defined for ${project.name}`, 0), + n("input.winProbability", "Win Probability", `${project.winProbability}%`, "%", "INPUT", "Project win probability", 0), + ...(hasDateRange ? [ + n("input.projectStart", "Project Start", project.startDate!.toISOString().slice(0, 10), "date", "INPUT", "Project start date", 0), + n("input.projectEnd", "Project End", project.endDate!.toISOString().slice(0, 10), "date", "INPUT", "Project end date", 0), + ] : []), + ); + + if (latestVersion && latestVersion.demandLines.length > 0) { + const lines = latestVersion.demandLines; + const summary = summarizeEstimateDemandLines(lines); + const { totalHours, totalCostCents, totalPriceCents, marginCents, marginPercent: marginPct } = summary; + + const avgCostRate = totalHours > 0 ? Math.round(totalCostCents / totalHours) : 0; + const avgBillRate = totalHours > 0 ? Math.round(totalPriceCents / totalHours) : 0; + + const chapterMap = new Map(); + for (const dl of lines) { + const ch = dl.chapter ?? "(none)"; + chapterMap.set(ch, (chapterMap.get(ch) ?? 0) + dl.hours); + } + const chapterCount = chapterMap.size; + + const snapshotCount = latestVersion.resourceSnapshots?.length ?? 0; + + nodes.push( + n("input.estLines", "Demand Lines", `${lines.length}`, "count", "INPUT", "Estimate demand line count", 0), + n("input.avgCostRate", "Avg Cost Rate", fmtEur(avgCostRate), "cents/h", "INPUT", "Average cost rate across demand lines", 0), + n("input.avgBillRate", "Avg Bill Rate", fmtEur(avgBillRate), "cents/h", "INPUT", "Average bill rate across demand lines", 0), + ...(snapshotCount > 0 ? [ + n("input.resourceSnapshots", "Res. Snapshots", `${snapshotCount}`, "count", "INPUT", "Resource rate snapshots frozen in estimate version", 0), + ] : []), + n("est.totalHours", "Est. Hours", fmtNum(totalHours), "hours", "ESTIMATE", "Total estimated hours", 2, "Σ(line.hours)"), + n("est.totalCostCents", "Est. Cost", fmtEur(totalCostCents), "EUR", "ESTIMATE", "Total estimated cost", 2, "Σ(hours × costRate)"), + n("est.totalPriceCents", "Est. Price", fmtEur(totalPriceCents), "EUR", "ESTIMATE", "Total estimated price", 2, "Σ(hours × billRate)"), + n("est.marginCents", "Margin", fmtEur(marginCents), "EUR", "ESTIMATE", "Price minus cost", 3, "price - cost"), + n("est.marginPercent", "Margin %", `${marginPct.toFixed(1)}%`, "%", "ESTIMATE", "Margin as percentage of price", 3, "margin / price × 100"), + ...(chapterCount > 1 ? [ + n("est.chapters", "Chapters", `${chapterCount}`, "count", "ESTIMATE", `Demand lines grouped by ${chapterCount} chapters`, 1), + ] : []), + ); + + links.push( + l("input.estLines", "est.totalHours", "Σ hours", 1), + l("input.avgCostRate", "est.totalCostCents", "× hours", 2), + l("est.totalHours", "est.totalCostCents", "× costRate", 2), + l("input.avgBillRate", "est.totalPriceCents", "× hours", 2), + l("est.totalHours", "est.totalPriceCents", "× billRate", 2), + l("est.totalPriceCents", "est.marginCents", "−", 2), + l("est.totalCostCents", "est.marginCents", "−", 2), + l("est.marginCents", "est.marginPercent", "÷ price × 100", 2), + l("est.totalPriceCents", "est.marginPercent", "÷", 1), + ...(snapshotCount > 0 ? [ + l("input.resourceSnapshots", "input.avgCostRate", "LCR snapshot", 1), + l("input.resourceSnapshots", "input.avgBillRate", "UCR snapshot", 1), + ] : []), + ...(chapterCount > 1 ? [ + l("input.estLines", "est.chapters", "group by", 1), + l("est.chapters", "est.totalHours", "Σ per chapter", 1), + ] : []), + ); + + const scopeItems = latestVersion.scopeItems ?? []; + if (scopeItems.length > 0) { + const totalFrameCount = scopeItems.reduce((s, si) => s + (si.frameCount ?? 0), 0); + const totalItemCount = scopeItems.reduce((s, si) => s + (si.itemCount ?? 0), 0); + const scopeTypes = new Set(scopeItems.map((si) => si.scopeType)); - // Budget + project inputs - const hasBudget = project.budgetCents > 0; - const hasDateRange = !!(project.startDate && project.endDate); nodes.push( - n("input.budgetCents", "Project Budget", hasBudget ? fmtEur(project.budgetCents) : "Not set", hasBudget ? "EUR" : "—", "INPUT", hasBudget ? `Budget for ${project.name}` : `No budget defined for ${project.name}`, 0), - n("input.winProbability", "Win Probability", `${project.winProbability}%`, "%", "INPUT", "Project win probability", 0), - ...(hasDateRange ? [ - n("input.projectStart", "Project Start", project.startDate!.toISOString().slice(0, 10), "date", "INPUT", "Project start date", 0), - n("input.projectEnd", "Project End", project.endDate!.toISOString().slice(0, 10), "date", "INPUT", "Project end date", 0), + n("effort.scopeItems", "Scope Items", `${scopeItems.length}`, "count", "EFFORT", `${scopeItems.length} scope items across ${scopeTypes.size} type(s)`, 0), + ...(totalFrameCount > 0 ? [ + n("effort.totalFrames", "Total Frames", `${totalFrameCount}`, "frames", "EFFORT", "Sum of frame counts across scope items", 1), ] : []), + ...(totalItemCount > 0 ? [ + n("effort.totalItems", "Total Items", fmtNum(totalItemCount), "items", "EFFORT", "Sum of item counts across scope items", 1), + ] : []), + n("effort.effortRules", "Effort Rules", `${effortRuleCount}`, "count", "EFFORT", "Configured effort expansion rules (scopeType → discipline)", 0), + n("effort.expandedHours", "Expanded Hours", fmtNum(totalHours), "hours", "EFFORT", "Total hours from scope-to-effort expansion (unitCount × hoursPerUnit)", 2, "Σ(unitCount × hoursPerUnit)"), ); - if (latestVersion && latestVersion.demandLines.length > 0) { - const lines = latestVersion.demandLines; - const summary = summarizeEstimateDemandLines(lines); - const { totalHours, totalCostCents, totalPriceCents, marginCents, marginPercent: marginPct } = summary; + links.push( + l("effort.scopeItems", "effort.expandedHours", "expand", 2), + l("effort.effortRules", "effort.expandedHours", "× hoursPerUnit", 2), + ...(totalFrameCount > 0 ? [ + l("effort.scopeItems", "effort.totalFrames", "Σ frames", 1), + l("effort.totalFrames", "effort.expandedHours", "per_frame", 1), + ] : []), + ...(totalItemCount > 0 ? [ + l("effort.scopeItems", "effort.totalItems", "Σ items", 1), + l("effort.totalItems", "effort.expandedHours", "per_item", 1), + ] : []), + l("effort.expandedHours", "est.totalHours", "→ demand lines", 2), + ); + } - // Average rates - const avgCostRate = totalHours > 0 ? Math.round(totalCostCents / totalHours) : 0; - const avgBillRate = totalHours > 0 ? Math.round(totalPriceCents / totalHours) : 0; + if (experienceRuleCount > 0) { + nodes.push( + n("exp.ruleCount", "Exp. Rules", `${experienceRuleCount}`, "count", "EXPERIENCE", "Experience multiplier rules (chapter/location/level → rate adjustments)", 0), + n("exp.costMultiplier", "Cost Multiplier", "per rule", "×", "EXPERIENCE", "Multiplier applied to cost rate (costRateCents × multiplier)", 1, "costRate × costMultiplier"), + n("exp.billMultiplier", "Bill Multiplier", "per rule", "×", "EXPERIENCE", "Multiplier applied to bill rate (billRateCents × multiplier)", 1, "billRate × billMultiplier"), + n("exp.shoringRatio", "Shoring Ratio", "per rule", "ratio", "EXPERIENCE", "Offshore/nearshore effort factor (onsiteHours + offshoreHours × (1 + additionalEffort))", 2, "onsite + offshore × (1 + addlEffort)"), + n("exp.adjustedRates", "Adjusted Rates", "applied", "—", "EXPERIENCE", "Final cost and bill rates after experience multipliers", 2, "rate × multiplier"), + ); - // Chapters - const chapterMap = new Map(); - for (const dl of lines) { - const ch = dl.chapter ?? "(none)"; - chapterMap.set(ch, (chapterMap.get(ch) ?? 0) + dl.hours); - } - const chapterCount = chapterMap.size; + links.push( + l("exp.ruleCount", "exp.costMultiplier", "match rule", 1), + l("exp.ruleCount", "exp.billMultiplier", "match rule", 1), + l("exp.ruleCount", "exp.shoringRatio", "match rule", 1), + l("exp.costMultiplier", "exp.adjustedRates", "×", 2), + l("exp.billMultiplier", "exp.adjustedRates", "×", 2), + l("exp.shoringRatio", "exp.adjustedRates", "adjust hours", 1), + l("exp.adjustedRates", "est.totalCostCents", "→ costRate", 1), + l("exp.adjustedRates", "est.totalPriceCents", "→ billRate", 1), + ); + } - // Resource snapshots - const snapshotCount = latestVersion.resourceSnapshots?.length ?? 0; + const terms = latestVersion.commercialTerms as { + contingencyPercent?: number; + discountPercent?: number; + pricingModel?: string; + paymentTermDays?: number; + warrantyMonths?: number; + paymentMilestones?: Array<{ label: string; percent: number; dueDate?: string | null }>; + } | null; + const hasCommercialAdjustments = terms && (terms.contingencyPercent || terms.discountPercent); + const hasCommercialMeta = terms && (terms.pricingModel || terms.paymentTermDays || terms.warrantyMonths); + + if (hasCommercialAdjustments) { + const contingencyPct = terms!.contingencyPercent ?? 0; + const discountPct = terms!.discountPercent ?? 0; + const contingencyCents = Math.round(totalCostCents * contingencyPct / 100); + const discountCents = Math.round(totalPriceCents * discountPct / 100); + const adjCost = totalCostCents + contingencyCents; + const adjPrice = totalPriceCents - discountCents; + const adjMargin = adjPrice - adjCost; + const adjMarginPct = adjPrice > 0 ? (adjMargin / adjPrice) * 100 : 0; + + nodes.push( + n("input.contingencyPct", "Contingency %", `${contingencyPct}%`, "%", "INPUT", "Contingency percentage (risk buffer on cost)", 0), + n("input.discountPct", "Discount %", `${discountPct}%`, "%", "INPUT", "Discount percentage (reduction on sell side)", 0), + n("comm.contingencyCents", "Contingency", fmtEur(contingencyCents), "EUR", "COMMERCIAL", "Contingency surcharge", 2, "baseCost × contingency%"), + n("comm.discountCents", "Discount", fmtEur(discountCents), "EUR", "COMMERCIAL", "Discount deduction", 2, "basePrice × discount%"), + n("comm.adjustedCost", "Adj. Cost", fmtEur(adjCost), "EUR", "COMMERCIAL", "Cost plus contingency", 3, "baseCost + contingency"), + n("comm.adjustedPrice", "Adj. Price", fmtEur(adjPrice), "EUR", "COMMERCIAL", "Price minus discount", 3, "basePrice - discount"), + n("comm.adjustedMargin", "Adj. Margin", fmtEur(adjMargin), "EUR", "COMMERCIAL", "Adjusted margin", 3, "adjPrice - adjCost"), + n("comm.adjustedMarginPct", "Adj. Margin %", `${adjMarginPct.toFixed(1)}%`, "%", "COMMERCIAL", "Adjusted margin percentage", 3, "adjMargin / adjPrice × 100"), + ); + + links.push( + l("est.totalCostCents", "comm.contingencyCents", "×", 1), + l("input.contingencyPct", "comm.contingencyCents", "× %", 1), + l("est.totalPriceCents", "comm.discountCents", "×", 1), + l("input.discountPct", "comm.discountCents", "× %", 1), + l("est.totalCostCents", "comm.adjustedCost", "+", 2), + l("comm.contingencyCents", "comm.adjustedCost", "+", 2), + l("est.totalPriceCents", "comm.adjustedPrice", "−", 2), + l("comm.discountCents", "comm.adjustedPrice", "−", 2), + l("comm.adjustedPrice", "comm.adjustedMargin", "−", 2), + l("comm.adjustedCost", "comm.adjustedMargin", "−", 2), + l("comm.adjustedMargin", "comm.adjustedMarginPct", "÷ price × 100", 2), + l("comm.adjustedPrice", "comm.adjustedMarginPct", "÷", 1), + ); + } + + if (hasCommercialMeta || (terms?.paymentMilestones && terms.paymentMilestones.length > 0)) { + if (terms!.pricingModel) { nodes.push( - n("input.estLines", "Demand Lines", `${lines.length}`, "count", "INPUT", "Estimate demand line count", 0), - n("input.avgCostRate", "Avg Cost Rate", fmtEur(avgCostRate), "cents/h", "INPUT", "Average cost rate across demand lines", 0), - n("input.avgBillRate", "Avg Bill Rate", fmtEur(avgBillRate), "cents/h", "INPUT", "Average bill rate across demand lines", 0), - ...(snapshotCount > 0 ? [ - n("input.resourceSnapshots", "Res. Snapshots", `${snapshotCount}`, "count", "INPUT", "Resource rate snapshots frozen in estimate version", 0), - ] : []), - n("est.totalHours", "Est. Hours", fmtNum(totalHours), "hours", "ESTIMATE", "Total estimated hours", 2, "Σ(line.hours)"), - n("est.totalCostCents", "Est. Cost", fmtEur(totalCostCents), "EUR", "ESTIMATE", "Total estimated cost", 2, "Σ(hours × costRate)"), - n("est.totalPriceCents", "Est. Price", fmtEur(totalPriceCents), "EUR", "ESTIMATE", "Total estimated price", 2, "Σ(hours × billRate)"), - n("est.marginCents", "Margin", fmtEur(marginCents), "EUR", "ESTIMATE", "Price minus cost", 3, "price - cost"), - n("est.marginPercent", "Margin %", `${marginPct.toFixed(1)}%`, "%", "ESTIMATE", "Margin as percentage of price", 3, "margin / price × 100"), - ...(chapterCount > 1 ? [ - n("est.chapters", "Chapters", `${chapterCount}`, "count", "ESTIMATE", `Demand lines grouped by ${chapterCount} chapters`, 1), - ] : []), + n("comm.pricingModel", "Pricing Model", terms!.pricingModel.replace(/_/g, " "), "—", "COMMERCIAL", `Pricing model: ${terms!.pricingModel}`, 0), ); - - links.push( - l("input.estLines", "est.totalHours", "Σ hours", 1), - l("input.avgCostRate", "est.totalCostCents", "× hours", 2), - l("est.totalHours", "est.totalCostCents", "× costRate", 2), - l("input.avgBillRate", "est.totalPriceCents", "× hours", 2), - l("est.totalHours", "est.totalPriceCents", "× billRate", 2), - l("est.totalPriceCents", "est.marginCents", "−", 2), - l("est.totalCostCents", "est.marginCents", "−", 2), - l("est.marginCents", "est.marginPercent", "÷ price × 100", 2), - l("est.totalPriceCents", "est.marginPercent", "÷", 1), - ...(snapshotCount > 0 ? [ - l("input.resourceSnapshots", "input.avgCostRate", "LCR snapshot", 1), - l("input.resourceSnapshots", "input.avgBillRate", "UCR snapshot", 1), - ] : []), - ...(chapterCount > 1 ? [ - l("input.estLines", "est.chapters", "group by", 1), - l("est.chapters", "est.totalHours", "Σ per chapter", 1), - ] : []), - ); - - // ── EFFORT domain: scope items → demand line expansion ── - const scopeItems = latestVersion.scopeItems ?? []; - if (scopeItems.length > 0) { - const totalFrameCount = scopeItems.reduce((s, si) => s + (si.frameCount ?? 0), 0); - const totalItemCount = scopeItems.reduce((s, si) => s + (si.itemCount ?? 0), 0); - const scopeTypes = new Set(scopeItems.map((si) => si.scopeType)); - - nodes.push( - n("effort.scopeItems", "Scope Items", `${scopeItems.length}`, "count", "EFFORT", `${scopeItems.length} scope items across ${scopeTypes.size} type(s)`, 0), - ...(totalFrameCount > 0 ? [ - n("effort.totalFrames", "Total Frames", `${totalFrameCount}`, "frames", "EFFORT", "Sum of frame counts across scope items", 1), - ] : []), - ...(totalItemCount > 0 ? [ - n("effort.totalItems", "Total Items", fmtNum(totalItemCount), "items", "EFFORT", "Sum of item counts across scope items", 1), - ] : []), - n("effort.effortRules", "Effort Rules", `${effortRuleCount}`, "count", "EFFORT", "Configured effort expansion rules (scopeType → discipline)", 0), - n("effort.expandedHours", "Expanded Hours", fmtNum(totalHours), "hours", "EFFORT", "Total hours from scope-to-effort expansion (unitCount × hoursPerUnit)", 2, "Σ(unitCount × hoursPerUnit)"), - ); - - links.push( - l("effort.scopeItems", "effort.expandedHours", "expand", 2), - l("effort.effortRules", "effort.expandedHours", "× hoursPerUnit", 2), - ...(totalFrameCount > 0 ? [ - l("effort.scopeItems", "effort.totalFrames", "Σ frames", 1), - l("effort.totalFrames", "effort.expandedHours", "per_frame", 1), - ] : []), - ...(totalItemCount > 0 ? [ - l("effort.scopeItems", "effort.totalItems", "Σ items", 1), - l("effort.totalItems", "effort.expandedHours", "per_item", 1), - ] : []), - l("effort.expandedHours", "est.totalHours", "→ demand lines", 2), - ); - } - - // ── EXPERIENCE domain: multiplier rules ── - if (experienceRuleCount > 0) { - nodes.push( - n("exp.ruleCount", "Exp. Rules", `${experienceRuleCount}`, "count", "EXPERIENCE", "Experience multiplier rules (chapter/location/level → rate adjustments)", 0), - n("exp.costMultiplier", "Cost Multiplier", "per rule", "×", "EXPERIENCE", "Multiplier applied to cost rate (costRateCents × multiplier)", 1, "costRate × costMultiplier"), - n("exp.billMultiplier", "Bill Multiplier", "per rule", "×", "EXPERIENCE", "Multiplier applied to bill rate (billRateCents × multiplier)", 1, "billRate × billMultiplier"), - n("exp.shoringRatio", "Shoring Ratio", "per rule", "ratio", "EXPERIENCE", "Offshore/nearshore effort factor (onsiteHours + offshoreHours × (1 + additionalEffort))", 2, "onsite + offshore × (1 + addlEffort)"), - n("exp.adjustedRates", "Adjusted Rates", "applied", "—", "EXPERIENCE", "Final cost and bill rates after experience multipliers", 2, "rate × multiplier"), - ); - - links.push( - l("exp.ruleCount", "exp.costMultiplier", "match rule", 1), - l("exp.ruleCount", "exp.billMultiplier", "match rule", 1), - l("exp.ruleCount", "exp.shoringRatio", "match rule", 1), - l("exp.costMultiplier", "exp.adjustedRates", "×", 2), - l("exp.billMultiplier", "exp.adjustedRates", "×", 2), - l("exp.shoringRatio", "exp.adjustedRates", "adjust hours", 1), - l("exp.adjustedRates", "est.totalCostCents", "→ costRate", 1), - l("exp.adjustedRates", "est.totalPriceCents", "→ billRate", 1), - ); - } - - // ── COMMERCIAL terms (enhanced) ── - const terms = latestVersion.commercialTerms as { - contingencyPercent?: number; - discountPercent?: number; - pricingModel?: string; - paymentTermDays?: number; - warrantyMonths?: number; - paymentMilestones?: Array<{ label: string; percent: number; dueDate?: string | null }>; - } | null; - - const hasCommercialAdjustments = terms && (terms.contingencyPercent || terms.discountPercent); - const hasCommercialMeta = terms && (terms.pricingModel || terms.paymentTermDays || terms.warrantyMonths); - - if (hasCommercialAdjustments) { - const contingencyPct = terms!.contingencyPercent ?? 0; - const discountPct = terms!.discountPercent ?? 0; - const contingencyCents = Math.round(totalCostCents * contingencyPct / 100); - const discountCents = Math.round(totalPriceCents * discountPct / 100); - const adjCost = totalCostCents + contingencyCents; - const adjPrice = totalPriceCents - discountCents; - const adjMargin = adjPrice - adjCost; - const adjMarginPct = adjPrice > 0 ? (adjMargin / adjPrice) * 100 : 0; - - nodes.push( - n("input.contingencyPct", "Contingency %", `${contingencyPct}%`, "%", "INPUT", "Contingency percentage (risk buffer on cost)", 0), - n("input.discountPct", "Discount %", `${discountPct}%`, "%", "INPUT", "Discount percentage (reduction on sell side)", 0), - n("comm.contingencyCents", "Contingency", fmtEur(contingencyCents), "EUR", "COMMERCIAL", "Contingency surcharge", 2, "baseCost × contingency%"), - n("comm.discountCents", "Discount", fmtEur(discountCents), "EUR", "COMMERCIAL", "Discount deduction", 2, "basePrice × discount%"), - n("comm.adjustedCost", "Adj. Cost", fmtEur(adjCost), "EUR", "COMMERCIAL", "Cost plus contingency", 3, "baseCost + contingency"), - n("comm.adjustedPrice", "Adj. Price", fmtEur(adjPrice), "EUR", "COMMERCIAL", "Price minus discount", 3, "basePrice - discount"), - n("comm.adjustedMargin", "Adj. Margin", fmtEur(adjMargin), "EUR", "COMMERCIAL", "Adjusted margin", 3, "adjPrice - adjCost"), - n("comm.adjustedMarginPct", "Adj. Margin %", `${adjMarginPct.toFixed(1)}%`, "%", "COMMERCIAL", "Adjusted margin percentage", 3, "adjMargin / adjPrice × 100"), - ); - - links.push( - l("est.totalCostCents", "comm.contingencyCents", "×", 1), - l("input.contingencyPct", "comm.contingencyCents", "× %", 1), - l("est.totalPriceCents", "comm.discountCents", "×", 1), - l("input.discountPct", "comm.discountCents", "× %", 1), - l("est.totalCostCents", "comm.adjustedCost", "+", 2), - l("comm.contingencyCents", "comm.adjustedCost", "+", 2), - l("est.totalPriceCents", "comm.adjustedPrice", "−", 2), - l("comm.discountCents", "comm.adjustedPrice", "−", 2), - l("comm.adjustedPrice", "comm.adjustedMargin", "−", 2), - l("comm.adjustedCost", "comm.adjustedMargin", "−", 2), - l("comm.adjustedMargin", "comm.adjustedMarginPct", "÷ price × 100", 2), - l("comm.adjustedPrice", "comm.adjustedMarginPct", "÷", 1), - ); - } - - // Commercial metadata nodes (pricingModel, payment terms, milestones) - if (hasCommercialMeta || (terms?.paymentMilestones && terms.paymentMilestones.length > 0)) { - if (terms!.pricingModel) { - nodes.push( - n("comm.pricingModel", "Pricing Model", terms!.pricingModel.replace(/_/g, " "), "—", "COMMERCIAL", `Pricing model: ${terms!.pricingModel}`, 0), - ); - } - if (terms!.paymentTermDays) { - nodes.push( - n("comm.paymentTermDays", "Payment Terms", `${terms!.paymentTermDays} days`, "days", "COMMERCIAL", `Net payment terms: ${terms!.paymentTermDays} days`, 0), - ); - } - if (terms!.warrantyMonths) { - nodes.push( - n("comm.warrantyMonths", "Warranty", `${terms!.warrantyMonths} mo`, "months", "COMMERCIAL", `Warranty period: ${terms!.warrantyMonths} months`, 0), - ); - } - const milestones = terms!.paymentMilestones ?? []; - if (milestones.length > 0) { - const effectivePrice = hasCommercialAdjustments - ? totalPriceCents - Math.round(totalPriceCents * (terms!.discountPercent ?? 0) / 100) - : totalPriceCents; - nodes.push( - n("comm.milestones", "Milestones", `${milestones.length}`, "count", "COMMERCIAL", `${milestones.length} payment milestones (${milestones.map((m) => `${m.label}: ${m.percent}%`).join(", ")})`, 2), - n("comm.milestoneTotalPct", "Milestone Sum", `${milestones.reduce((s, m) => s + m.percent, 0).toFixed(0)}%`, "%", "COMMERCIAL", "Sum of milestone percentages (should be 100%)", 2, "Σ(milestone.percent)"), - ); - links.push( - l(hasCommercialAdjustments ? "comm.adjustedPrice" : "est.totalPriceCents", "comm.milestones", "× %", 1), - ); - } - } - - // ── SPREAD domain: monthly + weekly distribution ── - if (hasDateRange) { - // Compute monthly spread from demand lines - const spreadResult = computeEvenSpread({ - totalHours, - startDate: project.startDate!, - endDate: project.endDate!, - }); - const monthCount = spreadResult.months.length; - - // Compute weekly phasing - const weeklyResult = distributeHoursToWeeks({ - totalHours, - startDate: project.startDate!.toISOString().slice(0, 10), - endDate: project.endDate!.toISOString().slice(0, 10), - pattern: "even", - }); - const weekCount = weeklyResult.weeks.length; - - // Check if demand lines have manual monthly spreads - const hasManualSpreads = lines.some((dl) => { - const spread = dl.monthlySpread as Record | null; - return spread && Object.keys(spread).length > 0; - }); - - nodes.push( - n("spread.monthCount", "Months", `${monthCount}`, "count", "SPREAD", `${monthCount} months in project date range`, 1), - n("spread.weekCount", "Weeks", `${weekCount}`, "count", "SPREAD", `${weekCount} ISO weeks in project date range`, 1), - n("spread.monthlySpread", "Monthly Spread", hasManualSpreads ? "manual + even" : "even", "—", "SPREAD", "Hours distributed across months weighted by working days", 2, "hours × (monthWorkDays / totalWorkDays)"), - n("spread.weeklyPhasing", "Weekly Phasing", "even", "—", "SPREAD", "Hours distributed across ISO weeks (even/front/back-loaded)", 2, "totalHours / weekCount"), - n("spread.totalDistributed", "Distributed Hours", fmtNum(weeklyResult.totalDistributedHours), "hours", "SPREAD", "Total hours after weekly distribution (should match estimate)", 3, "Σ(weeklyHours)"), - ); - - links.push( - l("input.projectStart", "spread.monthCount", "→ range", 1), - l("input.projectEnd", "spread.monthCount", "→ range", 1), - l("input.projectStart", "spread.weekCount", "→ range", 1), - l("input.projectEnd", "spread.weekCount", "→ range", 1), - l("est.totalHours", "spread.monthlySpread", "distribute", 2), - l("spread.monthCount", "spread.monthlySpread", "÷ by workdays", 1), - l("est.totalHours", "spread.weeklyPhasing", "distribute", 2), - l("spread.weekCount", "spread.weeklyPhasing", "÷ by weeks", 1), - l("spread.weeklyPhasing", "spread.totalDistributed", "Σ", 2), - ); - } } + if (terms!.paymentTermDays) { + nodes.push( + n("comm.paymentTermDays", "Payment Terms", `${terms!.paymentTermDays} days`, "days", "COMMERCIAL", `Net payment terms: ${terms!.paymentTermDays} days`, 0), + ); + } + if (terms!.warrantyMonths) { + nodes.push( + n("comm.warrantyMonths", "Warranty", `${terms!.warrantyMonths} mo`, "months", "COMMERCIAL", `Warranty period: ${terms!.warrantyMonths} months`, 0), + ); + } + const milestones = terms!.paymentMilestones ?? []; + if (milestones.length > 0) { + nodes.push( + n("comm.milestones", "Milestones", `${milestones.length}`, "count", "COMMERCIAL", `${milestones.length} payment milestones (${milestones.map((m) => `${m.label}: ${m.percent}%`).join(", ")})`, 2), + n("comm.milestoneTotalPct", "Milestone Sum", `${milestones.reduce((s, m) => s + m.percent, 0).toFixed(0)}%`, "%", "COMMERCIAL", "Sum of milestone percentages (should be 100%)", 2, "Σ(milestone.percent)"), + ); + links.push( + l(hasCommercialAdjustments ? "comm.adjustedPrice" : "est.totalPriceCents", "comm.milestones", "× %", 1), + ); + } + } - // ── Budget status ── - const projectAllocs = await ctx.db.assignment.findMany({ - where: { projectId: input.projectId }, - select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true }, + if (hasDateRange) { + const spreadResult = computeEvenSpread({ + totalHours, + startDate: project.startDate!, + endDate: project.endDate!, + }); + const monthCount = spreadResult.months.length; + + const weeklyResult = distributeHoursToWeeks({ + totalHours, + startDate: project.startDate!.toISOString().slice(0, 10), + endDate: project.endDate!.toISOString().slice(0, 10), + pattern: "even", + }); + const weekCount = weeklyResult.weeks.length; + + const hasManualSpreads = lines.some((dl) => { + const spread = dl.monthlySpread as Record | null; + return spread && Object.keys(spread).length > 0; }); - if (projectAllocs.length > 0) { - const budgetStatus = computeBudgetStatus( - project.budgetCents, - project.winProbability, - projectAllocs.map((pa) => ({ - status: pa.status as unknown as string, - dailyCostCents: pa.dailyCostCents, - startDate: pa.startDate, - endDate: pa.endDate, - hoursPerDay: pa.hoursPerDay, - })) as Parameters[2], - project.startDate ?? new Date(), - project.endDate ?? new Date(), - ); + nodes.push( + n("spread.monthCount", "Months", `${monthCount}`, "count", "SPREAD", `${monthCount} months in project date range`, 1), + n("spread.weekCount", "Weeks", `${weekCount}`, "count", "SPREAD", `${weekCount} ISO weeks in project date range`, 1), + n("spread.monthlySpread", "Monthly Spread", hasManualSpreads ? "manual + even" : "even", "—", "SPREAD", "Hours distributed across months weighted by working days", 2, "hours × (monthWorkDays / totalWorkDays)"), + n("spread.weeklyPhasing", "Weekly Phasing", "even", "—", "SPREAD", "Hours distributed across ISO weeks (even/front/back-loaded)", 2, "totalHours / weekCount"), + n("spread.totalDistributed", "Distributed Hours", fmtNum(weeklyResult.totalDistributedHours), "hours", "SPREAD", "Total hours after weekly distribution (should match estimate)", 3, "Σ(weeklyHours)"), + ); - // Total allocated hours for comparison - const totalAllocatedHours = projectAllocs.reduce((sum, pa) => sum + pa.hoursPerDay, 0); + links.push( + l("input.projectStart", "spread.monthCount", "→ range", 1), + l("input.projectEnd", "spread.monthCount", "→ range", 1), + l("input.projectStart", "spread.weekCount", "→ range", 1), + l("input.projectEnd", "spread.weekCount", "→ range", 1), + l("est.totalHours", "spread.monthlySpread", "distribute", 2), + l("spread.monthCount", "spread.monthlySpread", "÷ by workdays", 1), + l("est.totalHours", "spread.weeklyPhasing", "distribute", 2), + l("spread.weekCount", "spread.weeklyPhasing", "÷ by weeks", 1), + l("spread.weeklyPhasing", "spread.totalDistributed", "Σ", 2), + ); + } + } - nodes.push( - n("budget.confirmedCents", "Confirmed", fmtEur(budgetStatus.confirmedCents), "EUR", "BUDGET", "Confirmed allocation costs", 2, "Σ(CONFIRMED allocs)"), - n("budget.proposedCents", "Proposed", fmtEur(budgetStatus.proposedCents), "EUR", "BUDGET", "Proposed allocation costs", 2, "Σ(PROPOSED allocs)"), - n("budget.allocatedCents", "Allocated", fmtEur(budgetStatus.allocatedCents), "EUR", "BUDGET", "Total allocated", 2, "confirmed + proposed"), - n("budget.remainingCents", "Remaining", - hasBudget ? fmtEur(budgetStatus.remainingCents) : "N/A", - hasBudget ? "EUR" : "—", "BUDGET", - hasBudget ? "Remaining budget" : "Cannot compute — no budget set", - 3, hasBudget ? "budget - allocated" : "needs budget"), - n("budget.utilizationPct", "Utilization", - hasBudget ? `${budgetStatus.utilizationPercent.toFixed(1)}%` : "N/A", - hasBudget ? "%" : "—", "BUDGET", - hasBudget ? "Budget utilization" : "Cannot compute — no budget set", - 3, hasBudget ? "allocated / budget × 100" : "needs budget"), - n("budget.weightedCents", "Win-Weighted", fmtEur(budgetStatus.winProbabilityWeightedCents), "EUR", "BUDGET", "Win-weighted cost", 3, "allocated × winProb / 100"), - n("budget.allocCount", "Allocations", `${projectAllocs.length}`, "count", "BUDGET", `${projectAllocs.length} resource allocations on project`, 1), - ); + const projectAllocs = await ctx.db.assignment.findMany({ + where: { projectId: input.projectId }, + select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true }, + }); - links.push( - l("budget.allocCount", "budget.confirmedCents", "Σ confirmed", 1), - l("budget.allocCount", "budget.proposedCents", "Σ proposed", 1), - l("budget.confirmedCents", "budget.allocatedCents", "+", 2), - l("budget.proposedCents", "budget.allocatedCents", "+", 2), - l("input.budgetCents", "budget.remainingCents", "−", 2), - l("budget.allocatedCents", "budget.remainingCents", "−", 2), - l("budget.allocatedCents", "budget.utilizationPct", "÷ budget × 100", 2), - l("input.budgetCents", "budget.utilizationPct", "÷", 1), - l("budget.allocatedCents", "budget.weightedCents", "× winProb / 100", 1), - l("input.winProbability", "budget.weightedCents", "×", 1), - ); + if (projectAllocs.length > 0) { + const budgetStatus = computeBudgetStatus( + project.budgetCents, + project.winProbability, + projectAllocs.map((pa) => ({ + status: pa.status as unknown as string, + dailyCostCents: pa.dailyCostCents, + startDate: pa.startDate, + endDate: pa.endDate, + hoursPerDay: pa.hoursPerDay, + })) as Parameters[2], + project.startDate ?? new Date(), + project.endDate ?? new Date(), + ); - // Estimate vs actual gap (if estimate exists) - if (latestVersion && latestVersion.demandLines.length > 0) { - const estCost = latestVersion.demandLines.reduce((s, dl) => s + dl.costTotalCents, 0); - const gapCents = budgetStatus.allocatedCents - estCost; - nodes.push( - n("budget.estVsActualGap", "Est. vs Actual", fmtEur(Math.abs(gapCents)), "EUR", "BUDGET", - gapCents > 0 - ? `Actual allocations exceed estimate by ${fmtEur(gapCents)}` - : gapCents < 0 - ? `Actual allocations under estimate by ${fmtEur(Math.abs(gapCents))}` - : "Actual allocations match estimate", - 3, "allocated - estCost"), - ); - links.push( - l("budget.allocatedCents", "budget.estVsActualGap", "−", 1), - l("est.totalCostCents", "budget.estVsActualGap", "−", 1), - ); - } - } + nodes.push( + n("budget.confirmedCents", "Confirmed", fmtEur(budgetStatus.confirmedCents), "EUR", "BUDGET", "Confirmed allocation costs", 2, "Σ(CONFIRMED allocs)"), + n("budget.proposedCents", "Proposed", fmtEur(budgetStatus.proposedCents), "EUR", "BUDGET", "Proposed allocation costs", 2, "Σ(PROPOSED allocs)"), + n("budget.allocatedCents", "Allocated", fmtEur(budgetStatus.allocatedCents), "EUR", "BUDGET", "Total allocated", 2, "confirmed + proposed"), + n("budget.remainingCents", "Remaining", + hasBudget ? fmtEur(budgetStatus.remainingCents) : "N/A", + hasBudget ? "EUR" : "—", "BUDGET", + hasBudget ? "Remaining budget" : "Cannot compute — no budget set", + 3, hasBudget ? "budget - allocated" : "needs budget"), + n("budget.utilizationPct", "Utilization", + hasBudget ? `${budgetStatus.utilizationPercent.toFixed(1)}%` : "N/A", + hasBudget ? "%" : "—", "BUDGET", + hasBudget ? "Budget utilization" : "Cannot compute — no budget set", + 3, hasBudget ? "allocated / budget × 100" : "needs budget"), + n("budget.weightedCents", "Win-Weighted", fmtEur(budgetStatus.winProbabilityWeightedCents), "EUR", "BUDGET", "Win-weighted cost", 3, "allocated × winProb / 100"), + n("budget.allocCount", "Allocations", `${projectAllocs.length}`, "count", "BUDGET", `${projectAllocs.length} resource allocations on project`, 1), + ); - return { - nodes, - links, - meta: { - projectName: project.name, - projectCode: project.shortCode, - }, - }; + links.push( + l("budget.allocCount", "budget.confirmedCents", "Σ confirmed", 1), + l("budget.allocCount", "budget.proposedCents", "Σ proposed", 1), + l("budget.confirmedCents", "budget.allocatedCents", "+", 2), + l("budget.proposedCents", "budget.allocatedCents", "+", 2), + l("input.budgetCents", "budget.remainingCents", "−", 2), + l("budget.allocatedCents", "budget.remainingCents", "−", 2), + l("budget.allocatedCents", "budget.utilizationPct", "÷ budget × 100", 2), + l("input.budgetCents", "budget.utilizationPct", "÷", 1), + l("budget.allocatedCents", "budget.weightedCents", "× winProb / 100", 1), + l("input.winProbability", "budget.weightedCents", "×", 1), + ); + + if (latestVersion && latestVersion.demandLines.length > 0) { + const estCost = latestVersion.demandLines.reduce((s, dl) => s + dl.costTotalCents, 0); + const gapCents = budgetStatus.allocatedCents - estCost; + nodes.push( + n("budget.estVsActualGap", "Est. vs Actual", fmtEur(Math.abs(gapCents)), "EUR", "BUDGET", + gapCents > 0 + ? `Actual allocations exceed estimate by ${fmtEur(gapCents)}` + : gapCents < 0 + ? `Actual allocations under estimate by ${fmtEur(Math.abs(gapCents))}` + : "Actual allocations match estimate", + 3, "allocated - estCost"), + ); + links.push( + l("budget.allocatedCents", "budget.estVsActualGap", "−", 1), + l("est.totalCostCents", "budget.estVsActualGap", "−", 1), + ); + } + } + + return { + nodes, + links, + meta: { + projectName: project.name, + projectCode: project.shortCode, + }, + }; +} + +function formatResourceGraphDetail(input: { + resourceId: string; + graph: Awaited>; + domain?: string; + includeLinks?: boolean; +}) { + return { + resource: { + id: input.resourceId, + eid: input.graph.meta.resourceEid, + displayName: input.graph.meta.resourceName, + }, + availableDomains: getAvailableDomains(input.graph.nodes), + totalNodeCount: input.graph.nodes.length, + totalLinkCount: input.graph.links.length, + ...filterGraphData({ + nodes: input.graph.nodes, + links: input.graph.links, + ...(input.domain ? { domain: input.domain } : {}), + ...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}), }), -}); + meta: input.graph.meta, + }; +} + +function formatProjectGraphDetail(input: { + projectId: string; + graph: Awaited>; + domain?: string; + includeLinks?: boolean; +}) { + return { + project: { + id: input.projectId, + shortCode: input.graph.meta.projectCode, + name: input.graph.meta.projectName, + }, + availableDomains: getAvailableDomains(input.graph.nodes), + totalNodeCount: input.graph.nodes.length, + totalLinkCount: input.graph.links.length, + ...filterGraphData({ + nodes: input.graph.nodes, + links: input.graph.links, + ...(input.domain ? { domain: input.domain } : {}), + ...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}), + }), + meta: input.graph.meta, + }; +} diff --git a/packages/api/src/router/country.ts b/packages/api/src/router/country.ts index c56826d..49f8797 100644 --- a/packages/api/src/router/country.ts +++ b/packages/api/src/router/country.ts @@ -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 .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { @@ -46,6 +140,19 @@ export const countryRouter = createTRPCRouter({ 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 .input(CreateCountrySchema) .mutation(async ({ ctx, input }) => { @@ -207,6 +314,6 @@ export const countryRouter = createTRPCRouter({ source: "ui", }); - return { success: true }; + return { success: true, id: city.id, name: city.name }; }), }); diff --git a/packages/api/src/router/dashboard.ts b/packages/api/src/router/dashboard.ts index 8bd65e1..298c6f9 100644 --- a/packages/api/src/router/dashboard.ts +++ b/packages/api/src/router/dashboard.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { createTRPCRouter, protectedProcedure, controllerProcedure } from "../trpc.js"; +import { createTRPCRouter, controllerProcedure } from "../trpc.js"; import { getDashboardChargeabilityOverview, getDashboardDemand, @@ -8,25 +8,182 @@ import { getDashboardTopValueResources, getDashboardBudgetForecast, getDashboardSkillGaps, + getDashboardSkillGapSummary, getDashboardProjectHealth, } from "@capakraken/application"; import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js"; import { cacheGet, cacheSet } from "../lib/cache.js"; +import { fmtEur } from "../lib/format-utils.js"; const DEFAULT_TTL = 60; // seconds -export const dashboardRouter = createTRPCRouter({ - getOverview: protectedProcedure.query(async ({ ctx }) => { - const cacheKey = "overview"; - const cached = await cacheGet>>(cacheKey); - if (cached) return cached; +function round1(value: number): number { + return Math.round(value * 10) / 10; +} - const result = await getDashboardOverview(ctx.db); - await cacheSet(cacheKey, result, DEFAULT_TTL); - return result; +function mapProjectHealthDetailRows(rows: Awaited>) { + const projects = rows + .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>) { + 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>) { + 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[0]) { + const cacheKey = "overview"; + const cached = await cacheGet>>(cacheKey); + if (cached) return cached; + + const result = await getDashboardOverview(db); + await cacheSet(cacheKey, result, DEFAULT_TTL); + return result; +} + +async function getPeakTimesCached( + db: Parameters[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>>(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[0], + input: { startDate: string; endDate: string; groupBy: "project" | "person" | "chapter" }, +) { + const cacheKey = `demand:${input.startDate}:${input.endDate}:${input.groupBy}`; + const cached = await cacheGet>>(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[0], + input: { limit: number; userRole: string }, +) { + const cacheKey = `topValue:${input.limit}:${input.userRole}`; + const cached = await cacheGet>>(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( z.object({ startDate: z.string().datetime(), @@ -36,42 +193,18 @@ export const dashboardRouter = createTRPCRouter({ }), ) .query(async ({ ctx, input }) => { - const cacheKey = `peakTimes:${input.startDate}:${input.endDate}:${input.granularity}:${input.groupBy}`; - const cached = await cacheGet>>(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; + return getPeakTimesCached(ctx.db, input); }), - getTopValueResources: protectedProcedure + getTopValueResources: controllerProcedure .input(z.object({ limit: z.number().int().min(1).max(50).default(10) })) .query(async ({ ctx, input }) => { const userRole = (ctx.session.user as { role?: string } | undefined)?.role ?? "USER"; - const cacheKey = `topValue:${input.limit}:${userRole}`; - const cached = await cacheGet>>(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; + return getTopValueResourcesCached(ctx.db, { limit: input.limit, userRole }); }), - getDemand: protectedProcedure + getDemand: controllerProcedure .input( z.object({ startDate: z.string().datetime(), @@ -80,16 +213,100 @@ export const dashboardRouter = createTRPCRouter({ }), ) .query(async ({ ctx, input }) => { - const cacheKey = `demand:${input.startDate}:${input.endDate}:${input.groupBy}`; - const cached = await cacheGet>>(cacheKey); - if (cached) return cached; + return getDemandCached(ctx.db, input); + }), + + getDetail: controllerProcedure + .input(z.object({ section: z.string().optional().default("all") })) + .query(async ({ ctx, input }) => { + const section = input.section; + const result: Record = {}; + 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; }), @@ -133,7 +350,7 @@ export const dashboardRouter = createTRPCRouter({ return result; }), - getBudgetForecast: protectedProcedure.query(async ({ ctx }) => { + getBudgetForecast: controllerProcedure.query(async ({ ctx }) => { const cacheKey = "budgetForecast"; const cached = await cacheGet>>(cacheKey); if (cached) return cached; @@ -143,7 +360,12 @@ export const dashboardRouter = createTRPCRouter({ 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 cached = await cacheGet>>(cacheKey); if (cached) return cached; @@ -153,7 +375,17 @@ export const dashboardRouter = createTRPCRouter({ return result; }), - getProjectHealth: protectedProcedure.query(async ({ ctx }) => { + getSkillGapSummary: controllerProcedure.query(async ({ ctx }) => { + const cacheKey = "skillGapSummary"; + const cached = await cacheGet>>(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 cached = await cacheGet>>(cacheKey); if (cached) return cached; @@ -162,4 +394,9 @@ export const dashboardRouter = createTRPCRouter({ await cacheSet(cacheKey, result, DEFAULT_TTL); return result; }), + + getProjectHealthDetail: controllerProcedure.query(async ({ ctx }) => { + const projectHealth = await getDashboardProjectHealth(ctx.db); + return mapProjectHealthDetailRows(projectHealth); + }), }); diff --git a/packages/api/src/router/entitlement.ts b/packages/api/src/router/entitlement.ts index 4e43de8..9f6fe71 100644 --- a/packages/api/src/router/entitlement.ts +++ b/packages/api/src/router/entitlement.ts @@ -23,6 +23,167 @@ type EntitlementSnapshot = { 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[0]>[0]["ctx"]; + +async function readBalanceSnapshot( + ctx: Pick, + 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, + 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. */ @@ -163,6 +324,15 @@ export const entitlementRouter = createTRPCRouter({ * Creates the entitlement record if it doesn't exist (with carryover). */ 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( z.object({ resourceId: z.string(), @@ -170,63 +340,20 @@ export const entitlementRouter = createTRPCRouter({ }), ) .query(async ({ ctx, input }) => { - // Ownership check: USER can only query their own balance - 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 balance = await readBalanceSnapshot(ctx, input); + const resource = await ctx.db.resource.findUnique({ + where: { id: input.resourceId }, + select: { displayName: true, eid: true }, + }); + + if (!resource) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Resource not found", + }); } - const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); - 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, - }; + return mapBalanceDetail(resource, balance); }), /** @@ -366,39 +493,25 @@ export const entitlementRouter = createTRPCRouter({ chapter: z.string().optional(), }), ) - .query(async ({ ctx, input }) => { - const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); - const defaultDays = settings?.vacationDefaultDays ?? 28; + .query(async ({ ctx, input }) => readYearSummarySnapshot(ctx, { + year: input.year, + ...(input.chapter ? { chapter: input.chapter } : {}), + })), - 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" }], + getYearSummaryDetail: managerProcedure + .input( + z.object({ + year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()), + chapter: z.string().optional(), + resourceName: z.string().optional(), + }), + ) + .query(async ({ ctx, input }) => { + const summaries = await readYearSummarySnapshot(ctx, { + year: input.year, + ...(input.chapter ? { chapter: input.chapter } : {}), }); - const results = await Promise.all( - 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; + return mapYearSummaryDetail(input.year, summaries, input.resourceName); }), }); diff --git a/packages/api/src/router/estimate.ts b/packages/api/src/router/estimate.ts index e4b1e2f..ca1c80a 100644 --- a/packages/api/src/router/estimate.ts +++ b/packages/api/src/router/estimate.ts @@ -47,6 +47,38 @@ import { } from "../trpc.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( demandLines: z.infer["demandLines"], ) { @@ -235,6 +267,199 @@ export const estimateRouter = createTRPCRouter({ 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>((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>((acc, item) => { + acc[item.scopeType] = (acc[item.scopeType] ?? 0) + 1; + return acc; + }, {}); + + const assumptionCategoryTotals = version.assumptions.reduce>((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 .input(CreateEstimateSchema) .mutation(async ({ ctx, input }) => { @@ -294,15 +519,12 @@ export const estimateRouter = createTRPCRouter({ input, ); } catch (error) { - if (error instanceof Error) { - if ( - error.message === "Source estimate not found" || - error.message === "Source estimate has no versions" - ) { - throw new TRPCError({ code: "NOT_FOUND", message: error.message }); - } - } - throw error; + rethrowEstimateRouterError(error, [ + { + code: "NOT_FOUND", + messages: ["Source estimate not found", "Source estimate has no versions"], + }, + ]); } await ctx.db.auditLog.create({ @@ -360,19 +582,16 @@ export const estimateRouter = createTRPCRouter({ withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"), ); } catch (error) { - if (error instanceof Error && error.message === "Estimate not found") { - throw new TRPCError({ code: "NOT_FOUND", message: error.message }); - } - if ( - error instanceof Error && - error.message === "Estimate has no working version" - ) { - throw new TRPCError({ + rethrowEstimateRouterError(error, [ + { + code: "NOT_FOUND", + messages: ["Estimate not found"], + }, + { code: "PRECONDITION_FAILED", - message: error.message, - }); - } - throw error; + messages: ["Estimate has no working version"], + }, + ]); } await ctx.db.auditLog.create({ @@ -411,24 +630,19 @@ export const estimateRouter = createTRPCRouter({ input, ); } catch (error) { - if (error instanceof Error) { - if ( - error.message === "Estimate not found" || - error.message === "Estimate version not found" - ) { - throw new TRPCError({ code: "NOT_FOUND", message: error.message }); - } - if ( - error.message === "Estimate has no working version" || - error.message === "Only working versions can be submitted" - ) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: error.message, - }); - } - } - throw error; + rethrowEstimateRouterError(error, [ + { + code: "NOT_FOUND", + messages: ["Estimate not found", "Estimate version not found"], + }, + { + code: "PRECONDITION_FAILED", + messages: [ + "Estimate has no working version", + "Only working versions can be submitted", + ], + }, + ]); } await ctx.db.auditLog.create({ @@ -464,24 +678,19 @@ export const estimateRouter = createTRPCRouter({ input, ); } catch (error) { - if (error instanceof Error) { - if ( - error.message === "Estimate not found" || - error.message === "Estimate version not found" - ) { - throw new TRPCError({ code: "NOT_FOUND", message: error.message }); - } - if ( - error.message === "Estimate has no submitted version" || - error.message === "Only submitted versions can be approved" - ) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: error.message, - }); - } - } - throw error; + rethrowEstimateRouterError(error, [ + { + code: "NOT_FOUND", + messages: ["Estimate not found", "Estimate version not found"], + }, + { + code: "PRECONDITION_FAILED", + messages: [ + "Estimate has no submitted version", + "Only submitted versions can be approved", + ], + }, + ]); } await ctx.db.auditLog.create({ @@ -517,25 +726,20 @@ export const estimateRouter = createTRPCRouter({ input, ); } catch (error) { - if (error instanceof Error) { - if ( - error.message === "Estimate not found" || - error.message === "Estimate version not found" - ) { - throw new TRPCError({ code: "NOT_FOUND", message: error.message }); - } - if ( - error.message === "Estimate already has a working version" || - error.message === "Estimate has no locked version to revise" || - error.message === "Source version must be locked before creating a revision" - ) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: error.message, - }); - } - } - throw error; + rethrowEstimateRouterError(error, [ + { + code: "NOT_FOUND", + messages: ["Estimate not found", "Estimate version not found"], + }, + { + code: "PRECONDITION_FAILED", + messages: [ + "Estimate already has a working version", + "Estimate has no locked version to revise", + "Source version must be locked before creating a revision", + ], + }, + ]); } await ctx.db.auditLog.create({ @@ -572,16 +776,16 @@ export const estimateRouter = createTRPCRouter({ input, ); } catch (error) { - if (error instanceof Error) { - if ( - error.message === "Estimate not found" || - error.message === "Estimate version not found" || - error.message === "Estimate has no version to export" - ) { - throw new TRPCError({ code: "NOT_FOUND", message: error.message }); - } - } - throw error; + rethrowEstimateRouterError(error, [ + { + code: "NOT_FOUND", + messages: [ + "Estimate not found", + "Estimate version not found", + "Estimate has no version to export", + ], + }, + ]); } const exportedVersion = input.versionId @@ -620,29 +824,30 @@ export const estimateRouter = createTRPCRouter({ input, ); } catch (error) { - if (error instanceof Error) { - if ( - error.message === "Estimate not found" || - error.message === "Estimate version not found" || - error.message === "Linked project not found" - ) { - throw new TRPCError({ code: "NOT_FOUND", message: error.message }); - } - if ( - error.message === "Estimate has no approved version" || - error.message === "Only approved versions can be handed off to planning" || - error.message === "Estimate must be linked to a project before planning handoff" || - error.message === "Planning handoff already exists for this approved version" || - error.message === "Linked project has an invalid date range" || - error.message.startsWith("Project window has no working days for demand line") - ) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: error.message, - }); - } - } - throw error; + rethrowEstimateRouterError(error, [ + { + code: "NOT_FOUND", + messages: [ + "Estimate not found", + "Estimate version not found", + "Linked project not found", + ], + }, + { + code: "PRECONDITION_FAILED", + messages: [ + "Estimate has no approved version", + "Only approved versions can be handed off to planning", + "Estimate must be linked to a project before planning handoff", + "Planning handoff already exists for this approved version", + "Linked project has an invalid date range", + ], + predicates: [ + (message) => + message.startsWith("Project window has no working days for demand line"), + ], + }, + ]); } await ctx.db.auditLog.create({ diff --git a/packages/api/src/router/holiday-calendar.ts b/packages/api/src/router/holiday-calendar.ts index e0d3fff..b4bc620 100644 --- a/packages/api/src/router/holiday-calendar.ts +++ b/packages/api/src/router/holiday-calendar.ts @@ -14,6 +14,7 @@ import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday import { createTRPCRouter, adminProcedure, protectedProcedure, type TRPCContext } from "../trpc.js"; type HolidayCalendarScope = HolidayCalendarScopeInput; +type HolidayReadContext = Pick; const HOLIDAY_SCOPE = { COUNTRY: "COUNTRY", @@ -49,6 +50,401 @@ function clampDate(date: Date): Date { 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(); + const bySourceType = new Map(); + const byCalendar = new Map(); + + 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, +) { + 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, +) { + 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, +) { + 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( db: HolidayCalendarDb, input: { @@ -153,26 +549,40 @@ async function assertScopeConsistency( export const holidayCalendarRouter = createTRPCRouter({ listCalendars: protectedProcedure - .input(z.object({ includeInactive: z.boolean().optional() }).optional()) - .query(async ({ ctx, input }) => { - const db = asHolidayCalendarDb(ctx.db); - const where = input?.includeInactive ? undefined : { isActive: true }; + .input(z.object({ + includeInactive: z.boolean().optional(), + countryCode: z.string().trim().min(1).optional(), + 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({ - ...(where ? { 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" }, - ], - }); + listCalendarsDetail: protectedProcedure + .input(z.object({ + includeInactive: z.boolean().optional(), + countryCode: z.string().trim().min(1).optional(), + 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 }) => { + const calendars = await readCalendarsSnapshot(ctx, input); + return { + count: calendars.length, + 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 @@ -323,7 +733,7 @@ export const holidayCalendarRouter = createTRPCRouter({ source: "ui", }); - return { success: true }; + return { success: true, id: existing.id, name: existing.name }; }), createEntry: adminProcedure @@ -430,42 +840,61 @@ export const holidayCalendarRouter = createTRPCRouter({ source: "ui", }); - return { success: true }; + return { success: true, id: existing.id, name: existing.name }; }), previewResolvedHolidays: protectedProcedure + .input(PreviewResolvedHolidaysSchema) + .query(async ({ ctx, input }) => (await readPreviewResolvedHolidaysSnapshot(ctx, input)).holidays), + + previewResolvedHolidaysDetail: protectedProcedure .input(PreviewResolvedHolidaysSchema) .query(async ({ ctx, input }) => { - const country = await findUniqueOrThrow( - ctx.db.country.findUnique({ - where: { id: input.countryId }, - select: { code: true }, - }), - "Country", - ); + const resolved = await readPreviewResolvedHolidaysSnapshot(ctx, input); + const holidays = resolved.holidays.map(formatResolvedHolidayDetail); + return { + count: holidays.length, + locationContext: resolved.locationContext, + summary: summarizeResolvedHolidaysDetail(holidays), + holidays, + }; + }), - const metroCity = input.metroCityId - ? await ctx.db.metroCity.findUnique({ - where: { id: input.metroCityId }, - select: { name: true }, - }) - : null; + resolveHolidays: protectedProcedure + .input(ResolveHolidaysInputSchema) + .query(async ({ ctx, input }) => readResolvedHolidaysSnapshot(ctx, input)), - 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, - }); + resolveHolidaysDetail: protectedProcedure + .input(ResolveHolidaysInputSchema) + .query(async ({ ctx, input }) => { + const resolved = await readResolvedHolidaysSnapshot(ctx, input); + const holidays = resolved.holidays.map(formatResolvedHolidayDetail); + return { + periodStart: resolved.periodStart, + periodEnd: resolved.periodEnd, + locationContext: resolved.locationContext, + count: holidays.length, + summary: summarizeResolvedHolidaysDetail(holidays), + holidays, + }; + }), - return resolved.map((holiday) => ({ - date: holiday.date, - name: holiday.name, - scopeType: holiday.scope, - calendarName: holiday.calendarName, - })); + resolveResourceHolidays: protectedProcedure + .input(ResolveResourceHolidaysInputSchema) + .query(async ({ ctx, input }) => readResolvedResourceHolidaysSnapshot(ctx, input)), + + 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, + }; }), }); diff --git a/packages/api/src/router/insights.ts b/packages/api/src/router/insights.ts index 160978a..0db2012 100644 --- a/packages/api/src/router/insights.ts +++ b/packages/api/src/router/insights.ts @@ -13,6 +13,69 @@ export interface Anomaly { 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): Promise; + }; + resource: { + findMany(args: Record): Promise; + }; + assignment: { + findMany(args: Record): Promise; + }; +} + // ─── Helpers ───────────────────────────────────────────────────────────────── /** @@ -29,9 +92,216 @@ function countBusinessDays(start: Date, end: Date): number { 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; +} + +async function loadInsightResources(db: InsightsDbAccess["resource"]) { + return db.findMany({ + where: { isActive: true }, + select: { + id: true, + displayName: true, + availability: true, + }, + }) as Promise; +} + +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; +} + +function summarizeAnomalies(anomalies: Anomaly[]): InsightSnapshot["summary"] { + return anomalies.reduce((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 { + 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(); + 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 | 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 ────────────────────────────────────────────────────────────────── 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. * Caches the result in the project's dynamicFields.aiNarrative to avoid @@ -185,300 +455,16 @@ ${dataContext}`; * No AI involved — pure data analysis. */ detectAnomalies: controllerProcedure.query(async ({ ctx }) => { - const now = new Date(); - const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000); - 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(); - 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 | 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; + const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess); + return snapshot.anomalies; }), /** * Dashboard-friendly summary: anomaly counts by category + total. */ getInsightsSummary: controllerProcedure.query(async ({ ctx }) => { - // Re-use the detectAnomalies logic inline (calling it directly would - // require the full context to be passed through — simpler to share code - // 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(); - 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 | 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, - }; + const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess); + return snapshot.summary; }), /** diff --git a/packages/api/src/router/notification.ts b/packages/api/src/router/notification.ts index 4088e14..9611714 100644 --- a/packages/api/src/router/notification.ts +++ b/packages/api/src/router/notification.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; +import { PermissionKey, parseTaskAction, resolvePermissions } from "@capakraken/shared"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js"; import { @@ -12,6 +13,7 @@ import { import { createNotification } from "../lib/create-notification.js"; import { resolveRecipients } from "../lib/notification-targeting.js"; import { sendEmail } from "../lib/email.js"; +import { getTaskAction } from "../lib/task-actions.js"; // ─── 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 */ updateTaskStatus: protectedProcedure .input( @@ -312,6 +357,101 @@ export const notificationRouter = createTRPCRouter({ 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 // ═══════════════════════════════════════════════════════════════════════════ @@ -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+) // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/api/src/router/org-unit.ts b/packages/api/src/router/org-unit.ts index 68e9850..c839a90 100644 --- a/packages/api/src/router/org-unit.ts +++ b/packages/api/src/router/org-unit.ts @@ -78,6 +78,98 @@ export const orgUnitRouter = createTRPCRouter({ 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 .input(CreateOrgUnitSchema) .mutation(async ({ ctx, input }) => { diff --git a/packages/api/src/router/project.ts b/packages/api/src/router/project.ts index 33e3c73..c0d75e4 100644 --- a/packages/api/src/router/project.ts +++ b/packages/api/src/router/project.ts @@ -16,17 +16,481 @@ import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure import { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js"; import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js"; import { invalidateDashboardCache } from "../lib/cache.js"; +import { logger } from "../lib/logger.js"; import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; import { validateImageDataUrl } from "../lib/image-validation.js"; +import type { TRPCContext } from "../trpc.js"; import { calculateEffectiveBookedHours, loadResourceDailyAvailabilityContexts, } 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 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 = {}, +): 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, +): 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, + 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, + 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, + 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, + 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({ - 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( PaginationInputSchema.extend({ status: z.nativeEnum(ProjectStatus).optional(), @@ -90,7 +554,7 @@ export const projectRouter = createTRPCRouter({ }; }), - getById: protectedProcedure + getById: controllerProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { 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() })) .query(async ({ ctx, input }) => { const project = await ctx.db.project.findUnique({ @@ -241,8 +716,8 @@ export const projectRouter = createTRPCRouter({ }, }); - void invalidateDashboardCache(); - void dispatchWebhooks(ctx.db, "project.created", { + invalidateDashboardCacheInBackground(); + dispatchProjectWebhookInBackground(ctx.db, "project.created", { id: project.id, shortCode: project.shortCode, name: project.name, @@ -302,7 +777,7 @@ export const projectRouter = createTRPCRouter({ }, }); - void invalidateDashboardCache(); + invalidateDashboardCacheInBackground(); return updated; }), @@ -314,8 +789,8 @@ export const projectRouter = createTRPCRouter({ where: { id: input.id }, data: { status: input.status }, }); - void invalidateDashboardCache(); - void dispatchWebhooks(ctx.db, "project.status_changed", { + invalidateDashboardCacheInBackground(); + dispatchProjectWebhookInBackground(ctx.db, "project.status_changed", { id: result.id, shortCode: result.shortCode, name: result.name, @@ -348,7 +823,7 @@ export const projectRouter = createTRPCRouter({ }, }); - void invalidateDashboardCache(); + invalidateDashboardCacheInBackground(); return { count: updated.length }; }), @@ -454,7 +929,7 @@ export const projectRouter = createTRPCRouter({ }); }); - void invalidateDashboardCache(); + invalidateDashboardCacheInBackground(); return { id: input.id, name: project.name }; }), @@ -494,7 +969,7 @@ export const projectRouter = createTRPCRouter({ }); }); - void invalidateDashboardCache(); + invalidateDashboardCacheInBackground(); return { count: projects.length }; }), diff --git a/packages/api/src/router/rate-card.ts b/packages/api/src/router/rate-card.ts index a0dcdbe..0ab0cac 100644 --- a/packages/api/src/router/rate-card.ts +++ b/packages/api/src/router/rate-card.ts @@ -11,6 +11,7 @@ import { findUniqueOrThrow } from "../db/helpers.js"; import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js"; import { ROLE_BRIEF_SELECT } from "../db/selects.js"; import { createAuditEntry } from "../lib/audit.js"; +import { fmtEur } from "../lib/format-utils.js"; const lineSelect = { id: true, @@ -30,6 +31,118 @@ const lineSelect = { updatedAt: true, } as const; +async function lookupBestRateMatch( + db: Pick, + 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({ list: controllerProcedure .input( @@ -92,6 +205,131 @@ export const rateCardRouter = createTRPCRouter({ 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 .input(CreateRateCardSchema) .mutation(async ({ ctx, input }) => { @@ -362,7 +600,7 @@ export const rateCardRouter = createTRPCRouter({ // ─── Rate resolution ─────────────────────────────────────────────────────── - resolveRate: controllerProcedure + resolveRateLine: controllerProcedure .input(z.object({ rateCardId: z.string(), roleId: z.string().optional(), diff --git a/packages/api/src/router/report.ts b/packages/api/src/router/report.ts index 5cf1faf..c2e61d4 100644 --- a/packages/api/src/router/report.ts +++ b/packages/api/src/router/report.ts @@ -163,6 +163,7 @@ const ENTITY_MAP = { } as const; 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. */ const ALLOWED_SCALAR_FIELDS: Record> = { @@ -190,6 +191,158 @@ function getValidScalarField(entity: EntityKey, field: string): string | 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): 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. * Always includes `id`. For relation columns like "country.name", @@ -254,24 +407,15 @@ function buildWhere( const where: Record = {}; for (const filter of filters) { - const field = getValidScalarField(entity, filter.field); - if (!field) continue; - - const entityColumns = COLUMN_MAP[entity]; - const colDef = entityColumns.find((c) => c.key === field); - const dataType = colDef?.dataType ?? "string"; - - // 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 field = assertValidFilterField(entity, filter.field); + const colDef = getColumnDef(entity, field); + if (!colDef) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unknown filter field for ${entity}: ${filter.field}`, + }); } + const parsedValue = parseFilterValueOrThrow(colDef, filter.value); switch (filter.op) { case "eq": @@ -293,14 +437,28 @@ function buildWhere( where[field] = { lte: parsedValue }; break; case "contains": - if (dataType === "string") { - where[field] = { contains: filter.value, mode: "insensitive" }; + if (colDef.dataType !== "string") { + 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; case "in": - if (dataType === "string") { - where[field] = { in: filter.value.split(",").map((v) => v.trim()) }; + if (colDef.dataType !== "string") { + 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; } } @@ -355,7 +513,7 @@ const ReportInputSchema = z.object({ groupBy: z.string().optional(), sortBy: z.string().optional(), 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), offset: z.number().int().min(0).default(0), }); @@ -440,6 +598,7 @@ export const reportRouter = createTRPCRouter({ config: ReportTemplateConfigSchema, })) .mutation(async ({ ctx, input }) => { + validateReportInput(input.config); const reportTemplate = getReportTemplateDelegate(ctx.db); const payload = input.config as unknown as Prisma.InputJsonValue; const entity = toTemplateEntity(input.config.entity); @@ -568,6 +727,8 @@ async function executeReportQuery( db: any, input: ReportInput, ): Promise<{ rows: Record[]; columns: string[]; totalCount: number }> { + validateReportInput(input); + if (input.entity === "resource_month") { return executeResourceMonthReport(db, input); } @@ -579,9 +740,13 @@ async function executeReportQuery( let orderBy: Record | undefined; if (sortBy) { const validField = getValidScalarField(entity, sortBy); - if (validField) { - orderBy = { [validField]: sortDir }; + if (!validField) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unsupported sort field for ${entity}: ${sortBy}`, + }); } + orderBy = { [validField]: sortDir }; } const modelDelegate = getModelDelegate(db, entity); diff --git a/packages/api/src/router/resource.ts b/packages/api/src/router/resource.ts index b9e41d9..31fdeb3 100644 --- a/packages/api/src/router/resource.ts +++ b/packages/api/src/router/resource.ts @@ -5,7 +5,16 @@ import { listAssignmentBookings, recomputeResourceValueScores, } from "@capakraken/application"; -import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared"; +import { + calculateAllocation, + deriveResourceForecast, + getMonthRange, + DEFAULT_CALCULATION_RULES, + type AssignmentSlice, +} from "@capakraken/engine"; +import { VacationStatus } from "@capakraken/db"; +import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, SystemRole, UpdateResourceSchema, inferStateFromPostalCode, resolvePermissions, type PermissionOverrides } from "@capakraken/shared"; +import type { CalculationRule, SpainScheduleRule } from "@capakraken/shared"; import type { WeekdayAvailability } from "@capakraken/shared"; import { assertBlueprintDynamicFields } from "./blueprint-validation.js"; import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js"; @@ -16,12 +25,19 @@ import { getAnonymizationDirectory, resolveResourceIdsByDisplayedEids, } from "../lib/anonymization.js"; +import { + asHolidayResolverDb, + collectHolidayAvailability, + getResolvedCalendarHolidays, +} from "../lib/holiday-availability.js"; import { calculateEffectiveAvailableHours, calculateEffectiveBookedHours, calculateEffectiveDayAvailability, + countEffectiveWorkingDays, loadResourceDailyAvailabilityContexts, } from "../lib/resource-capacity.js"; +import { fmtEur } from "../lib/format-utils.js"; export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool. @@ -37,6 +53,7 @@ import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; import { ROLE_BRIEF_SELECT } from "../db/selects.js"; +import type { TRPCContext } from "../trpc.js"; function parseResourceCursor(cursor: string | undefined): { displayName: string; id: string } | null { if (!cursor) return null; @@ -95,36 +112,1118 @@ function buildDailyBookedHoursMap( return dailyBookedHours; } +function getAvailabilityHoursForDate( + availability: WeekdayAvailability, + date: Date, +): number { + const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability; + return availability[dayKey] ?? 0; +} + +function sumAvailabilityHoursForDates( + availability: WeekdayAvailability, + dates: Date[], +): number { + return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0); +} + +function formatResolvedHolidaySummary(holiday: { + date: string; + name: string; + scope: string; + calendarName: string | null; + sourceType?: string | null; +}) { + return { + date: holiday.date, + name: holiday.name, + scope: holiday.scope, + calendarName: holiday.calendarName ?? "Built-in", + sourceType: holiday.sourceType ?? "system", + }; +} + +const RESOURCE_SUMMARY_SELECT = { + id: true, + eid: true, + displayName: true, + chapter: true, + isActive: true, + areaRole: { select: { name: true } }, + country: { select: { code: true, name: true } }, + metroCity: { select: { name: true } }, + orgUnit: { select: { name: true } }, +} as const; + +const RESOURCE_SUMMARY_DETAIL_SELECT = { + ...RESOURCE_SUMMARY_SELECT, + fte: true, + lcrCents: true, + chargeabilityTarget: true, +} as const; + +const RESOURCE_IDENTIFIER_SELECT = { + id: true, + eid: true, + displayName: true, + chapter: true, + isActive: true, +} as const; + +const RESOURCE_IDENTIFIER_DETAIL_SELECT = { + ...RESOURCE_IDENTIFIER_SELECT, + id: true, + eid: true, + displayName: true, + email: true, + chapter: true, + fte: true, + lcrCents: true, + ucrCents: true, + chargeabilityTarget: true, + isActive: true, + availability: true, + skills: true, + postalCode: true, + federalState: true, + areaRole: { select: { name: true, color: true } }, + country: { select: { code: true, name: true, dailyWorkingHours: true } }, + metroCity: { select: { name: true } }, + managementLevelGroup: { select: { name: true, targetPercentage: true } }, + orgUnit: { select: { name: true, level: true } }, + _count: { select: { assignments: true, vacations: true } }, +} as const; + +function mapResourceSummary(resource: { + id: string; + eid: string; + displayName: string; + chapter: string | null; + isActive: boolean; + areaRole: { name: string } | null; + country: { code: string; name: string } | null; + metroCity: { name: string } | null; + orgUnit: { name: string } | null; +}) { + return { + id: resource.id, + eid: resource.eid, + name: resource.displayName, + chapter: resource.chapter, + role: resource.areaRole?.name ?? null, + country: resource.country?.name ?? resource.country?.code ?? null, + countryCode: resource.country?.code ?? null, + metroCity: resource.metroCity?.name ?? null, + orgUnit: resource.orgUnit?.name ?? null, + active: resource.isActive, + }; +} + +function mapResourceSummaryDetail(resource: { + id: string; + eid: string; + displayName: string; + chapter: string | null; + fte: number | null; + lcrCents: number | null; + chargeabilityTarget: number | null; + isActive: boolean; + areaRole: { name: string } | null; + country: { code: string; name: string } | null; + metroCity: { name: string } | null; + orgUnit: { name: string } | null; +}) { + return { + id: resource.id, + eid: resource.eid, + name: resource.displayName, + chapter: resource.chapter, + role: resource.areaRole?.name ?? null, + country: resource.country?.name ?? resource.country?.code ?? null, + countryCode: resource.country?.code ?? null, + metroCity: resource.metroCity?.name ?? null, + orgUnit: resource.orgUnit?.name ?? null, + fte: resource.fte, + lcr: resource.lcrCents != null ? fmtEur(resource.lcrCents) : null, + chargeabilityTarget: resource.chargeabilityTarget != null ? `${resource.chargeabilityTarget}%` : null, + active: resource.isActive, + }; +} + +function mapResourceDetail(resource: { + id: string; + eid: string; + displayName: string; + email: string | null; + chapter: string | null; + fte: number | null; + lcrCents: number | null; + ucrCents: number | null; + chargeabilityTarget: number | null; + isActive: boolean; + skills: unknown; + postalCode: string | null; + federalState: string | null; + areaRole: { name: string; color: string | null } | null; + country: { code: string; name: string; dailyWorkingHours: number | null } | null; + metroCity: { name: string } | null; + managementLevelGroup: { name: string; targetPercentage: number | null } | null; + orgUnit: { name: string; level: number } | null; + _count: { assignments: number; vacations: number }; +}) { + const skills = Array.isArray(resource.skills) ? resource.skills as { name?: string; level?: number }[] : []; + return { + id: resource.id, + eid: resource.eid, + name: resource.displayName, + email: resource.email, + chapter: resource.chapter, + role: resource.areaRole?.name ?? null, + country: resource.country?.name ?? resource.country?.code ?? null, + countryCode: resource.country?.code ?? null, + countryHours: resource.country?.dailyWorkingHours ?? 8, + metroCity: resource.metroCity?.name ?? null, + fte: resource.fte, + lcr: resource.lcrCents != null ? fmtEur(resource.lcrCents) : null, + ucr: resource.ucrCents != null ? fmtEur(resource.ucrCents) : null, + chargeabilityTarget: resource.chargeabilityTarget != null ? `${resource.chargeabilityTarget}%` : null, + managementLevel: resource.managementLevelGroup?.name ?? null, + orgUnit: resource.orgUnit?.name ?? null, + postalCode: resource.postalCode, + federalState: resource.federalState, + active: resource.isActive, + totalAssignments: resource._count.assignments, + totalVacations: resource._count.vacations, + skillCount: skills.length, + topSkills: skills.slice(0, 10).map((skill) => `${skill.name ?? "?"} (${skill.level ?? "?"})`), + }; +} + +function summarizeResolvedHolidaySummary(holidays: Array>) { + const byScope = new Map(); + const bySourceType = new Map(); + const byCalendar = new Map(); + + 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 })), + }; +} + +function round1(value: number): number { + return Math.round(value * 10) / 10; +} + +function averagePerWorkingDay(totalHours: number, workingDays: number): number { + return workingDays > 0 ? round1(totalHours / workingDays) : 0; +} + +type ResourceReadContext = Pick; + +function resolveResourcePermissions(ctx: Pick): Set { + if (!ctx.dbUser) { + return new Set(); + } + + return resolvePermissions( + ctx.dbUser.systemRole as SystemRole, + ctx.dbUser.permissionOverrides as PermissionOverrides | null, + ctx.roleDefaults ?? undefined, + ); +} + +function canReadAllResources(ctx: Pick): boolean { + const permissions = resolveResourcePermissions(ctx); + return permissions.has(PermissionKey.VIEW_ALL_RESOURCES) || permissions.has(PermissionKey.MANAGE_RESOURCES); +} + +async function findOwnedResourceId(ctx: ResourceReadContext): Promise { + 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 assertCanReadResource( + ctx: ResourceReadContext, + resourceId: string, + message = "You can only view your own resource data", +): Promise { + if (canReadAllResources(ctx)) { + return; + } + + const ownedResourceId = await findOwnedResourceId(ctx); + if (!ownedResourceId || ownedResourceId !== resourceId) { + throw new TRPCError({ + code: "FORBIDDEN", + message, + }); + } +} + +function isBroadResourceLookupAllowed(ctx: Pick): boolean { + return canReadAllResources(ctx); +} + +const ResourceDirectoryQuerySchema = z.object({ + chapter: z.string().optional(), + chapters: z.array(z.string()).optional(), + isActive: z.boolean().optional().default(true), + search: z.string().optional(), + eids: z.array(z.string()).optional(), + countryIds: z.array(z.string()).optional(), + excludedCountryIds: z.array(z.string()).optional(), + includeWithoutCountry: z.boolean().optional().default(true), + resourceTypes: z.array(z.nativeEnum(ResourceType)).optional(), + excludedResourceTypes: z.array(z.nativeEnum(ResourceType)).optional(), + includeWithoutResourceType: z.boolean().optional().default(true), + rolledOff: z.boolean().optional(), + departed: z.boolean().optional(), + page: z.number().int().min(1).default(1), + limit: z.number().int().min(1).max(500).default(50), + cursor: z.string().optional(), +}); + +const ResourceListQuerySchema = ResourceDirectoryQuerySchema.extend({ + includeRoles: z.boolean().optional().default(false), + customFieldFilters: z.array(z.object({ + key: z.string(), + value: z.string(), + type: z.nativeEnum(FieldType), + })).optional(), +}); + +async function listStaffResources( + ctx: Pick, + input: z.infer, +) { + const { + chapter, + chapters, + isActive, + search, + eids, + countryIds, + excludedCountryIds, + includeWithoutCountry, + resourceTypes, + excludedResourceTypes, + includeWithoutResourceType, + rolledOff, + departed, + page, + limit, + includeRoles, + cursor, + customFieldFilters, + } = input; + const parsedCursor = parseResourceCursor(cursor); + + const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields })); + type WhereClause = Record; + const andClauses: WhereClause[] = []; + const chapterFilters = Array.from( + new Set([ + ...(chapter ? [chapter] : []), + ...(chapters ?? []), + ]), + ); + const directory = await getAnonymizationDirectory(ctx.db); + + if (!eids) { + andClauses.push({ isActive }); + } + if (eids && !directory) { + andClauses.push({ eid: { in: eids } }); + } + if (chapterFilters.length === 1) { + andClauses.push({ chapter: chapterFilters[0] }); + } else if (chapterFilters.length > 1) { + andClauses.push({ chapter: { in: chapterFilters } }); + } + if (search && !directory) { + andClauses.push({ + OR: [ + { displayName: { contains: search, mode: "insensitive" as const } }, + { eid: { contains: search, mode: "insensitive" as const } }, + { email: { contains: search, mode: "insensitive" as const } }, + ], + }); + } + if (countryIds && countryIds.length > 0) { + const countryClauses: WhereClause[] = [{ countryId: { in: countryIds } }]; + if (includeWithoutCountry) { + countryClauses.push({ countryId: null }); + } + andClauses.push(countryClauses.length === 1 ? countryClauses[0]! : { OR: countryClauses }); + } + if (excludedCountryIds && excludedCountryIds.length > 0) { + andClauses.push({ NOT: { countryId: { in: excludedCountryIds } } }); + } + if (!includeWithoutCountry) { + andClauses.push({ NOT: { countryId: null } }); + } + if (resourceTypes && resourceTypes.length > 0) { + const resourceTypeClauses: WhereClause[] = [{ resourceType: { in: resourceTypes } }]; + if (includeWithoutResourceType) { + resourceTypeClauses.push({ resourceType: null }); + } + andClauses.push( + resourceTypeClauses.length === 1 ? resourceTypeClauses[0]! : { OR: resourceTypeClauses }, + ); + } + if (excludedResourceTypes && excludedResourceTypes.length > 0) { + andClauses.push({ NOT: { resourceType: { in: excludedResourceTypes } } }); + } + if (!includeWithoutResourceType) { + andClauses.push({ NOT: { resourceType: null } }); + } + if (rolledOff !== undefined) { + andClauses.push({ rolledOff }); + } + if (departed !== undefined) { + andClauses.push({ departed }); + } + andClauses.push(...cfConditions); + + const where = andClauses.length > 0 ? { AND: andClauses } : {}; + + if (directory) { + const rawResources = await (includeRoles + ? ctx.db.resource.findMany({ + where, + include: { + resourceRoles: { + include: { role: { select: ROLE_BRIEF_SELECT } }, + }, + }, + orderBy: [{ displayName: "asc" }, { id: "asc" }], + }) + : ctx.db.resource.findMany({ + where, + orderBy: [{ displayName: "asc" }, { id: "asc" }], + })); + + const directoryResources = rawResources.map((resource) => ({ + id: resource.id, + eid: resource.eid, + displayName: resource.displayName, + email: resource.email, + })); + const requestedIds = eids + ? resolveResourceIdsByDisplayedEids(directoryResources, directory, eids) + : []; + const requestedIdSet = requestedIds.length > 0 ? new Set(requestedIds) : null; + + const filteredResources = rawResources.filter((resource) => { + const alias = directory.byResourceId.get(resource.id); + if (requestedIdSet && !requestedIdSet.has(resource.id)) { + return false; + } + if (eids && eids.length > 0 && requestedIds.length === 0) { + return false; + } + if (search && !anonymizeSearchMatches( + { + id: resource.id, + eid: resource.eid, + displayName: resource.displayName, + email: resource.email, + }, + alias, + search, + )) { + return false; + } + return true; + }); + + const anonymizedResources = anonymizeResources(filteredResources, directory).sort((left, right) => { + const displayNameCompare = left.displayName.localeCompare(right.displayName); + if (displayNameCompare !== 0) { + return displayNameCompare; + } + return left.id.localeCompare(right.id); + }); + + const total = anonymizedResources.length; + const afterCursor = parsedCursor + ? anonymizedResources.filter( + (resource) => + resource.displayName > parsedCursor.displayName || + (resource.displayName === parsedCursor.displayName && resource.id > parsedCursor.id), + ) + : anonymizedResources; + const skip = cursor ? 0 : (page - 1) * limit; + const paged = afterCursor.slice(skip, skip + limit + 1); + const hasMore = paged.length > limit; + const resources = hasMore ? paged.slice(0, limit) : paged; + const nextCursor = hasMore + ? JSON.stringify({ + displayName: resources[resources.length - 1]!.displayName, + id: resources[resources.length - 1]!.id, + }) + : null; + + return { resources, total, page, limit, nextCursor }; + } + + const skip = cursor ? 0 : (page - 1) * limit; + const orderBy = [{ displayName: "asc" as const }, { id: "asc" as const }]; + const whereWithCursor = parsedCursor + ? { + AND: [ + ...((where as { AND?: WhereClause[] }).AND ?? []), + { + OR: [ + { displayName: { gt: parsedCursor.displayName } }, + { displayName: parsedCursor.displayName, id: { gt: parsedCursor.id } }, + ], + }, + ], + } + : where; + const baseQuery = { where: whereWithCursor, skip, take: limit + 1, orderBy }; + + const [rawResources, total] = await Promise.all([ + includeRoles + ? ctx.db.resource.findMany({ + ...baseQuery, + include: { + resourceRoles: { + include: { role: { select: ROLE_BRIEF_SELECT } }, + }, + }, + }) + : ctx.db.resource.findMany(baseQuery), + ctx.db.resource.count({ where }), + ]); + + const hasMore = rawResources.length > limit; + const resources = hasMore ? rawResources.slice(0, limit) : rawResources; + const nextCursor = hasMore + ? JSON.stringify({ + displayName: resources[resources.length - 1]!.displayName, + id: resources[resources.length - 1]!.id, + }) + : null; + + return { resources, total, page, limit, nextCursor }; +} + +function buildResourceSummaryWhere(input: { + search?: string; + country?: string; + metroCity?: string; + orgUnit?: string; + roleName?: string; + isActive: boolean; +}) { + const where: Record = {}; + if (input.isActive !== false) { + where.isActive = true; + } + if (input.search) { + where.OR = [ + { displayName: { contains: input.search, mode: "insensitive" as const } }, + { eid: { contains: input.search, mode: "insensitive" as const } }, + { chapter: { contains: input.search, mode: "insensitive" as const } }, + ]; + } + if (input.country) { + where.country = { + OR: [ + { code: { equals: input.country, mode: "insensitive" as const } }, + { name: { contains: input.country, mode: "insensitive" as const } }, + ], + }; + } + if (input.metroCity) { + where.metroCity = { name: { contains: input.metroCity, mode: "insensitive" as const } }; + } + if (input.orgUnit) { + where.orgUnit = { name: { contains: input.orgUnit, mode: "insensitive" as const } }; + } + if (input.roleName) { + where.areaRole = { name: { contains: input.roleName, mode: "insensitive" as const } }; + } + + return where; +} + +function assertCanSearchResourceSummaries(ctx: Pick) { + if (!canReadAllResources(ctx)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You need resource overview access to search resource summaries", + }); + } +} + +async function readResourceSummariesSnapshot( + ctx: Pick, + input: { + search?: string; + country?: string; + metroCity?: string; + orgUnit?: string; + roleName?: string; + isActive: boolean; + limit: number; + }, +) { + assertCanSearchResourceSummaries(ctx); + + return ctx.db.resource.findMany({ + where: buildResourceSummaryWhere(input), + select: RESOURCE_SUMMARY_SELECT, + take: input.limit, + orderBy: { displayName: "asc" }, + }); +} + +async function readResourceSummaryDetailsSnapshot( + ctx: Pick, + input: { + search?: string; + country?: string; + metroCity?: string; + orgUnit?: string; + roleName?: string; + isActive: boolean; + limit: number; + }, +) { + assertCanSearchResourceSummaries(ctx); + + return ctx.db.resource.findMany({ + where: buildResourceSummaryWhere(input), + select: RESOURCE_SUMMARY_DETAIL_SELECT, + take: input.limit, + orderBy: { displayName: "asc" }, + }); +} + +async function resolveResourceIdentifierSnapshot( + ctx: ResourceReadContext, + identifier: string, + forbiddenMessage = "You can only view your own resource unless you have staff access", +) { + let resource = await ctx.db.resource.findUnique({ + where: { id: identifier }, + select: RESOURCE_IDENTIFIER_SELECT, + }); + if (!resource) { + resource = await ctx.db.resource.findUnique({ + where: { eid: identifier }, + select: RESOURCE_IDENTIFIER_SELECT, + }); + } + if (!resource) { + resource = await ctx.db.resource.findFirst({ + where: { displayName: { equals: identifier, mode: "insensitive" } }, + select: RESOURCE_IDENTIFIER_SELECT, + }); + } + if (!resource && isBroadResourceLookupAllowed(ctx)) { + resource = await ctx.db.resource.findFirst({ + where: { displayName: { contains: identifier, mode: "insensitive" } }, + select: RESOURCE_IDENTIFIER_SELECT, + }); + } + if (!resource && isBroadResourceLookupAllowed(ctx)) { + const words = identifier.split(/[\s,._\-/]+/).filter((word) => word.length >= 2); + if (words.length > 0) { + const candidates = await ctx.db.resource.findMany({ + where: { + OR: words.map((word) => ({ + displayName: { contains: word, mode: "insensitive" as const }, + })), + }, + select: RESOURCE_IDENTIFIER_SELECT, + take: 5, + }); + if (candidates.length === 1) { + resource = candidates[0]!; + } else if (candidates.length > 1) { + return { + error: `Resource not found: "${identifier}". Did you mean one of these?`, + suggestions: candidates.map((candidate) => ({ + id: candidate.id, + eid: candidate.eid, + name: candidate.displayName, + })), + } as const; + } + } + } + + if (!resource) { + return { error: `Resource not found: ${identifier}` } as const; + } + + await assertCanReadResource( + ctx, + resource.id, + forbiddenMessage, + ); + + return resource; +} + +async function readResourceByIdentifierDetailSnapshot( + ctx: ResourceReadContext, + identifier: string, +) { + const resource = await resolveResourceIdentifierSnapshot(ctx, identifier); + if ("error" in resource) { + return resource; + } + + const detail = await ctx.db.resource.findUnique({ + where: { id: resource.id }, + select: RESOURCE_IDENTIFIER_DETAIL_SELECT, + }); + + if (!detail) { + return { error: `Resource not found: ${identifier}` } as const; + } + + return detail; +} + export const resourceRouter = createTRPCRouter({ - list: protectedProcedure - .input( - z.object({ - chapter: z.string().optional(), - chapters: z.array(z.string()).optional(), - isActive: z.boolean().optional().default(true), - search: z.string().optional(), - eids: z.array(z.string()).optional(), - countryIds: z.array(z.string()).optional(), - excludedCountryIds: z.array(z.string()).optional(), - includeWithoutCountry: z.boolean().optional().default(true), - resourceTypes: z.array(z.nativeEnum(ResourceType)).optional(), - excludedResourceTypes: z.array(z.nativeEnum(ResourceType)).optional(), - includeWithoutResourceType: z.boolean().optional().default(true), - rolledOff: z.boolean().optional(), - departed: z.boolean().optional(), - page: z.number().int().min(1).default(1), - limit: z.number().int().min(1).max(500).default(50), - includeRoles: z.boolean().optional().default(false), - // Cursor-based pagination (additive — page/limit still supported) - cursor: z.string().optional(), - // Custom field JSONB filters - customFieldFilters: z.array(z.object({ - key: z.string(), - value: z.string(), - type: z.nativeEnum(FieldType), - })).optional(), - }), - ) + resolveByIdentifier: protectedProcedure + .input(z.object({ identifier: z.string() })) + .query(async ({ ctx, input }) => { + const resource = await resolveResourceIdentifierSnapshot( + ctx, + input.identifier, + "You can only resolve your own resource unless you have staff access", + ); + if ("error" in resource) { + throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); + } + return resource; + }), + + resolveResponsiblePersonName: protectedProcedure + .input(z.object({ name: z.string() })) + .query(async ({ ctx, input }) => { + const exact = await ctx.db.resource.findFirst({ + where: { displayName: { equals: input.name, mode: "insensitive" }, isActive: true }, + select: { displayName: true }, + }); + if (exact) { + return { + status: "resolved" as const, + displayName: exact.displayName, + }; + } + + const candidates = await ctx.db.resource.findMany({ + where: { displayName: { contains: input.name, mode: "insensitive" }, isActive: true }, + select: { displayName: true, eid: true }, + take: 5, + }); + if (candidates.length === 1) { + return { + status: "resolved" as const, + displayName: candidates[0]!.displayName, + }; + } + if (candidates.length > 1) { + return { + status: "ambiguous" as const, + message: `Multiple resources match "${input.name}": ${candidates.map((candidate) => `${candidate.displayName} (${candidate.eid})`).join(", ")}. Please specify the exact name.`, + candidates, + }; + } + + return { + status: "missing" as const, + message: `No active resource found matching "${input.name}". The responsible person must be an existing resource.`, + candidates: [], + }; + }), + + getChargeabilitySummary: protectedProcedure + .input(z.object({ + resourceId: z.string(), + month: z.string().regex(/^\d{4}-\d{2}$/), + })) + .query(async ({ ctx, input }) => { + await assertCanReadResource( + ctx, + input.resourceId, + "You can only view chargeability details for your own resource unless you have staff access", + ); + + const [year, month] = input.month.split("-").map(Number) as [number, number]; + const { start: monthStart, end: monthEnd } = getMonthRange(year, month); + + const resource = await ctx.db.resource.findUniqueOrThrow({ + where: { id: input.resourceId }, + select: { + id: true, + displayName: true, + eid: true, + fte: true, + lcrCents: true, + chargeabilityTarget: true, + countryId: true, + federalState: true, + metroCityId: true, + availability: true, + country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } }, + metroCity: { select: { id: true, name: true } }, + managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } }, + }, + }); + + const dailyHours = resource.country?.dailyWorkingHours ?? 8; + const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null; + const targetRatio = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100); + + const availability = resource.availability as WeekdayAvailability | null; + const weeklyAvailability: WeekdayAvailability = availability ?? { + monday: dailyHours, + tuesday: dailyHours, + wednesday: dailyHours, + thursday: dailyHours, + friday: dailyHours, + saturday: 0, + sunday: 0, + }; + + const assignments = await ctx.db.assignment.findMany({ + where: { + resourceId: input.resourceId, + startDate: { lte: monthEnd }, + endDate: { gte: monthStart }, + status: { in: ["CONFIRMED", "ACTIVE", "PROPOSED"] }, + }, + select: { + id: true, + hoursPerDay: true, + startDate: true, + endDate: true, + dailyCostCents: true, + status: true, + project: { + select: { + id: true, + name: true, + shortCode: true, + orderType: true, + utilizationCategory: { select: { code: true } }, + }, + }, + }, + }); + + const vacations = await ctx.db.vacation.findMany({ + where: { + resourceId: input.resourceId, + status: VacationStatus.APPROVED, + startDate: { lte: monthEnd }, + endDate: { gte: monthStart }, + }, + select: { startDate: true, endDate: true, type: true, isHalfDay: true }, + }); + + const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { + periodStart: monthStart, + periodEnd: monthEnd, + countryId: resource.countryId, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name, + }); + const holidayAvailability = collectHolidayAvailability({ + vacations, + periodStart: monthStart, + periodEnd: monthEnd, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityName: resource.metroCity?.name, + resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date), + }); + const absenceDays = holidayAvailability.absenceDays; + + const contexts = await loadResourceDailyAvailabilityContexts( + ctx.db, + [{ + id: resource.id, + availability: weeklyAvailability, + countryId: resource.countryId, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name, + }], + monthStart, + monthEnd, + ); + const availabilityContext = contexts.get(resource.id); + + let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES; + try { + const dbRules = await ctx.db.calculationRule.findMany({ + where: { isActive: true }, + orderBy: [{ priority: "desc" }], + }); + if (dbRules.length > 0) { + calcRules = dbRules as unknown as CalculationRule[]; + } + } catch { + // table may not exist yet + } + + const baseWorkingDays = countEffectiveWorkingDays({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: undefined, + }); + const effectiveWorkingDays = countEffectiveWorkingDays({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: availabilityContext, + }); + const baseAvailableHours = calculateEffectiveAvailableHours({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: undefined, + }); + const effectiveAvailableHours = calculateEffectiveAvailableHours({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: availabilityContext, + }); + + const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`)); + const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => ( + count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0) + ), 0); + const publicHolidayHoursDeduction = sumAvailabilityHoursForDates( + weeklyAvailability, + publicHolidayDates, + ); + const absenceDayEquivalent = absenceDays.reduce((sum, absence) => { + if (absence.type === "PUBLIC_HOLIDAY") { + return sum; + } + return sum + (absence.isHalfDay ? 0.5 : 1); + }, 0); + const absenceHoursDeduction = absenceDays.reduce((sum, absence) => { + if (absence.type === "PUBLIC_HOLIDAY") { + return sum; + } + const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date); + return sum + baseHours * (absence.isHalfDay ? 0.5 : 1); + }, 0); + + const slices: AssignmentSlice[] = []; + const assignmentBreakdown: Array<{ + project: string; + code: string; + hours: number; + status: string; + }> = []; + let totalBookedHours = 0; + + for (const assignment of assignments) { + const overlapStart = new Date(Math.max(monthStart.getTime(), assignment.startDate.getTime())); + const overlapEnd = new Date(Math.min(monthEnd.getTime(), assignment.endDate.getTime())); + const categoryCode = assignment.project.utilizationCategory?.code ?? "Chg"; + + const calcResult = calculateAllocation({ + lcrCents: resource.lcrCents, + hoursPerDay: assignment.hoursPerDay, + startDate: overlapStart, + endDate: overlapEnd, + availability: weeklyAvailability, + absenceDays, + calculationRules: calcRules, + orderType: assignment.project.orderType, + projectId: assignment.project.id, + }); + if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) { + continue; + } + + totalBookedHours += calcResult.totalHours; + assignmentBreakdown.push({ + project: assignment.project.name, + code: assignment.project.shortCode, + hours: round1(calcResult.totalHours), + status: assignment.status, + }); + slices.push({ + hoursPerDay: assignment.hoursPerDay, + workingDays: calcResult.workingDays, + categoryCode, + ...(calcResult.totalChargeableHours !== undefined + ? { totalChargeableHours: calcResult.totalChargeableHours } + : {}), + }); + } + + const forecast = deriveResourceForecast({ + fte: resource.fte, + targetPercentage: targetRatio, + assignments: slices, + sah: effectiveAvailableHours, + }); + + const formattedHolidays = resolvedHolidays.map((holiday) => formatResolvedHolidaySummary({ + ...holiday, + calendarName: holiday.calendarName ?? "Built-in", + sourceType: holiday.sourceType ?? "system", + })); + const workingDays = round1(effectiveWorkingDays); + const baseWorkingDaysRounded = round1(baseWorkingDays); + const baseAvailableHoursRounded = round1(baseAvailableHours); + const availableHours = round1(effectiveAvailableHours); + const bookedHours = round1(totalBookedHours); + const targetPct = round1(targetRatio * 100); + const targetHours = availableHours > 0 ? round1((availableHours * targetPct) / 100) : 0; + const chargeabilityPct = round1(forecast.chg * 100); + const unassignedHours = round1(Math.max(0, availableHours - bookedHours)); + + return { + resource: resource.displayName, + eid: resource.eid, + month: input.month, + periodStart: monthStart.toISOString().slice(0, 10), + periodEnd: monthEnd.toISOString().slice(0, 10), + fte: round1(resource.fte), + target: `${targetPct}%`, + targetPct, + targetHours, + workingDays, + baseWorkingDays: baseWorkingDaysRounded, + locationContext: { + countryCode: resource.country?.code ?? null, + country: resource.country?.name ?? resource.country?.code ?? null, + federalState: resource.federalState ?? null, + metroCity: resource.metroCity?.name ?? null, + }, + baseAvailableHours: baseAvailableHoursRounded, + availableHours, + bookedHours, + unassignedHours, + chargeability: `${chargeabilityPct}%`, + chargeabilityPct, + onTarget: chargeabilityPct >= targetPct, + holidaySummary: { + count: formattedHolidays.length, + workdayCount: round1(publicHolidayWorkdayCount), + hoursDeduction: round1(publicHolidayHoursDeduction), + holidays: formattedHolidays, + breakdown: summarizeResolvedHolidaySummary(formattedHolidays), + }, + absenceSummary: { + dayEquivalent: round1(absenceDayEquivalent), + hoursDeduction: round1(absenceHoursDeduction), + }, + capacityBreakdown: { + formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours", + baseAvailableHours: baseAvailableHoursRounded, + holidayHoursDeduction: round1(publicHolidayHoursDeduction), + absenceHoursDeduction: round1(absenceHoursDeduction), + availableHours, + }, + averages: { + availableHoursPerWorkingDay: averagePerWorkingDay(availableHours, workingDays), + bookedHoursPerWorkingDay: averagePerWorkingDay(bookedHours, workingDays), + remainingHoursPerWorkingDay: averagePerWorkingDay(Math.max(0, availableHours - bookedHours), workingDays), + }, + allocations: assignmentBreakdown, + scheduleContext: { + dailyWorkingHours: dailyHours, + hasScheduleRules: Boolean(scheduleRules), + }, + }; + }), + + listSummaries: protectedProcedure + .input(z.object({ + search: z.string().optional(), + country: z.string().optional(), + metroCity: z.string().optional(), + orgUnit: z.string().optional(), + roleName: z.string().optional(), + isActive: z.boolean().optional().default(true), + limit: z.number().int().min(1).max(100).default(50), + })) + .query(async ({ ctx, input }) => { + const resources = await readResourceSummariesSnapshot(ctx, { + isActive: input.isActive, + limit: input.limit, + ...(input.search ? { search: input.search } : {}), + ...(input.country ? { country: input.country } : {}), + ...(input.metroCity ? { metroCity: input.metroCity } : {}), + ...(input.orgUnit ? { orgUnit: input.orgUnit } : {}), + ...(input.roleName ? { roleName: input.roleName } : {}), + }); + return resources.map(mapResourceSummary); + }), + + listSummariesDetail: protectedProcedure + .input(z.object({ + search: z.string().optional(), + country: z.string().optional(), + metroCity: z.string().optional(), + orgUnit: z.string().optional(), + roleName: z.string().optional(), + isActive: z.boolean().optional().default(true), + limit: z.number().int().min(1).max(100).default(50), + })) + .query(async ({ ctx, input }) => { + const resources = await readResourceSummaryDetailsSnapshot(ctx, { + isActive: input.isActive, + limit: input.limit, + ...(input.search ? { search: input.search } : {}), + ...(input.country ? { country: input.country } : {}), + ...(input.metroCity ? { metroCity: input.metroCity } : {}), + ...(input.orgUnit ? { orgUnit: input.orgUnit } : {}), + ...(input.roleName ? { roleName: input.roleName } : {}), + }); + return resources.map(mapResourceSummaryDetail); + }), + + directory: protectedProcedure + .input(ResourceDirectoryQuerySchema) .query(async ({ ctx, input }) => { const { chapter, @@ -142,13 +1241,10 @@ export const resourceRouter = createTRPCRouter({ departed, page, limit, - includeRoles, cursor, - customFieldFilters, } = input; - const parsedCursor = parseResourceCursor(cursor); - const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields })); + const parsedCursor = parseResourceCursor(cursor); type WhereClause = Record; const andClauses: WhereClause[] = []; const chapterFilters = Array.from( @@ -175,7 +1271,6 @@ export const resourceRouter = createTRPCRouter({ OR: [ { displayName: { contains: search, mode: "insensitive" as const } }, { eid: { contains: search, mode: "insensitive" as const } }, - { email: { contains: search, mode: "insensitive" as const } }, ], }); } @@ -213,34 +1308,25 @@ export const resourceRouter = createTRPCRouter({ if (departed !== undefined) { andClauses.push({ departed }); } - andClauses.push(...cfConditions); const where = andClauses.length > 0 ? { AND: andClauses } : {}; + const orderBy = [{ displayName: "asc" as const }, { id: "asc" as const }]; if (directory) { - const rawResources = await (includeRoles - ? ctx.db.resource.findMany({ - where, - include: { - resourceRoles: { - include: { role: { select: ROLE_BRIEF_SELECT } }, - }, - }, - orderBy: [{ displayName: "asc" }, { id: "asc" }], - }) - : ctx.db.resource.findMany({ - where, - orderBy: [{ displayName: "asc" }, { id: "asc" }], - })); - - const directoryResources = rawResources.map((resource) => ({ - id: resource.id, - eid: resource.eid, - displayName: resource.displayName, - email: resource.email, - })); + const rawResources = await ctx.db.resource.findMany({ + where, + select: { + id: true, + eid: true, + displayName: true, + chapter: true, + isActive: true, + email: true, + }, + orderBy, + }); const requestedIds = eids - ? resolveResourceIdsByDisplayedEids(directoryResources, directory, eids) + ? resolveResourceIdsByDisplayedEids(rawResources, directory, eids) : []; const requestedIdSet = requestedIds.length > 0 ? new Set(requestedIds) : null; @@ -286,7 +1372,13 @@ export const resourceRouter = createTRPCRouter({ const skip = cursor ? 0 : (page - 1) * limit; const paged = afterCursor.slice(skip, skip + limit + 1); const hasMore = paged.length > limit; - const resources = hasMore ? paged.slice(0, limit) : paged; + const resources = (hasMore ? paged.slice(0, limit) : paged).map((resource) => ({ + id: resource.id, + eid: resource.eid, + displayName: resource.displayName, + chapter: resource.chapter, + isActive: resource.isActive, + })); const nextCursor = hasMore ? JSON.stringify({ displayName: resources[resources.length - 1]!.displayName, @@ -298,7 +1390,6 @@ export const resourceRouter = createTRPCRouter({ } const skip = cursor ? 0 : (page - 1) * limit; - const orderBy = [{ displayName: "asc" as const }, { id: "asc" as const }]; const whereWithCursor = parsedCursor ? { AND: [ @@ -312,19 +1403,21 @@ export const resourceRouter = createTRPCRouter({ ], } : where; - const baseQuery = { where: whereWithCursor, skip, take: limit + 1, orderBy }; const [rawResources, total] = await Promise.all([ - includeRoles - ? ctx.db.resource.findMany({ - ...baseQuery, - include: { - resourceRoles: { - include: { role: { select: ROLE_BRIEF_SELECT } }, - }, - }, - }) - : ctx.db.resource.findMany(baseQuery), + ctx.db.resource.findMany({ + where: whereWithCursor, + skip, + take: limit + 1, + orderBy, + select: { + id: true, + eid: true, + displayName: true, + chapter: true, + isActive: true, + }, + }), ctx.db.resource.count({ where }), ]); @@ -340,6 +1433,18 @@ export const resourceRouter = createTRPCRouter({ return { resources, total, page, limit, nextCursor }; }), + listStaff: protectedProcedure + .input(ResourceListQuerySchema) + .query(async ({ ctx, input }) => { + if (!canReadAllResources(ctx)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You need resource overview access to list staff resource data", + }); + } + return listStaffResources(ctx, input); + }), + /** Lightweight resource card for hover tooltips on the timeline. */ getHoverCard: protectedProcedure .input(z.object({ id: z.string() })) @@ -368,6 +1473,11 @@ export const resourceRouter = createTRPCRouter({ }), "Resource", ); + await assertCanReadResource( + ctx, + resource.id, + "You can only view hover details for your own resource unless you have staff access", + ); const directory = await getAnonymizationDirectory(ctx.db); const anon = anonymizeResource(resource, directory); return { @@ -388,6 +1498,20 @@ export const resourceRouter = createTRPCRouter({ }; }), + getByIdentifier: protectedProcedure + .input(z.object({ identifier: z.string() })) + .query(async ({ ctx, input }) => resolveResourceIdentifierSnapshot(ctx, input.identifier)), + + getByIdentifierDetail: protectedProcedure + .input(z.object({ identifier: z.string() })) + .query(async ({ ctx, input }) => { + const resource = await readResourceByIdentifierDetailSnapshot(ctx, input.identifier); + if ("error" in resource) { + return resource; + } + return mapResourceDetail(resource); + }), + getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { @@ -404,6 +1528,11 @@ export const resourceRouter = createTRPCRouter({ }), "Resource", ); + await assertCanReadResource( + ctx, + resource.id, + "You can only view your own resource unless you have staff access", + ); const directory = await getAnonymizationDirectory(ctx.db); return { @@ -426,6 +1555,11 @@ export const resourceRouter = createTRPCRouter({ if (!resource) { throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); } + await assertCanReadResource( + ctx, + resource.id, + "You can only view your own resource unless you have staff access", + ); return anonymizeResource(resource, directory); }), @@ -1033,10 +2167,8 @@ export const resourceRouter = createTRPCRouter({ }), ) .query(async ({ ctx, input }) => { - const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); - const visibleRoles = (settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"]; - const userRole = (ctx.session.user as { role?: string } | undefined)?.role ?? "USER"; - if (!visibleRoles.includes(userRole)) return []; + const permissions = resolveResourcePermissions(ctx); + requirePermission({ permissions }, PermissionKey.VIEW_SCORES); const resources = await ctx.db.resource.findMany({ where: { isActive: input.isActive }, diff --git a/packages/api/src/router/role.ts b/packages/api/src/router/role.ts index 925c128..ee66d4c 100644 --- a/packages/api/src/router/role.ts +++ b/packages/api/src/router/role.ts @@ -80,6 +80,87 @@ export const roleRouter = createTRPCRouter({ 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 .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { diff --git a/packages/api/src/router/staffing.ts b/packages/api/src/router/staffing.ts index 69ce252..5360bce 100644 --- a/packages/api/src/router/staffing.ts +++ b/packages/api/src/router/staffing.ts @@ -10,6 +10,7 @@ import { loadResourceDailyAvailabilityContexts, type ResourceDailyAvailabilityContext, } from "../lib/resource-capacity.js"; +import { fmtEur } from "../lib/format-utils.js"; import { createTRPCRouter, protectedProcedure } from "../trpc.js"; const DAY_KEYS: (keyof WeekdayAvailability)[] = [ @@ -27,6 +28,41 @@ function toIsoDate(value: Date): string { return value.toISOString().slice(0, 10); } +function fmtDate(value: Date | null | undefined): string | null { + return value ? value.toISOString().slice(0, 10) : null; +} + +function createUtcDate(year: number, monthIndex: number, day: number): Date { + return new Date(Date.UTC(year, monthIndex, day)); +} + +function normalizeUtcDate(value: Date): Date { + return createUtcDate(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()); +} + +function createDateRange(input: { + startDate?: Date | undefined; + endDate?: Date | undefined; + durationDays?: number | undefined; +}): { startDate: Date; endDate: Date } { + const startDate = input.startDate + ? normalizeUtcDate(input.startDate) + : createUtcDate(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate()); + const endDate = input.endDate + ? normalizeUtcDate(input.endDate) + : createUtcDate( + startDate.getUTCFullYear(), + startDate.getUTCMonth(), + startDate.getUTCDate() + Math.max((input.durationDays ?? 21) - 1, 0), + ); + + if (endDate < startDate) { + throw new Error("endDate must be on or after startDate."); + } + + return { startDate, endDate }; +} + function round1(value: number): number { return Math.round(value * 10) / 10; } @@ -101,6 +137,725 @@ function calculateAllocatedHoursForDay(input: { ); } +type StaffingSuggestionInput = { + requiredSkills: string[]; + preferredSkills?: string[] | undefined; + startDate: Date; + endDate: Date; + hoursPerDay: number; + budgetLcrCentsPerHour?: number | undefined; + chapter?: string | undefined; + skillCategory?: string | undefined; + mainSkillsOnly?: boolean | undefined; + minProficiency?: number | undefined; +}; + +type StaffingSuggestionsDbClient = + Parameters[0] + & Parameters[0] + & { + resource: { + findMany: (args: Record) => Promise; + }; + }; + +type StaffingResourceRecord = { + id: string; + displayName: string; + eid: string; + fte: number | null; + chapter: string | null; + skills: unknown; + lcrCents: number | null; + chargeabilityTarget: number | null; + availability: unknown; + valueScore: number | null; + countryId: string | null; + federalState: string | null; + metroCityId: string | null; + country: { code: string; name: string } | null; + metroCity: { name: string } | null; + areaRole: { name: string } | null; +}; + +type BestProjectResourceRankingMode = + | "lowest_lcr" + | "highest_remaining_hours_per_day" + | "highest_remaining_hours"; + +type BestProjectResourceInput = { + projectId: string; + startDate: Date; + endDate: Date; + minHoursPerDay: number; + rankingMode: BestProjectResourceRankingMode; + chapter?: string | undefined; + roleName?: string | undefined; +}; + +type BestProjectResourceDbClient = + Parameters[0] + & { + assignment: { + findMany: (args: Record) => Promise; + }; + }; + +type BestProjectResourceDetailDbClient = BestProjectResourceDbClient & { + project: { + findUnique: (args: Record) => Promise; + }; +}; + +type BestProjectResourceAssignmentRecord = { + resourceId: string; + hoursPerDay: number; + startDate: Date; + endDate: Date; + status: string; + resource: { + id: string; + eid: string; + displayName: string; + chapter: string | null; + lcrCents: number | null; + availability: unknown; + countryId: string | null; + federalState: string | null; + metroCityId: string | null; + country: { code: string; name: string } | null; + metroCity: { name: string } | null; + areaRole: { name: string } | null; + }; +}; + +type BestProjectResourceOverlapAssignmentRecord = { + resourceId: string; + projectId: string; + hoursPerDay: number; + startDate: Date; + endDate: Date; + status: string; + project: { name: string; shortCode: string | null } | null; +}; + +async function queryStaffingSuggestions( + db: StaffingSuggestionsDbClient, + input: StaffingSuggestionInput, +) { + const { + requiredSkills, + preferredSkills, + startDate, + endDate, + hoursPerDay, + budgetLcrCentsPerHour, + chapter, + skillCategory, + mainSkillsOnly, + minProficiency, + } = input; + + const resources = await db.resource.findMany({ + where: { + isActive: true, + ...(chapter ? { chapter } : {}), + }, + select: { + id: true, + displayName: true, + eid: true, + fte: true, + chapter: true, + skills: true, + lcrCents: true, + chargeabilityTarget: true, + availability: true, + valueScore: true, + countryId: true, + federalState: true, + metroCityId: true, + country: { select: { code: true, name: true } }, + metroCity: { select: { name: true } }, + areaRole: { select: { name: true } }, + }, + }) as StaffingResourceRecord[]; + const bookings = await listAssignmentBookings(db, { + startDate, + endDate, + resourceIds: resources.map((resource) => resource.id), + }); + const contexts = 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, + })), + startDate, + endDate, + ); + const bookingsByResourceId = new Map(); + for (const booking of bookings) { + if (!booking.resourceId) { + continue; + } + const items = bookingsByResourceId.get(booking.resourceId) ?? []; + items.push(booking); + bookingsByResourceId.set(booking.resourceId, items); + } + + const enrichedResources = resources.map((resource) => { + const availability = resource.availability as unknown as WeekdayAvailability; + const context = contexts.get(resource.id); + const resourceBookings = bookingsByResourceId.get(resource.id) ?? []; + const activeBookings = resourceBookings.filter((booking) => ACTIVE_STATUSES.has(booking.status)); + const baseAvailableHours = calculateEffectiveAvailableHours({ + availability, + periodStart: startDate, + periodEnd: endDate, + context: undefined, + }); + const totalAvailableHours = calculateEffectiveAvailableHours({ + availability, + periodStart: startDate, + periodEnd: endDate, + context, + }); + const baseWorkingDays = countEffectiveWorkingDays({ + availability, + periodStart: startDate, + periodEnd: endDate, + context: undefined, + }); + const effectiveWorkingDays = countEffectiveWorkingDays({ + availability, + periodStart: startDate, + periodEnd: endDate, + context, + }); + const allocatedHours = activeBookings.reduce( + (sum, booking) => + sum + calculateEffectiveBookedHours({ + availability, + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + periodStart: startDate, + periodEnd: endDate, + context, + }), + 0, + ); + const holidayDates = [...(context?.holidayDates ?? new Set())].sort(); + const holidayWorkdayCount = holidayDates.reduce((count, isoDate) => ( + count + (getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0) + ), 0); + const holidayHoursDeduction = holidayDates.reduce((sum, isoDate) => ( + sum + getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)) + ), 0); + let absenceDayEquivalent = 0; + let absenceHoursDeduction = 0; + for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) { + const dayHours = getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)); + if (dayHours <= 0 || context?.holidayDates.has(isoDate)) { + continue; + } + absenceDayEquivalent += fraction; + absenceHoursDeduction += dayHours * fraction; + } + const conflictDays: string[] = []; + const conflictDetails: Array<{ + date: string; + baseHours: number; + effectiveHours: number; + allocatedHours: number; + remainingHours: number; + requestedHours: number; + shortageHours: number; + absenceFraction: number; + isHoliday: boolean; + }> = []; + const cursor = new Date(startDate); + cursor.setUTCHours(0, 0, 0, 0); + const periodEndAtMidnight = new Date(endDate); + periodEndAtMidnight.setUTCHours(0, 0, 0, 0); + + while (cursor <= periodEndAtMidnight) { + const isoDate = toIsoDate(cursor); + const baseHoursForDay = getBaseDayAvailability(availability, cursor); + const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context); + const isHoliday = context?.holidayDates.has(isoDate) ?? false; + const absenceFraction = Math.min( + 1, + Math.max(0, context?.absenceFractionsByDate.get(isoDate) ?? 0), + ); + if (availableHoursForDay > 0) { + const { allocatedHours: allocatedHoursForDay } = calculateAllocatedHoursForDay({ + bookings: activeBookings, + date: cursor, + context, + }); + if (allocatedHoursForDay + hoursPerDay > availableHoursForDay) { + const remainingHoursForDay = Math.max(0, availableHoursForDay - allocatedHoursForDay); + conflictDays.push(isoDate); + conflictDetails.push({ + date: isoDate, + baseHours: round1(baseHoursForDay), + effectiveHours: round1(availableHoursForDay), + allocatedHours: round1(allocatedHoursForDay), + remainingHours: round1(remainingHoursForDay), + requestedHours: round1(hoursPerDay), + shortageHours: round1(Math.max(0, hoursPerDay - remainingHoursForDay)), + absenceFraction: round1(absenceFraction), + isHoliday, + }); + } + } + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + + const remainingHours = Math.max(0, totalAvailableHours - allocatedHours); + const remainingHoursPerDay = averagePerWorkingDay(remainingHours, effectiveWorkingDays); + const utilizationPercent = + totalAvailableHours > 0 + ? Math.min(100, (allocatedHours / totalAvailableHours) * 100) + : 0; + + type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean }; + let skills = resource.skills as unknown as SkillRow[]; + + if (mainSkillsOnly) skills = skills.filter((s) => s.isMainSkill); + if (skillCategory) skills = skills.filter((s) => s.category === skillCategory); + if (minProficiency) skills = skills.filter((s) => s.proficiency >= minProficiency); + + return { + id: resource.id, + displayName: resource.displayName, + eid: resource.eid, + fte: resource.fte, + chapter: resource.chapter, + role: resource.areaRole?.name ?? null, + skills: skills as unknown as import("@capakraken/shared").SkillEntry[], + lcrCents: resource.lcrCents, + chargeabilityTarget: resource.chargeabilityTarget, + currentUtilizationPercent: utilizationPercent, + hasAvailabilityConflicts: conflictDays.length > 0, + conflictDays, + valueScore: resource.valueScore ?? 0, + transparency: { + location: { + countryCode: resource.country?.code ?? null, + countryName: resource.country?.name ?? null, + federalState: resource.federalState ?? null, + metroCityName: resource.metroCity?.name ?? null, + label: createLocationLabel({ + countryCode: resource.country?.code ?? null, + federalState: resource.federalState, + metroCityName: resource.metroCity?.name ?? null, + }), + }, + capacity: { + requestedHoursPerDay: round1(hoursPerDay), + requestedHoursTotal: round1(effectiveWorkingDays * hoursPerDay), + baseWorkingDays: round1(baseWorkingDays), + effectiveWorkingDays: round1(effectiveWorkingDays), + baseAvailableHours: round1(baseAvailableHours), + effectiveAvailableHours: round1(totalAvailableHours), + bookedHours: round1(allocatedHours), + remainingHours: round1(remainingHours), + remainingHoursPerDay, + holidayCount: holidayDates.length, + holidayWorkdayCount, + holidayHoursDeduction: round1(holidayHoursDeduction), + absenceDayEquivalent: round1(absenceDayEquivalent), + absenceHoursDeduction: round1(absenceHoursDeduction), + }, + conflicts: { + count: conflictDays.length, + conflictDays, + details: conflictDetails, + }, + }, + }; + }); + + const ranked = rankResources({ + requiredSkills, + preferredSkills, + resources: enrichedResources, + budgetLcrCentsPerHour, + } as unknown as Parameters[0]); + const baseRankIndex = new Map(ranked.map((suggestion, index) => [suggestion.resourceId, index])); + + return [...ranked].sort((a, b) => { + if (Math.abs(a.score - b.score) <= 2) { + const aVal = (enrichedResources.find((resource) => resource.id === a.resourceId)?.valueScore ?? 0); + const bVal = (enrichedResources.find((resource) => resource.id === b.resourceId)?.valueScore ?? 0); + return bVal - aVal; + } + return 0; + }).map((suggestion, index) => { + const resource = enrichedResources.find((entry) => entry.id === suggestion.resourceId); + const fallbackBreakdown = "breakdown" in suggestion + ? (suggestion as { breakdown?: { skillScore: number; availabilityScore: number; costScore: number; utilizationScore: number } }).breakdown + : undefined; + const scoreBreakdown = suggestion.scoreBreakdown ?? { + skillScore: fallbackBreakdown?.skillScore ?? 0, + availabilityScore: fallbackBreakdown?.availabilityScore ?? 0, + costScore: fallbackBreakdown?.costScore ?? 0, + utilizationScore: fallbackBreakdown?.utilizationScore ?? 0, + total: suggestion.score, + }; + const baseRank = (baseRankIndex.get(suggestion.resourceId) ?? index) + 1; + const tieBreakerApplied = baseRank !== index + 1; + + return { + ...suggestion, + resourceName: suggestion.resourceName ?? resource?.displayName ?? "", + eid: suggestion.eid ?? resource?.eid ?? "", + fte: resource?.fte ?? 0, + chapter: resource?.chapter ?? null, + role: resource?.role ?? null, + scoreBreakdown, + matchedSkills: suggestion.matchedSkills ?? requiredSkills.filter((skill) => + resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()), + ), + missingSkills: suggestion.missingSkills ?? requiredSkills.filter((skill) => + !resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()), + ), + availabilityConflicts: suggestion.availabilityConflicts ?? resource?.conflictDays ?? [], + estimatedDailyCostCents: suggestion.estimatedDailyCostCents ?? ((resource?.lcrCents ?? 0) * 8), + currentUtilization: suggestion.currentUtilization ?? round1(resource?.currentUtilizationPercent ?? 0), + valueScore: resource?.valueScore ?? 0, + location: resource?.transparency.location ?? { + countryCode: null, + countryName: null, + federalState: null, + metroCityName: null, + label: "", + }, + capacity: resource?.transparency.capacity ?? { + requestedHoursPerDay: round1(hoursPerDay), + requestedHoursTotal: 0, + baseWorkingDays: 0, + effectiveWorkingDays: 0, + baseAvailableHours: 0, + effectiveAvailableHours: 0, + bookedHours: 0, + remainingHours: 0, + remainingHoursPerDay: 0, + holidayCount: 0, + holidayWorkdayCount: 0, + holidayHoursDeduction: 0, + absenceDayEquivalent: 0, + absenceHoursDeduction: 0, + }, + conflicts: resource?.transparency.conflicts ?? { + count: 0, + conflictDays: [], + details: [], + }, + ranking: { + rank: index + 1, + baseRank, + tieBreakerApplied, + tieBreakerReason: tieBreakerApplied + ? "Within 2 score points, higher value score moves the candidate up." + : null, + model: "Composite ranking across skill fit, availability, cost, and utilization.", + components: [ + { key: "skillScore", label: "Skills", score: scoreBreakdown.skillScore }, + { key: "availabilityScore", label: "Availability", score: scoreBreakdown.availabilityScore }, + { key: "costScore", label: "Cost", score: scoreBreakdown.costScore }, + { key: "utilizationScore", label: "Utilization", score: scoreBreakdown.utilizationScore }, + ], + }, + remainingHoursPerDay: resource?.transparency.capacity.remainingHoursPerDay ?? 0, + remainingHours: resource?.transparency.capacity.remainingHours ?? 0, + effectiveAvailableHours: resource?.transparency.capacity.effectiveAvailableHours ?? 0, + baseAvailableHours: resource?.transparency.capacity.baseAvailableHours ?? 0, + holidayHoursDeduction: resource?.transparency.capacity.holidayHoursDeduction ?? 0, + }; + }); +} + +async function queryBestProjectResource( + db: BestProjectResourceDbClient, + input: BestProjectResourceInput, +) { + const projectAssignmentsResult = await db.assignment.findMany({ + where: { + projectId: input.projectId, + status: { not: "CANCELLED" }, + startDate: { lte: input.endDate }, + endDate: { gte: input.startDate }, + resource: { + isActive: true, + ...(input.chapter ? { chapter: { contains: input.chapter, mode: "insensitive" } } : {}), + ...(input.roleName ? { areaRole: { name: { contains: input.roleName, mode: "insensitive" } } } : {}), + }, + }, + select: { + resourceId: true, + hoursPerDay: true, + startDate: true, + endDate: true, + status: true, + resource: { + select: { + id: true, + eid: true, + displayName: true, + chapter: true, + lcrCents: true, + availability: true, + countryId: true, + federalState: true, + metroCityId: true, + country: { select: { code: true, name: true } }, + metroCity: { select: { name: true } }, + areaRole: { select: { name: true } }, + }, + }, + }, + orderBy: [{ resourceId: "asc" }, { startDate: "asc" }], + }); + const projectAssignments = (Array.isArray(projectAssignmentsResult) + ? projectAssignmentsResult + : []) as BestProjectResourceAssignmentRecord[]; + + if (projectAssignments.length === 0) { + return { + period: { + startDate: toIsoDate(input.startDate), + endDate: toIsoDate(input.endDate), + minHoursPerDay: input.minHoursPerDay, + rankingMode: input.rankingMode, + }, + filters: { + chapter: input.chapter ?? null, + roleName: input.roleName ?? null, + }, + candidateCount: 0, + candidates: [], + bestMatch: null, + note: "No active project resources matched the requested filters in the selected period.", + }; + } + + const resourcesById = new Map(); + const assignmentsOnProjectByResourceId = new Map(); + for (const assignment of projectAssignments) { + resourcesById.set(assignment.resourceId, assignment.resource); + const items = assignmentsOnProjectByResourceId.get(assignment.resourceId) ?? []; + items.push(assignment); + assignmentsOnProjectByResourceId.set(assignment.resourceId, items); + } + + const resourceIds = [...resourcesById.keys()]; + const overlappingAssignmentsResult = await db.assignment.findMany({ + where: { + resourceId: { in: resourceIds }, + status: { not: "CANCELLED" }, + startDate: { lte: input.endDate }, + endDate: { gte: input.startDate }, + }, + select: { + resourceId: true, + projectId: true, + hoursPerDay: true, + startDate: true, + endDate: true, + status: true, + project: { select: { name: true, shortCode: true } }, + }, + orderBy: [{ resourceId: "asc" }, { startDate: "asc" }], + }); + const overlappingAssignments = (Array.isArray(overlappingAssignmentsResult) + ? overlappingAssignmentsResult + : []) as BestProjectResourceOverlapAssignmentRecord[]; + + const assignmentsByResourceId = new Map(); + for (const assignment of overlappingAssignments) { + const items = assignmentsByResourceId.get(assignment.resourceId) ?? []; + items.push(assignment); + assignmentsByResourceId.set(assignment.resourceId, items); + } + + const resources = [...resourcesById.values()]; + const contexts = 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, + })), + input.startDate, + input.endDate, + ); + + const candidates = resources.map((resource) => { + const availability = resource.availability as unknown as WeekdayAvailability; + const context = contexts.get(resource.id); + const baseWorkingDays = countEffectiveWorkingDays({ + availability, + periodStart: input.startDate, + periodEnd: input.endDate, + context: undefined, + }); + const workingDays = countEffectiveWorkingDays({ + availability, + periodStart: input.startDate, + periodEnd: input.endDate, + context, + }); + const baseAvailableHours = calculateEffectiveAvailableHours({ + availability, + periodStart: input.startDate, + periodEnd: input.endDate, + context: undefined, + }); + const availableHours = calculateEffectiveAvailableHours({ + availability, + periodStart: input.startDate, + periodEnd: input.endDate, + context, + }); + const assignments = assignmentsByResourceId.get(resource.id) ?? []; + const bookedHours = assignments.reduce( + (sum, assignment) => + sum + calculateEffectiveBookedHours({ + availability, + startDate: assignment.startDate, + endDate: assignment.endDate, + hoursPerDay: assignment.hoursPerDay, + periodStart: input.startDate, + periodEnd: input.endDate, + context, + }), + 0, + ); + const projectHours = (assignmentsOnProjectByResourceId.get(resource.id) ?? []).reduce( + (sum, assignment) => + sum + calculateEffectiveBookedHours({ + availability, + startDate: assignment.startDate, + endDate: assignment.endDate, + hoursPerDay: assignment.hoursPerDay, + periodStart: input.startDate, + periodEnd: input.endDate, + context, + }), + 0, + ); + let excludedCapacityDays = 0; + for (const fraction of context?.absenceFractionsByDate.values() ?? []) { + excludedCapacityDays += fraction; + } + const holidayWorkdayCount = [...(context?.holidayDates ?? new Set())].reduce((count, isoDate) => ( + count + (getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0) + ), 0); + const holidayHoursDeduction = [...(context?.holidayDates ?? new Set())].reduce((sum, isoDate) => ( + sum + getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)) + ), 0); + let absenceDayEquivalent = 0; + let absenceHoursDeduction = 0; + for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) { + const dayHours = getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)); + if (dayHours <= 0 || context?.holidayDates.has(isoDate)) { + continue; + } + absenceDayEquivalent += fraction; + absenceHoursDeduction += dayHours * fraction; + } + + const remainingHours = Math.max(0, availableHours - bookedHours); + const remainingHoursPerDay = averagePerWorkingDay(remainingHours, workingDays); + + return { + id: resource.id, + eid: resource.eid, + name: resource.displayName, + role: resource.areaRole?.name ?? null, + chapter: resource.chapter ?? null, + country: resource.country?.name ?? resource.country?.code ?? null, + countryCode: resource.country?.code ?? null, + federalState: resource.federalState ?? null, + metroCity: resource.metroCity?.name ?? null, + lcrCents: resource.lcrCents ?? null, + lcr: resource.lcrCents != null ? fmtEur(resource.lcrCents) : null, + baseWorkingDays: round1(baseWorkingDays), + workingDays, + excludedCapacityDays: round1(excludedCapacityDays), + baseAvailableHours: round1(baseAvailableHours), + availableHours: round1(availableHours), + bookedHours: round1(bookedHours), + remainingHours: round1(remainingHours), + remainingHoursPerDay, + projectHours: round1(projectHours), + assignmentCount: assignments.length, + holidaySummary: { + count: context?.holidayDates.size ?? 0, + workdayCount: holidayWorkdayCount, + hoursDeduction: round1(holidayHoursDeduction), + holidayDates: [...(context?.holidayDates ?? new Set())].sort(), + }, + absenceSummary: { + dayEquivalent: round1(absenceDayEquivalent), + hoursDeduction: round1(absenceHoursDeduction), + }, + capacityBreakdown: { + formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours", + baseAvailableHours: round1(baseAvailableHours), + holidayHoursDeduction: round1(holidayHoursDeduction), + absenceHoursDeduction: round1(absenceHoursDeduction), + availableHours: round1(availableHours), + }, + }; + }).filter((candidate) => candidate.remainingHoursPerDay >= input.minHoursPerDay); + + candidates.sort((left, right) => { + if (input.rankingMode === "highest_remaining_hours_per_day") { + return right.remainingHoursPerDay - left.remainingHoursPerDay + || right.remainingHours - left.remainingHours + || (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER); + } + if (input.rankingMode === "highest_remaining_hours") { + return right.remainingHours - left.remainingHours + || right.remainingHoursPerDay - left.remainingHoursPerDay + || (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER); + } + return (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER) + || right.remainingHoursPerDay - left.remainingHoursPerDay + || right.remainingHours - left.remainingHours; + }); + + return { + period: { + startDate: toIsoDate(input.startDate), + endDate: toIsoDate(input.endDate), + minHoursPerDay: input.minHoursPerDay, + rankingMode: input.rankingMode, + }, + filters: { + chapter: input.chapter ?? null, + roleName: input.roleName ?? null, + }, + candidateCount: candidates.length, + bestMatch: candidates[0] ?? null, + candidates, + }; +} + export const staffingRouter = createTRPCRouter({ /** * Get ranked resource suggestions for a staffing requirement. @@ -120,35 +875,123 @@ export const staffingRouter = createTRPCRouter({ minProficiency: z.number().min(1).max(5).optional(), }), ) + .query(async ({ ctx, input }) => queryStaffingSuggestions(ctx.db as unknown as StaffingSuggestionsDbClient, input)), + + getProjectStaffingSuggestions: protectedProcedure + .input( + z.object({ + projectId: z.string().min(1), + roleName: z.string().optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), + limit: z.number().int().min(1).max(50).optional().default(5), + }), + ) .query(async ({ ctx, input }) => { - const { requiredSkills, preferredSkills, startDate, endDate, hoursPerDay, budgetLcrCentsPerHour, chapter, skillCategory, mainSkillsOnly, minProficiency } = input; + const project = await findUniqueOrThrow(ctx.db.project.findUnique({ + where: { id: input.projectId }, + select: { + id: true, + shortCode: true, + name: true, + startDate: true, + endDate: true, + }, + }), "Project"); + const startDate = input.startDate ?? project.startDate ?? new Date(); + const endDate = input.endDate ?? project.endDate ?? new Date(); + const normalizedRoleFilter = input.roleName?.trim().toLowerCase(); + const suggestions = await queryStaffingSuggestions(ctx.db as unknown as StaffingSuggestionsDbClient, { + requiredSkills: [], + startDate, + endDate, + hoursPerDay: 8, + }); + + return { + project: `${project.name} (${project.shortCode})`, + period: `${fmtDate(startDate)} to ${fmtDate(endDate)}`, + suggestions: suggestions + .filter((suggestion) => { + if (!normalizedRoleFilter) { + return true; + } + return suggestion.role?.toLowerCase().includes(normalizedRoleFilter) ?? false; + }) + .map((suggestion) => ({ + id: suggestion.resourceId, + name: suggestion.resourceName, + eid: suggestion.eid, + role: suggestion.role, + chapter: suggestion.chapter, + fte: round1(suggestion.fte ?? 0), + lcr: fmtEur(Math.round((suggestion.estimatedDailyCostCents ?? 0) / 8)), + workingDays: round1(suggestion.capacity.effectiveWorkingDays), + availableHours: round1(suggestion.capacity.remainingHours), + bookedHours: round1(suggestion.capacity.bookedHours), + availableHoursPerDay: round1(suggestion.capacity.remainingHoursPerDay), + utilization: round1(suggestion.currentUtilization ?? 0), + })) + .filter((suggestion) => suggestion.availableHours > 0) + .slice(0, input.limit), + }; + }), + + searchCapacity: protectedProcedure + .input( + z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + minHoursPerDay: z.number().optional().default(4), + roleName: z.string().optional(), + chapter: z.string().optional(), + limit: z.number().int().min(1).max(100).optional().default(20), + }), + ) + .query(async ({ ctx, input }) => { + const where: Record = { isActive: true }; + if (input.roleName) { + where.areaRole = { name: { contains: input.roleName, mode: "insensitive" } }; + } + if (input.chapter) { + where.chapter = { contains: input.chapter, mode: "insensitive" }; + } const resources = await ctx.db.resource.findMany({ - where: { - isActive: true, - ...(chapter ? { chapter } : {}), - }, + where, select: { id: true, displayName: true, eid: true, - skills: true, - lcrCents: true, - chargeabilityTarget: true, + fte: true, availability: true, - valueScore: true, countryId: true, federalState: true, metroCityId: true, - country: { select: { code: true, name: true } }, + country: { select: { code: true } }, metroCity: { select: { name: true } }, + areaRole: { select: { name: true } }, + chapter: true, }, + take: 100, }); + const bookings = await listAssignmentBookings(ctx.db, { - startDate, - endDate, + startDate: input.startDate, + endDate: input.endDate, resourceIds: resources.map((resource) => resource.id), }); + + const bookingsByResourceId = new Map(); + for (const booking of bookings) { + if (!booking.resourceId) { + continue; + } + const existing = bookingsByResourceId.get(booking.resourceId) ?? []; + existing.push(booking); + bookingsByResourceId.set(booking.resourceId, existing); + } + const contexts = await loadResourceDailyAvailabilityContexts( ctx.db, resources.map((resource) => ({ @@ -160,289 +1003,62 @@ export const staffingRouter = createTRPCRouter({ metroCityId: resource.metroCityId, metroCityName: resource.metroCity?.name, })), - startDate, - endDate, + input.startDate, + input.endDate, ); - const bookingsByResourceId = new Map(); - for (const booking of bookings) { - if (!booking.resourceId) { - continue; - } - const items = bookingsByResourceId.get(booking.resourceId) ?? []; - items.push(booking); - bookingsByResourceId.set(booking.resourceId, items); - } - // Compute utilization percent for each resource in the requested period - const enrichedResources = resources.map((resource) => { - const availability = resource.availability as unknown as WeekdayAvailability; - const context = contexts.get(resource.id); - const resourceBookings = bookingsByResourceId.get(resource.id) ?? []; - const activeBookings = resourceBookings.filter((booking) => ACTIVE_STATUSES.has(booking.status)); - const baseAvailableHours = calculateEffectiveAvailableHours({ - availability, - periodStart: startDate, - periodEnd: endDate, - context: undefined, - }); - const totalAvailableHours = calculateEffectiveAvailableHours({ - availability, - periodStart: startDate, - periodEnd: endDate, - context, - }); - const baseWorkingDays = countEffectiveWorkingDays({ - availability, - periodStart: startDate, - periodEnd: endDate, - context: undefined, - }); - const effectiveWorkingDays = countEffectiveWorkingDays({ - availability, - periodStart: startDate, - periodEnd: endDate, - context, - }); - const allocatedHours = activeBookings.reduce( - (sum, booking) => - sum + calculateEffectiveBookedHours({ - availability, - startDate: booking.startDate, - endDate: booking.endDate, - hoursPerDay: booking.hoursPerDay, - periodStart: startDate, - periodEnd: endDate, - context, - }), - 0, - ); - const holidayDates = [...(context?.holidayDates ?? new Set())].sort(); - const holidayWorkdayCount = holidayDates.reduce((count, isoDate) => ( - count + (getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0) - ), 0); - const holidayHoursDeduction = holidayDates.reduce((sum, isoDate) => ( - sum + getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)) - ), 0); - let absenceDayEquivalent = 0; - let absenceHoursDeduction = 0; - for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) { - const dayHours = getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)); - if (dayHours <= 0 || context?.holidayDates.has(isoDate)) { - continue; - } - absenceDayEquivalent += fraction; - absenceHoursDeduction += dayHours * fraction; - } - const conflictDays: string[] = []; - const conflictDetails: Array<{ - date: string; - baseHours: number; - effectiveHours: number; - allocatedHours: number; - remainingHours: number; - requestedHours: number; - shortageHours: number; - absenceFraction: number; - isHoliday: boolean; - }> = []; - const cursor = new Date(startDate); - cursor.setUTCHours(0, 0, 0, 0); - const periodEndAtMidnight = new Date(endDate); - periodEndAtMidnight.setUTCHours(0, 0, 0, 0); - - while (cursor <= periodEndAtMidnight) { - const isoDate = toIsoDate(cursor); - const baseHoursForDay = getBaseDayAvailability(availability, cursor); - const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context); - const isHoliday = context?.holidayDates.has(isoDate) ?? false; - const absenceFraction = Math.min( - 1, - Math.max(0, context?.absenceFractionsByDate.get(isoDate) ?? 0), + const results = resources + .map((resource) => { + const availability = resource.availability as unknown as WeekdayAvailability; + const context = contexts.get(resource.id); + const workingDays = countEffectiveWorkingDays({ + availability, + periodStart: input.startDate, + periodEnd: input.endDate, + context, + }); + const availableHours = calculateEffectiveAvailableHours({ + availability, + periodStart: input.startDate, + periodEnd: input.endDate, + context, + }); + const bookedHours = (bookingsByResourceId.get(resource.id) ?? []).reduce( + (sum, booking) => + sum + calculateEffectiveBookedHours({ + availability, + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + periodStart: input.startDate, + periodEnd: input.endDate, + context, + }), + 0, ); - if (availableHoursForDay > 0) { - const { allocatedHours: allocatedHoursForDay } = calculateAllocatedHoursForDay({ - bookings: activeBookings, - date: cursor, - context, - }); - if (allocatedHoursForDay + hoursPerDay > availableHoursForDay) { - const remainingHoursForDay = Math.max(0, availableHoursForDay - allocatedHoursForDay); - conflictDays.push(isoDate); - conflictDetails.push({ - date: isoDate, - baseHours: round1(baseHoursForDay), - effectiveHours: round1(availableHoursForDay), - allocatedHours: round1(allocatedHoursForDay), - remainingHours: round1(remainingHoursForDay), - requestedHours: round1(hoursPerDay), - shortageHours: round1(Math.max(0, hoursPerDay - remainingHoursForDay)), - absenceFraction: round1(absenceFraction), - isHoliday, - }); - } - } - cursor.setUTCDate(cursor.getUTCDate() + 1); - } + const remainingHours = Math.max(0, availableHours - bookedHours); - const remainingHours = Math.max(0, totalAvailableHours - allocatedHours); - const remainingHoursPerDay = averagePerWorkingDay(remainingHours, effectiveWorkingDays); - const utilizationPercent = - totalAvailableHours > 0 - ? Math.min(100, (allocatedHours / totalAvailableHours) * 100) - : 0; + return { + id: resource.id, + name: resource.displayName, + eid: resource.eid, + role: resource.areaRole?.name ?? null, + chapter: resource.chapter, + workingDays, + availableHours: round1(remainingHours), + availableHoursPerDay: averagePerWorkingDay(remainingHours, workingDays), + }; + }) + .filter((resource) => resource.availableHoursPerDay >= input.minHoursPerDay) + .sort((left, right) => right.availableHours - left.availableHours) + .slice(0, input.limit); - type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean }; - let skills = resource.skills as unknown as SkillRow[]; - - // Apply skill filters before matching - if (mainSkillsOnly) skills = skills.filter((s) => s.isMainSkill); - if (skillCategory) skills = skills.filter((s) => s.category === skillCategory); - if (minProficiency) skills = skills.filter((s) => s.proficiency >= minProficiency); - - return { - id: resource.id, - displayName: resource.displayName, - eid: resource.eid, - skills: skills as unknown as import("@capakraken/shared").SkillEntry[], - lcrCents: resource.lcrCents, - chargeabilityTarget: resource.chargeabilityTarget, - currentUtilizationPercent: utilizationPercent, - hasAvailabilityConflicts: conflictDays.length > 0, - conflictDays, - valueScore: resource.valueScore ?? 0, - transparency: { - location: { - countryCode: resource.country?.code ?? null, - countryName: resource.country?.name ?? null, - federalState: resource.federalState ?? null, - metroCityName: resource.metroCity?.name ?? null, - label: createLocationLabel({ - countryCode: resource.country?.code ?? null, - federalState: resource.federalState, - metroCityName: resource.metroCity?.name ?? null, - }), - }, - capacity: { - requestedHoursPerDay: round1(hoursPerDay), - requestedHoursTotal: round1(effectiveWorkingDays * hoursPerDay), - baseWorkingDays: round1(baseWorkingDays), - effectiveWorkingDays: round1(effectiveWorkingDays), - baseAvailableHours: round1(baseAvailableHours), - effectiveAvailableHours: round1(totalAvailableHours), - bookedHours: round1(allocatedHours), - remainingHours: round1(remainingHours), - remainingHoursPerDay, - holidayCount: holidayDates.length, - holidayWorkdayCount, - holidayHoursDeduction: round1(holidayHoursDeduction), - absenceDayEquivalent: round1(absenceDayEquivalent), - absenceHoursDeduction: round1(absenceHoursDeduction), - }, - conflicts: { - count: conflictDays.length, - conflictDays, - details: conflictDetails, - }, - }, - }; - }); - - const ranked = rankResources({ - requiredSkills, - preferredSkills: preferredSkills, - resources: enrichedResources, - budgetLcrCentsPerHour, - } as unknown as Parameters[0]); - const baseRankIndex = new Map(ranked.map((suggestion, index) => [suggestion.resourceId, index])); - - // Value-score tiebreaker: within 2 points, prefer higher valueScore - return [...ranked].sort((a, b) => { - if (Math.abs(a.score - b.score) <= 2) { - const aVal = (enrichedResources.find((r) => r.id === a.resourceId)?.valueScore ?? 0); - const bVal = (enrichedResources.find((r) => r.id === b.resourceId)?.valueScore ?? 0); - return bVal - aVal; - } - return 0; - }).map((suggestion, index) => { - const resource = enrichedResources.find((item) => item.id === suggestion.resourceId); - const fallbackBreakdown = "breakdown" in suggestion - ? (suggestion as { breakdown?: { skillScore: number; availabilityScore: number; costScore: number; utilizationScore: number } }).breakdown - : undefined; - const scoreBreakdown = suggestion.scoreBreakdown ?? { - skillScore: fallbackBreakdown?.skillScore ?? 0, - availabilityScore: fallbackBreakdown?.availabilityScore ?? 0, - costScore: fallbackBreakdown?.costScore ?? 0, - utilizationScore: fallbackBreakdown?.utilizationScore ?? 0, - total: suggestion.score, - }; - const baseRank = (baseRankIndex.get(suggestion.resourceId) ?? index) + 1; - const tieBreakerApplied = baseRank !== index + 1; - - return { - ...suggestion, - resourceName: suggestion.resourceName ?? resource?.displayName ?? "", - eid: suggestion.eid ?? resource?.eid ?? "", - scoreBreakdown, - matchedSkills: suggestion.matchedSkills ?? requiredSkills.filter((skill) => - resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()), - ), - missingSkills: suggestion.missingSkills ?? requiredSkills.filter((skill) => - !resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()), - ), - availabilityConflicts: suggestion.availabilityConflicts ?? resource?.conflictDays ?? [], - estimatedDailyCostCents: suggestion.estimatedDailyCostCents ?? ((resource?.lcrCents ?? 0) * 8), - currentUtilization: suggestion.currentUtilization ?? round1(resource?.currentUtilizationPercent ?? 0), - valueScore: resource?.valueScore ?? 0, - location: resource?.transparency.location ?? { - countryCode: null, - countryName: null, - federalState: null, - metroCityName: null, - label: "", - }, - capacity: resource?.transparency.capacity ?? { - requestedHoursPerDay: round1(hoursPerDay), - requestedHoursTotal: 0, - baseWorkingDays: 0, - effectiveWorkingDays: 0, - baseAvailableHours: 0, - effectiveAvailableHours: 0, - bookedHours: 0, - remainingHours: 0, - remainingHoursPerDay: 0, - holidayCount: 0, - holidayWorkdayCount: 0, - holidayHoursDeduction: 0, - absenceDayEquivalent: 0, - absenceHoursDeduction: 0, - }, - conflicts: resource?.transparency.conflicts ?? { - count: 0, - conflictDays: [], - details: [], - }, - ranking: { - rank: index + 1, - baseRank, - tieBreakerApplied, - tieBreakerReason: tieBreakerApplied - ? "Within 2 score points, higher value score moves the candidate up." - : null, - model: "Composite ranking across skill fit, availability, cost, and utilization.", - components: [ - { key: "skillScore", label: "Skills", score: scoreBreakdown.skillScore }, - { key: "availabilityScore", label: "Availability", score: scoreBreakdown.availabilityScore }, - { key: "costScore", label: "Cost", score: scoreBreakdown.costScore }, - { key: "utilizationScore", label: "Utilization", score: scoreBreakdown.utilizationScore }, - ], - }, - remainingHoursPerDay: resource?.transparency.capacity.remainingHoursPerDay ?? 0, - remainingHours: resource?.transparency.capacity.remainingHours ?? 0, - effectiveAvailableHours: resource?.transparency.capacity.effectiveAvailableHours ?? 0, - baseAvailableHours: resource?.transparency.capacity.baseAvailableHours ?? 0, - holidayHoursDeduction: resource?.transparency.capacity.holidayHoursDeduction ?? 0, - }; - }); + return { + period: `${toIsoDate(input.startDate)} to ${toIsoDate(input.endDate)}`, + minHoursFilter: input.minHoursPerDay, + results, + totalFound: results.length, + }; }), /** @@ -690,4 +1306,69 @@ export const staffingRouter = createTRPCRouter({ return windows; }), + + findBestProjectResource: protectedProcedure + .input( + z.object({ + projectId: z.string(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + minHoursPerDay: z.number().min(0).default(3), + rankingMode: z.enum(["lowest_lcr", "highest_remaining_hours_per_day", "highest_remaining_hours"]).default("lowest_lcr"), + chapter: z.string().optional(), + roleName: z.string().optional(), + }), + ) + .query(async ({ ctx, input }) => queryBestProjectResource(ctx.db as unknown as BestProjectResourceDbClient, input)), + + getBestProjectResourceDetail: protectedProcedure + .input( + z.object({ + projectId: z.string().min(1), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), + durationDays: z.number().int().min(1).optional(), + minHoursPerDay: z.number().min(0).default(3), + rankingMode: z.enum(["lowest_lcr", "highest_remaining_hours_per_day", "highest_remaining_hours"]).default("lowest_lcr"), + chapter: z.string().optional(), + roleName: z.string().optional(), + }), + ) + .query(async ({ ctx, input }) => { + const project = await findUniqueOrThrow(ctx.db.project.findUnique({ + where: { id: input.projectId }, + select: { + id: true, + name: true, + shortCode: true, + status: true, + responsiblePerson: true, + }, + }), "Project"); + const { startDate, endDate } = createDateRange({ + startDate: input.startDate, + endDate: input.endDate, + durationDays: input.durationDays, + }); + const result = await queryBestProjectResource(ctx.db as unknown as BestProjectResourceDbClient, { + projectId: input.projectId, + startDate, + endDate, + minHoursPerDay: input.minHoursPerDay, + rankingMode: input.rankingMode, + ...(input.chapter ? { chapter: input.chapter } : {}), + ...(input.roleName ? { roleName: input.roleName } : {}), + }); + + return { + ...result, + project: { + id: project.id, + name: project.name, + shortCode: project.shortCode, + status: project.status, + responsiblePerson: project.responsiblePerson, + }, + }; + }), }); diff --git a/packages/api/src/router/timeline.ts b/packages/api/src/router/timeline.ts index 66841c2..a565d51 100644 --- a/packages/api/src/router/timeline.ts +++ b/packages/api/src/router/timeline.ts @@ -8,10 +8,10 @@ import { updateDemandRequirement, updateAllocationEntry, } from "@capakraken/application"; +import { Prisma, VacationType } from "@capakraken/db"; import type { PrismaClient } from "@capakraken/db"; import { calculateAllocation, computeBudgetStatus, validateShift, DEFAULT_CALCULATION_RULES } from "@capakraken/engine"; import type { CalculationRule, AbsenceDay } from "@capakraken/shared"; -import { VacationType } from "@capakraken/db"; import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; @@ -28,8 +28,10 @@ import { } from "../sse/event-bus.js"; import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.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 { logger } from "../lib/logger.js"; +import type { TRPCContext } from "../trpc.js"; type ShiftDbClient = Pick< PrismaClient, @@ -52,6 +54,20 @@ export type TimelineEntriesFilters = { 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; +type TimelineSelfServiceContext = Pick; + export function getAssignmentResourceIds( readModel: ReturnType, ): 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 { + 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 { + 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 { + 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(); + const resourceIds = new Set(); + + 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, +) { + const resourceIds = new Set(); + const byScope = new Map(); + + 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( db: TimelineEntriesDbClient, input: TimelineEntriesFilters, @@ -147,6 +372,14 @@ export async function loadTimelineHolidayOverlays( input: TimelineEntriesFilters, ) { const readModel = await loadTimelineEntriesReadModel(db, input); + return loadTimelineHolidayOverlaysForReadModel(db, input, readModel); +} + +async function loadTimelineHolidayOverlaysForReadModel( + db: TimelineEntriesDbClient, + input: TimelineEntriesFilters, + readModel: ReturnType, +) { const resourceIds = [...new Set( readModel.assignments .map((assignment) => assignment.resourceId) @@ -380,17 +613,56 @@ function anonymizeResourceOnEntry 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 { + const calculationRuleModel = (db as PrismaClient & { + calculationRule?: { findMany?: (args: unknown) => Promise }; + }).calculationRule; + + if (!calculationRuleModel || typeof calculationRuleModel.findMany !== "function") { + return DEFAULT_CALCULATION_RULES; + } + try { - const rules = await db.calculationRule.findMany({ + const rules = await calculationRuleModel.findMany({ where: { isActive: true }, orderBy: [{ priority: "desc" }], }); if (rules.length > 0) { return rules as unknown as CalculationRule[]; } - } catch { - // table may not exist yet + } catch (error) { + 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; } @@ -440,8 +712,14 @@ async function buildAbsenceDays( cur.setDate(cur.getDate() + 1); } } - } catch { - // vacation table may not exist yet + } catch (error) { + if (!isMissingOptionalTableError(error, ["vacation", "vacations"])) { + logger.error( + { err: error, resourceId, startDate, endDate }, + "Failed to load timeline absence days", + ); + throw error; + } } return { absenceDays, legacyVacationDates }; @@ -452,38 +730,16 @@ export const timelineRouter = createTRPCRouter({ * Get all timeline entries (projects + allocations) for a date range. * Includes project startDate, endDate, staffingReqs for demand overlay. */ - getEntries: protectedProcedure - .input( - 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(), - }), - ) + getEntries: controllerProcedure + .input(TimelineWindowFiltersSchema) .query(async ({ ctx, input }) => { const readModel = await loadTimelineEntriesReadModel(ctx.db, input); const directory = await getAnonymizationDirectory(ctx.db); return readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)); }), - getEntriesView: protectedProcedure - .input( - 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(), - }), - ) + getEntriesView: controllerProcedure + .input(TimelineWindowFiltersSchema) .query(async ({ ctx, input }) => { const [readModel, directory] = await Promise.all([ 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( z.object({ - startDate: z.coerce.date(), - endDate: z.coerce.date(), + 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(), @@ -510,7 +802,73 @@ export const timelineRouter = createTRPCRouter({ 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: @@ -519,7 +877,7 @@ export const timelineRouter = createTRPCRouter({ * - all assignment bookings for the same resources (for cross-project overlap display) * Used when: drag starts or project panel opens. */ - getProjectContext: protectedProcedure + getProjectContext: controllerProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { 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. * Recalculates dailyCostCents and emits SSE. @@ -682,10 +1156,50 @@ export const timelineRouter = createTRPCRouter({ * Preview a project shift — validate without committing. * Returns cost impact, conflicts, warnings. */ - previewShift: protectedProcedure + previewShift: controllerProcedure .input(ShiftProjectSchema) .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. * Reads includeSaturday from each allocation's metadata. @@ -1044,7 +1558,7 @@ export const timelineRouter = createTRPCRouter({ /** * Get budget status for a project. */ - getBudgetStatus: protectedProcedure + getBudgetStatus: controllerProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { const project = await findUniqueOrThrow( @@ -1052,6 +1566,8 @@ export const timelineRouter = createTRPCRouter({ where: { id: input.projectId }, select: { id: true, + name: true, + shortCode: true, budgetCents: true, winProbability: true, startDate: true, @@ -1066,7 +1582,7 @@ export const timelineRouter = createTRPCRouter({ projectIds: [project.id], }); - return computeBudgetStatus( + const budgetStatus = computeBudgetStatus( project.budgetCents, project.winProbability, bookings.map((booking) => ({ @@ -1079,5 +1595,13 @@ export const timelineRouter = createTRPCRouter({ project.startDate, project.endDate, ); + + return { + ...budgetStatus, + projectName: project.name, + projectCode: project.shortCode, + totalAllocations: bookings.length, + budgetCents: project.budgetCents, + }; }), }); diff --git a/packages/api/src/router/vacation.ts b/packages/api/src/router/vacation.ts index b4aa26d..f7f00c0 100644 --- a/packages/api/src/router/vacation.ts +++ b/packages/api/src/router/vacation.ts @@ -15,14 +15,126 @@ import { createAuditEntry } from "../lib/audit.js"; import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js"; import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.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 */ const BALANCE_TYPES = new Set([VacationType.ANNUAL, VacationType.OTHER]); +type VacationReadContext = Pick; + +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 = {}, +): void { + void Promise.resolve() + .then(execute) + .catch((error) => { + logger.error( + { err: error, effectName, ...metadata }, + "Vacation background side effect failed", + ); + }); +} + +function notifyVacationStatusInBackground( + db: Parameters[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[0]>[0]["ctx"]["db"], + event: string, + payload: Record, +): void { + runVacationBackgroundEffect( + "dispatchWebhooks", + () => dispatchWebhooks(db, event, payload), + { event }, + ); +} + +async function findOwnedResourceId( + ctx: VacationReadContext, +): Promise { + 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 { + 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 { 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({ resourceId: z.string(), type: z.nativeEnum(VacationType), @@ -224,9 +336,25 @@ export const vacationRouter = createTRPCRouter({ }), ) .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({ where: { - ...(input.resourceId ? { resourceId: input.resourceId } : {}), + ...(resourceIdFilter ? { resourceId: resourceIdFilter } : {}), ...(input.status ? { status: Array.isArray(input.status) ? { in: input.status } : input.status } : {}), ...(input.type ? { type: input.type } : {}), ...(input.startDate ? { endDate: { gte: input.startDate } } : {}), @@ -254,15 +382,38 @@ export const vacationRouter = createTRPCRouter({ ctx.db.vacation.findUnique({ where: { id: input.id }, include: { - resource: { select: RESOURCE_BRIEF_SELECT }, + resource: { select: { ...RESOURCE_BRIEF_SELECT, userId: true } }, requestedBy: { select: { id: true, name: true, email: true } }, approvedBy: { select: { id: true, name: true, email: true } }, }, }), "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); - 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})`, }); - void dispatchWebhooks(ctx.db, "vacation.approved", { + dispatchVacationWebhookInBackground(ctx.db, "vacation.approved", { id: updated.id, resourceId: updated.resourceId, startDate: updated.startDate.toISOString(), @@ -497,7 +648,7 @@ export const vacationRouter = createTRPCRouter({ }); 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 }; @@ -558,7 +709,13 @@ export const vacationRouter = createTRPCRouter({ 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; }), @@ -599,7 +756,7 @@ export const vacationRouter = createTRPCRouter({ for (const v of vacations) { 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({ db: ctx.db, @@ -668,7 +825,13 @@ export const vacationRouter = createTRPCRouter({ for (const v of vacations) { 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({ db: ctx.db, @@ -773,6 +936,8 @@ export const vacationRouter = createTRPCRouter({ }), ) .query(async ({ ctx, input }) => { + await assertCanReadVacationResource(ctx, input.resourceId); + return ctx.db.vacation.findMany({ where: { resourceId: input.resourceId, @@ -798,7 +963,7 @@ export const vacationRouter = createTRPCRouter({ return ctx.db.vacation.findMany({ where: { status: VacationStatus.PENDING }, include: { - resource: { select: RESOURCE_BRIEF_SELECT }, + resource: { select: { ...RESOURCE_BRIEF_SELECT, chapter: true } }, requestedBy: { select: { id: true, name: true, email: true } }, }, orderBy: { startDate: "asc" }, @@ -818,6 +983,8 @@ export const vacationRouter = createTRPCRouter({ }), ) .query(async ({ ctx, input }) => { + await assertCanReadVacationResource(ctx, input.resourceId); + // Find the chapter of the requesting resource const resource = await ctx.db.resource.findUnique({ 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. * Admin-only. Creates as APPROVED automatically. diff --git a/packages/api/src/sse/event-bus.ts b/packages/api/src/sse/event-bus.ts index 11f977b..53b7708 100644 --- a/packages/api/src/sse/event-bus.ts +++ b/packages/api/src/sse/event-bus.ts @@ -1,20 +1,34 @@ 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 { type: SseEventType; payload: Record; timestamp: string; + audience: SseAudience[]; } type Subscriber = (event: SseEvent) => void; +interface Subscription { + fn: Subscriber; + audiences: Set; + includeUnscoped: boolean; +} + +export interface SseSubscriptionOptions { + audiences?: Iterable; + includeUnscoped?: boolean; +} + // Module-level subscriber registry (shared between EventBus and publishLocal) -const subscribers = new Set(); +const subscribers = new Set(); // --------------------------------------------------------------------------- -// Debounce buffer: aggregates rapid events of the same type within a 50ms -// window and delivers a single event per type to subscribers. +// Debounce buffer: aggregates rapid events of the same type and audience within +// a 50ms window and delivers a single event per scope to subscribers. // --------------------------------------------------------------------------- const DEBOUNCE_MS = 50; @@ -23,48 +37,76 @@ interface BufferEntry { payloads: Record[]; timer: ReturnType; firstTimestamp: string; + audience: SseAudience[]; } -const debounceBuffer = new Map(); +const debounceBuffer = new Map(); + +function normalizeAudiences(audiences?: Iterable): 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. */ -function flushEventType(type: SseEventType): void { - const entry = debounceBuffer.get(type); +function flushEventType(type: SseEventType, audience: readonly SseAudience[]): void { + const key = getBufferKey(type, audience); + const entry = debounceBuffer.get(key); if (!entry) return; - debounceBuffer.delete(type); + debounceBuffer.delete(key); const event: SseEvent = entry.payloads.length === 1 - ? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp } + ? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp, audience: entry.audience } : { type, payload: { _batch: entry.payloads }, timestamp: entry.firstTimestamp, + audience: entry.audience, }; - for (const fn of subscribers) { - fn(event); - } + deliverEvent(event); } /** Flush all pending debounce timers immediately (for cleanup / tests). */ export function flushPendingEvents(): void { - for (const [type, entry] of debounceBuffer) { + for (const [key, entry] of debounceBuffer) { clearTimeout(entry.timer); - debounceBuffer.delete(type); + debounceBuffer.delete(key); + const [type] = key.split("::") as [SseEventType]; const event: SseEvent = entry.payloads.length === 1 - ? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp } + ? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp, audience: entry.audience } : { type, payload: { _batch: entry.payloads }, timestamp: entry.firstTimestamp, + audience: entry.audience, }; - for (const fn of subscribers) { - fn(event); - } + deliverEvent(event); } } @@ -101,9 +143,21 @@ function setupSubscriber(): void { }); subscriber.on("message", (_channel: string, message: string) => { try { - const parsed = JSON.parse(message) as { type: SseEventType; payload: Record; timestamp: string }; - publishLocal({ type: parsed.type, payload: parsed.payload, timestamp: parsed.timestamp }); - } catch { /* ignore parse errors */ } + const parsed = JSON.parse(message) as { + type: SseEventType; + payload: Record; + timestamp: string; + audience?: SseAudience[]; + }; + publishLocal({ + type: parsed.type, + payload: parsed.payload, + timestamp: parsed.timestamp, + audience: normalizeAudiences(parsed.audience), + }); + } catch { + // ignore parse errors + } }); } catch (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. */ class EventBus { - subscribe(fn: Subscriber): () => void { - subscribers.add(fn); - return () => subscribers.delete(fn); + subscribe(fn: Subscriber, options: SseSubscriptionOptions = {}): () => void { + const subscription: Subscription = { + fn, + audiences: new Set(normalizeAudiences(options.audiences)), + includeUnscoped: options.includeUnscoped ?? true, + }; + subscribers.add(subscription); + return () => subscribers.delete(subscription); } publish(event: SseEvent): void { + const normalizedEvent: SseEvent = { + ...event, + audience: normalizeAudiences(event.audience), + }; + // Broadcast via Redis (all instances receive via subscriber.on("message")) try { 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) { console.warn("[Redis emit] fallback to local-only:", e); // Deliver locally when Redis is unavailable - publishLocal(event); + publishLocal(normalizedEvent); } } - emit(type: SseEventType, payload: Record): void { + emit(type: SseEventType, payload: Record, audience: Iterable = []): void { this.publish({ type, payload, 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 -// window and then deliver a single (possibly aggregated) event to subscribers. +// Local delivery with debounce: buffer events of the same type and audience +// within a 50ms window and then deliver a single (possibly aggregated) event. 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) { // Another event of the same type is already buffered — append payload and // reset the timer so the window starts fresh from the latest arrival. existing.payloads.push(event.payload); clearTimeout(existing.timer); - existing.timer = setTimeout(() => flushEventType(event.type), DEBOUNCE_MS); + existing.timer = setTimeout(() => flushEventType(event.type, audience), DEBOUNCE_MS); } else { - // First event of this type — start a new debounce window. - debounceBuffer.set(event.type, { + // First event of this type and audience — start a new debounce window. + debounceBuffer.set(key, { payloads: [event.payload], - timer: setTimeout(() => flushEventType(event.type), DEBOUNCE_MS), + timer: setTimeout(() => flushEventType(event.type, audience), DEBOUNCE_MS), firstTimestamp: event.timestamp, + audience, }); } } @@ -174,58 +250,73 @@ setupSubscriber(); // Helper emitters export const emitAllocationCreated = (allocation: Record) => - 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) => - 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) => - 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) => - 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) => - 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) => - 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) => - 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) => - 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) => - 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) => - 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) => - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - eventBus.emit(SSE_EVENT_TYPES.BROADCAST_SENT, { broadcastId, recipientCount }); + eventBus.emit(SSE_EVENT_TYPES.BROADCAST_SENT, { broadcastId, recipientCount }, [ + roleAudience(SystemRole.ADMIN), + roleAudience(SystemRole.MANAGER), + ]); } diff --git a/packages/application/src/__tests__/dashboard.test.ts b/packages/application/src/__tests__/dashboard.test.ts index 16df044..e3a8f98 100644 --- a/packages/application/src/__tests__/dashboard.test.ts +++ b/packages/application/src/__tests__/dashboard.test.ts @@ -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); @@ -83,6 +89,8 @@ describe("dashboard use-cases", () => { { status: "ACTIVE", count: 1 }, { status: "DRAFT", count: 1 }, ]); + expect(result.approvedVacations).toBe(2); + expect(result.totalEstimates).toBe(5); expect(result.chapterUtilization).toEqual([ { chapter: "CGI", resourceCount: 2, avgChargeabilityTarget: 70 }, { chapter: "Unassigned", resourceCount: 1, avgChargeabilityTarget: 0 }, @@ -134,6 +142,12 @@ describe("dashboard use-cases", () => { auditLog: { findMany: vi.fn().mockResolvedValue([]), }, + vacation: { + count: vi.fn().mockResolvedValue(0), + }, + estimate: { + count: vi.fn().mockResolvedValue(0), + }, }; const result = await getDashboardOverview(db as never); @@ -242,12 +256,20 @@ describe("dashboard use-cases", () => { auditLog: { findMany: vi.fn().mockResolvedValue([]), }, + vacation: { + count: vi.fn().mockResolvedValue(1), + }, + estimate: { + count: vi.fn().mockResolvedValue(2), + }, }; const result = await getDashboardOverview(db as never); expect(result.totalAllocations).toBe(3); expect(result.activeAllocations).toBe(2); + expect(result.approvedVacations).toBe(1); + expect(result.totalEstimates).toBe(2); expect(result.budgetSummary).toEqual({ totalBudgetCents: 100_000, totalCostCents: 4_000, @@ -954,6 +976,12 @@ describe("dashboard use-cases", () => { auditLog: { findMany: vi.fn().mockResolvedValue([]), }, + vacation: { + count: vi.fn().mockResolvedValue(1), + }, + estimate: { + count: vi.fn().mockResolvedValue(4), + }, }; const result = await getDashboardOverview(db as never); @@ -963,6 +991,8 @@ describe("dashboard use-cases", () => { totalCostCents: 1_000, avgUtilizationPercent: 10, }); + expect(result.approvedVacations).toBe(1); + expect(result.totalEstimates).toBe(4); }); it("excludes regional public holidays from project health budget usage", async () => { diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index ec01a39..597416a 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -91,7 +91,9 @@ export { type DemandRowDerivation, type DashboardDemandRow, getDashboardSkillGaps, + getDashboardSkillGapSummary, type SkillGapRow, + type DashboardSkillGapSummary, getDashboardProjectHealth, type ProjectHealthRow, } from "./use-cases/dashboard/index.js"; diff --git a/packages/application/src/use-cases/dashboard/get-overview.ts b/packages/application/src/use-cases/dashboard/get-overview.ts index 7c5ac51..333433a 100644 --- a/packages/application/src/use-cases/dashboard/get-overview.ts +++ b/packages/application/src/use-cases/dashboard/get-overview.ts @@ -1,4 +1,5 @@ import type { PrismaClient } from "@capakraken/db"; +import { VacationStatus } from "@capakraken/db"; import { AllocationStatus } from "@capakraken/shared"; import { buildSplitAllocationReadModel } from "../allocation/build-split-allocation-read-model.js"; import { calculateInclusiveDays } from "./shared.js"; @@ -25,6 +26,8 @@ export async function getDashboardOverview(db: PrismaClient) { budgetAssignments, recentActivity, allResources, + approvedVacations, + totalEstimates, ] = await Promise.all([ db.resource.count(), db.resource.count({ where: { isActive: true } }), @@ -95,6 +98,8 @@ export async function getDashboardOverview(db: PrismaClient) { db.resource.findMany({ select: { chapter: true, chargeabilityTarget: true }, }), + db.vacation.count({ where: { status: VacationStatus.APPROVED } }), + db.estimate.count(), ]); const planningReadModel = buildSplitAllocationReadModel({ @@ -200,6 +205,8 @@ export async function getDashboardOverview(db: PrismaClient) { totalAllocations, activeAllocations, cancelledAllocations, + approvedVacations, + totalEstimates, budgetSummary: { totalBudgetCents, totalCostCents, diff --git a/packages/application/src/use-cases/dashboard/get-project-health.ts b/packages/application/src/use-cases/dashboard/get-project-health.ts index 7a9e3cd..90f3631 100644 --- a/packages/application/src/use-cases/dashboard/get-project-health.ts +++ b/packages/application/src/use-cases/dashboard/get-project-health.ts @@ -10,6 +10,7 @@ export interface ProjectHealthRow { id: string; projectName: string; shortCode: string; + status: string; clientId: string | null; clientName: string | null; budgetHealth: number; @@ -74,6 +75,7 @@ export async function getDashboardProjectHealth( id: true, name: true, shortCode: true, + status: true, budgetCents: true, endDate: true, clientId: true, @@ -232,6 +234,7 @@ export async function getDashboardProjectHealth( id: p.id, projectName: p.name, shortCode: p.shortCode, + status: p.status, clientId: p.clientId, clientName: p.client?.name ?? null, budgetHealth, diff --git a/packages/application/src/use-cases/dashboard/get-skill-gaps.ts b/packages/application/src/use-cases/dashboard/get-skill-gaps.ts index 6bdb593..22be674 100644 --- a/packages/application/src/use-cases/dashboard/get-skill-gaps.ts +++ b/packages/application/src/use-cases/dashboard/get-skill-gaps.ts @@ -12,6 +12,31 @@ interface SkillEntry { 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( db: PrismaClient, ): Promise { @@ -87,3 +112,86 @@ export async function getDashboardSkillGaps( rows.sort((a, b) => a.gap - b.gap); return rows.slice(0, 10); } + +export async function getDashboardSkillGapSummary( + db: PrismaClient, +): Promise { + 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(); + 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(); + const supplyByRole = new Map(); + + for (const resource of resources) { + const rawSkills = Array.isArray(resource.skills) + ? resource.skills as Array> + : []; + 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 })), + }; +} diff --git a/packages/application/src/use-cases/dashboard/index.ts b/packages/application/src/use-cases/dashboard/index.ts index 44d7626..d7a87a5 100644 --- a/packages/application/src/use-cases/dashboard/index.ts +++ b/packages/application/src/use-cases/dashboard/index.ts @@ -37,7 +37,9 @@ export { export { getDashboardSkillGaps, + getDashboardSkillGapSummary, type SkillGapRow, + type DashboardSkillGapSummary, } from "./get-skill-gaps.js"; export { diff --git a/packages/db/prisma/migrations/20260310_jsonb_gin_indexes.sql b/packages/db/prisma/migrations/20260310_jsonb_gin_indexes.sql new file mode 100644 index 0000000..5778626 --- /dev/null +++ b/packages/db/prisma/migrations/20260310_jsonb_gin_indexes.sql @@ -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); diff --git a/packages/db/prisma/migrations/20260313_demand_assignment_additive.sql b/packages/db/prisma/migrations/20260313_demand_assignment_additive.sql new file mode 100644 index 0000000..8e2e7b6 --- /dev/null +++ b/packages/db/prisma/migrations/20260313_demand_assignment_additive.sql @@ -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); diff --git a/packages/db/prisma/migrations/20260314_dispo_import_roster_source_kind.sql b/packages/db/prisma/migrations/20260314_dispo_import_roster_source_kind.sql new file mode 100644 index 0000000..08f190c --- /dev/null +++ b/packages/db/prisma/migrations/20260314_dispo_import_roster_source_kind.sql @@ -0,0 +1 @@ +ALTER TYPE "DispoImportSourceKind" ADD VALUE IF NOT EXISTS 'ROSTER'; diff --git a/packages/db/prisma/migrations/20260314_dispo_import_staged_resource_rates.sql b/packages/db/prisma/migrations/20260314_dispo_import_staged_resource_rates.sql new file mode 100644 index 0000000..8fd5886 --- /dev/null +++ b/packages/db/prisma/migrations/20260314_dispo_import_staged_resource_rates.sql @@ -0,0 +1,3 @@ +ALTER TABLE "staged_resources" + ADD COLUMN IF NOT EXISTS "lcrCents" INTEGER, + ADD COLUMN IF NOT EXISTS "ucrCents" INTEGER; diff --git a/packages/db/prisma/migrations/20260314_dispo_import_staging.sql b/packages/db/prisma/migrations/20260314_dispo_import_staging.sql new file mode 100644 index 0000000..78ceb4b --- /dev/null +++ b/packages/db/prisma/migrations/20260314_dispo_import_staging.sql @@ -0,0 +1,328 @@ +-- Dispo import staging foundation +-- Run: psql $DATABASE_URL -f packages/db/prisma/migrations/20260314_dispo_import_staging.sql + +DO $$ +BEGIN + CREATE TYPE "ImportBatchStatus" AS ENUM ( + 'DRAFT', + 'STAGING', + 'STAGED', + 'REVIEW_READY', + 'APPROVED', + 'COMMITTING', + 'COMMITTED', + 'FAILED', + 'CANCELLED' + ); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ +BEGIN + CREATE TYPE "StagedRecordStatus" AS ENUM ( + 'PARSED', + 'NORMALIZED', + 'UNRESOLVED', + 'APPROVED', + 'REJECTED', + 'COMMITTED', + 'FAILED' + ); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ +BEGIN + CREATE TYPE "DispoImportSourceKind" AS ENUM ( + 'REFERENCE', + 'PLANNING', + 'CHARGEABILITY' + ); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ +BEGIN + CREATE TYPE "DispoStagedRecordType" AS ENUM ( + 'RESOURCE', + 'CLIENT', + 'PROJECT', + 'ASSIGNMENT', + 'VACATION', + 'AVAILABILITY_RULE', + 'UNRESOLVED' + ); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS import_batches ( + id text PRIMARY KEY, + "sourceSystem" text NOT NULL DEFAULT 'DISPO_V2', + status "ImportBatchStatus" NOT NULL DEFAULT 'DRAFT', + "referenceSourceFile" text, + "planningSourceFile" text, + "chargeabilitySourceFile" text, + notes text, + summary jsonb NOT NULL DEFAULT '{}'::jsonb, + "startedAt" timestamptz, + "stagedAt" timestamptz, + "approvedAt" timestamptz, + "committedAt" timestamptz, + "failedAt" timestamptz, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS staged_resources ( + id text PRIMARY KEY, + "importBatchId" text NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE, + status "StagedRecordStatus" NOT NULL DEFAULT 'PARSED', + "sourceKind" "DispoImportSourceKind" NOT NULL, + "sourceWorkbook" text NOT NULL, + "sourceSheet" text NOT NULL, + "sourceRow" integer NOT NULL, + "sourceColumn" text, + "canonicalExternalId" text NOT NULL, + "enterpriseId" text, + eid text, + "displayName" text, + email text, + chapter text, + "chapterCode" text, + "managementLevelGroupName" text, + "managementLevelName" text, + "countryCode" text, + "metroCityName" text, + "clientUnitName" text, + "resourceType" "ResourceType", + "chargeabilityTarget" double precision, + fte double precision, + availability jsonb, + "roleTokens" text[] NOT NULL DEFAULT ARRAY[]::text[], + warnings text[] NOT NULL DEFAULT ARRAY[]::text[], + "errorMessage" text, + "rawPayload" jsonb NOT NULL, + "normalizedData" jsonb NOT NULL DEFAULT '{}'::jsonb, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS staged_clients ( + id text PRIMARY KEY, + "importBatchId" text NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE, + status "StagedRecordStatus" NOT NULL DEFAULT 'PARSED', + "sourceKind" "DispoImportSourceKind" NOT NULL, + "sourceWorkbook" text NOT NULL, + "sourceSheet" text NOT NULL, + "sourceRow" integer NOT NULL, + "sourceColumn" text, + "clientCode" text, + "parentClientCode" text, + name text NOT NULL, + "sortOrder" integer, + "isActive" boolean NOT NULL DEFAULT true, + warnings text[] NOT NULL DEFAULT ARRAY[]::text[], + "errorMessage" text, + "rawPayload" jsonb NOT NULL, + "normalizedData" jsonb NOT NULL DEFAULT '{}'::jsonb, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS staged_projects ( + id text PRIMARY KEY, + "importBatchId" text NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE, + status "StagedRecordStatus" NOT NULL DEFAULT 'PARSED', + "sourceKind" "DispoImportSourceKind" NOT NULL, + "sourceWorkbook" text NOT NULL, + "sourceSheet" text NOT NULL, + "sourceRow" integer NOT NULL, + "sourceColumn" text, + "projectKey" text NOT NULL, + "shortCode" text, + name text, + "clientCode" text, + "utilizationCategoryCode" text, + "orderType" "OrderType", + "allocationType" "AllocationType", + "winProbability" integer, + "isInternal" boolean NOT NULL DEFAULT false, + "isTbd" boolean NOT NULL DEFAULT false, + "startDate" date, + "endDate" date, + warnings text[] NOT NULL DEFAULT ARRAY[]::text[], + "errorMessage" text, + "rawPayload" jsonb NOT NULL, + "normalizedData" jsonb NOT NULL DEFAULT '{}'::jsonb, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS staged_assignments ( + id text PRIMARY KEY, + "importBatchId" text NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE, + status "StagedRecordStatus" NOT NULL DEFAULT 'PARSED', + "sourceKind" "DispoImportSourceKind" NOT NULL, + "sourceWorkbook" text NOT NULL, + "sourceSheet" text NOT NULL, + "sourceRow" integer NOT NULL, + "sourceColumn" text, + "resourceExternalId" text NOT NULL, + "projectKey" text, + "assignmentDate" date, + "startDate" date, + "endDate" date, + "hoursPerDay" double precision, + percentage double precision, + "slotFraction" double precision, + "roleToken" text, + "roleName" text, + "chapterToken" text, + "utilizationCategoryCode" text, + "winProbability" integer, + "isInternal" boolean NOT NULL DEFAULT false, + "isUnassigned" boolean NOT NULL DEFAULT false, + "isTbd" boolean NOT NULL DEFAULT false, + warnings text[] NOT NULL DEFAULT ARRAY[]::text[], + "errorMessage" text, + "rawPayload" jsonb NOT NULL, + "normalizedData" jsonb NOT NULL DEFAULT '{}'::jsonb, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS staged_vacations ( + id text PRIMARY KEY, + "importBatchId" text NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE, + status "StagedRecordStatus" NOT NULL DEFAULT 'PARSED', + "sourceKind" "DispoImportSourceKind" NOT NULL, + "sourceWorkbook" text NOT NULL, + "sourceSheet" text NOT NULL, + "sourceRow" integer NOT NULL, + "sourceColumn" text, + "resourceExternalId" text NOT NULL, + "vacationType" "VacationType" NOT NULL, + "startDate" date NOT NULL, + "endDate" date NOT NULL, + note text, + "holidayName" text, + "isHalfDay" boolean NOT NULL DEFAULT false, + "halfDayPart" text, + "isPublicHoliday" boolean NOT NULL DEFAULT false, + warnings text[] NOT NULL DEFAULT ARRAY[]::text[], + "errorMessage" text, + "rawPayload" jsonb NOT NULL, + "normalizedData" jsonb NOT NULL DEFAULT '{}'::jsonb, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS staged_availability_rules ( + id text PRIMARY KEY, + "importBatchId" text NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE, + status "StagedRecordStatus" NOT NULL DEFAULT 'PARSED', + "sourceKind" "DispoImportSourceKind" NOT NULL, + "sourceWorkbook" text NOT NULL, + "sourceSheet" text NOT NULL, + "sourceRow" integer NOT NULL, + "sourceColumn" text, + "resourceExternalId" text NOT NULL, + "ruleType" text NOT NULL, + weekday integer, + "effectiveStartDate" date, + "effectiveEndDate" date, + "availableHours" double precision, + percentage double precision, + "isResolved" boolean NOT NULL DEFAULT false, + warnings text[] NOT NULL DEFAULT ARRAY[]::text[], + "errorMessage" text, + "rawPayload" jsonb NOT NULL, + "normalizedData" jsonb NOT NULL DEFAULT '{}'::jsonb, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS staged_unresolved_records ( + id text PRIMARY KEY, + "importBatchId" text NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE, + status "StagedRecordStatus" NOT NULL DEFAULT 'UNRESOLVED', + "sourceKind" "DispoImportSourceKind" NOT NULL, + "sourceWorkbook" text NOT NULL, + "sourceSheet" text NOT NULL, + "sourceRow" integer NOT NULL, + "sourceColumn" text, + "recordType" "DispoStagedRecordType" NOT NULL, + "resourceExternalId" text, + "projectKey" text, + message text NOT NULL, + "resolutionHint" text, + warnings text[] NOT NULL DEFAULT ARRAY[]::text[], + "rawPayload" jsonb NOT NULL, + "normalizedData" jsonb NOT NULL DEFAULT '{}'::jsonb, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_import_batches_status + ON import_batches (status); + +CREATE INDEX IF NOT EXISTS idx_staged_resources_batch_status + ON staged_resources ("importBatchId", status); + +CREATE INDEX IF NOT EXISTS idx_staged_resources_canonical_external_id + ON staged_resources ("canonicalExternalId"); + +CREATE INDEX IF NOT EXISTS idx_staged_clients_batch_status + ON staged_clients ("importBatchId", status); + +CREATE INDEX IF NOT EXISTS idx_staged_clients_client_code + ON staged_clients ("clientCode"); + +CREATE INDEX IF NOT EXISTS idx_staged_projects_batch_status + ON staged_projects ("importBatchId", status); + +CREATE INDEX IF NOT EXISTS idx_staged_projects_project_key + ON staged_projects ("projectKey"); + +CREATE INDEX IF NOT EXISTS idx_staged_assignments_batch_status + ON staged_assignments ("importBatchId", status); + +CREATE INDEX IF NOT EXISTS idx_staged_assignments_resource_external_id + ON staged_assignments ("resourceExternalId"); + +CREATE INDEX IF NOT EXISTS idx_staged_assignments_project_key + ON staged_assignments ("projectKey"); + +CREATE INDEX IF NOT EXISTS idx_staged_assignments_assignment_date + ON staged_assignments ("assignmentDate"); + +CREATE INDEX IF NOT EXISTS idx_staged_vacations_batch_status + ON staged_vacations ("importBatchId", status); + +CREATE INDEX IF NOT EXISTS idx_staged_vacations_resource_external_id + ON staged_vacations ("resourceExternalId"); + +CREATE INDEX IF NOT EXISTS idx_staged_vacations_start_end + ON staged_vacations ("startDate", "endDate"); + +CREATE INDEX IF NOT EXISTS idx_staged_availability_rules_batch_status + ON staged_availability_rules ("importBatchId", status); + +CREATE INDEX IF NOT EXISTS idx_staged_availability_rules_resource_external_id + ON staged_availability_rules ("resourceExternalId"); + +CREATE INDEX IF NOT EXISTS idx_staged_unresolved_records_batch_status + ON staged_unresolved_records ("importBatchId", status); + +CREATE INDEX IF NOT EXISTS idx_staged_unresolved_records_record_type + ON staged_unresolved_records ("recordType"); + +CREATE INDEX IF NOT EXISTS idx_staged_unresolved_records_resource_external_id + ON staged_unresolved_records ("resourceExternalId"); + +CREATE INDEX IF NOT EXISTS idx_staged_unresolved_records_project_key + ON staged_unresolved_records ("projectKey"); diff --git a/packages/db/prisma/migrations/20260328_assistant_approvals.sql b/packages/db/prisma/migrations/20260328_assistant_approvals.sql new file mode 100644 index 0000000..4055c36 --- /dev/null +++ b/packages/db/prisma/migrations/20260328_assistant_approvals.sql @@ -0,0 +1,29 @@ +CREATE TYPE "AssistantApprovalStatus" AS ENUM ('PENDING', 'APPROVED', 'CANCELLED', 'EXPIRED'); + +CREATE TABLE "assistant_approvals" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "conversationId" TEXT NOT NULL, + "toolName" TEXT NOT NULL, + "toolArguments" TEXT NOT NULL, + "summary" TEXT NOT NULL, + "status" "AssistantApprovalStatus" NOT NULL DEFAULT 'PENDING', + "approvedAt" TIMESTAMP(3), + "cancelledAt" TIMESTAMP(3), + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "assistant_approvals_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "assistant_approvals_userId_conversationId_status_expiresAt_idx" + ON "assistant_approvals"("userId", "conversationId", "status", "expiresAt"); + +CREATE INDEX "assistant_approvals_status_expiresAt_idx" + ON "assistant_approvals"("status", "expiresAt"); + +ALTER TABLE "assistant_approvals" + ADD CONSTRAINT "assistant_approvals_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260328_holiday_calendar_integrity.sql b/packages/db/prisma/migrations/20260328_holiday_calendar_integrity.sql new file mode 100644 index 0000000..f2e4f2c --- /dev/null +++ b/packages/db/prisma/migrations/20260328_holiday_calendar_integrity.sql @@ -0,0 +1,143 @@ +BEGIN; + +UPDATE "holiday_calendars" +SET "stateCode" = UPPER("stateCode") +WHERE "stateCode" IS NOT NULL; + +UPDATE "holiday_calendars" +SET "stateCode" = NULL, + "metroCityId" = NULL +WHERE "scopeType" = 'COUNTRY'; + +UPDATE "holiday_calendars" +SET "metroCityId" = NULL +WHERE "scopeType" = 'STATE'; + +CREATE TEMP TABLE "tmp_holiday_calendar_merge_map" ( + "duplicate_id" TEXT PRIMARY KEY, + "keeper_id" TEXT NOT NULL +) ON COMMIT DROP; + +WITH ranked AS ( + SELECT + "id", + FIRST_VALUE("id") OVER ( + PARTITION BY "countryId" + ORDER BY "priority" DESC, "createdAt" DESC, "id" DESC + ) AS "keeper_id", + ROW_NUMBER() OVER ( + PARTITION BY "countryId" + ORDER BY "priority" DESC, "createdAt" DESC, "id" DESC + ) AS "rn" + FROM "holiday_calendars" + WHERE "scopeType" = 'COUNTRY' +) +INSERT INTO "tmp_holiday_calendar_merge_map" ("duplicate_id", "keeper_id") +SELECT "id", "keeper_id" +FROM ranked +WHERE "rn" > 1; + +WITH ranked AS ( + SELECT + "id", + FIRST_VALUE("id") OVER ( + PARTITION BY "countryId", "stateCode" + ORDER BY "priority" DESC, "createdAt" DESC, "id" DESC + ) AS "keeper_id", + ROW_NUMBER() OVER ( + PARTITION BY "countryId", "stateCode" + ORDER BY "priority" DESC, "createdAt" DESC, "id" DESC + ) AS "rn" + FROM "holiday_calendars" + WHERE "scopeType" = 'STATE' + AND "stateCode" IS NOT NULL +) +INSERT INTO "tmp_holiday_calendar_merge_map" ("duplicate_id", "keeper_id") +SELECT "id", "keeper_id" +FROM ranked +WHERE "rn" > 1 +ON CONFLICT ("duplicate_id") DO NOTHING; + +WITH ranked AS ( + SELECT + "id", + FIRST_VALUE("id") OVER ( + PARTITION BY "countryId", "metroCityId" + ORDER BY "priority" DESC, "createdAt" DESC, "id" DESC + ) AS "keeper_id", + ROW_NUMBER() OVER ( + PARTITION BY "countryId", "metroCityId" + ORDER BY "priority" DESC, "createdAt" DESC, "id" DESC + ) AS "rn" + FROM "holiday_calendars" + WHERE "scopeType" = 'CITY' + AND "metroCityId" IS NOT NULL +) +INSERT INTO "tmp_holiday_calendar_merge_map" ("duplicate_id", "keeper_id") +SELECT "id", "keeper_id" +FROM ranked +WHERE "rn" > 1 +ON CONFLICT ("duplicate_id") DO NOTHING; + +UPDATE "holiday_calendar_entries" AS "entry" +SET "holidayCalendarId" = "map"."keeper_id" +FROM "tmp_holiday_calendar_merge_map" AS "map" +WHERE "entry"."holidayCalendarId" = "map"."duplicate_id" + AND NOT EXISTS ( + SELECT 1 + FROM "holiday_calendar_entries" AS "existing" + WHERE "existing"."holidayCalendarId" = "map"."keeper_id" + AND "existing"."date" = "entry"."date" + ); + +WITH ranked_entries AS ( + SELECT + "id", + ROW_NUMBER() OVER ( + PARTITION BY "holidayCalendarId", "date" + ORDER BY "isRecurringAnnual" DESC, "updatedAt" DESC, "createdAt" DESC, "id" DESC + ) AS "rn" + FROM "holiday_calendar_entries" +) +DELETE FROM "holiday_calendar_entries" AS "entry" +USING ranked_entries +WHERE "entry"."id" = ranked_entries."id" + AND ranked_entries."rn" > 1; + +DELETE FROM "holiday_calendars" AS "calendar" +USING "tmp_holiday_calendar_merge_map" AS "map" +WHERE "calendar"."id" = "map"."duplicate_id"; + +DROP INDEX IF EXISTS "holiday_calendar_entries_holidayCalendarId_date_name_key"; +DROP INDEX IF EXISTS "holiday_calendar_entries_holidayCalendarId_date_key"; +CREATE UNIQUE INDEX "holiday_calendar_entries_holidayCalendarId_date_key" + ON "holiday_calendar_entries" ("holidayCalendarId", "date"); + +ALTER TABLE "holiday_calendars" + DROP CONSTRAINT IF EXISTS "holiday_calendars_scope_fields_check"; + +ALTER TABLE "holiday_calendars" + ADD CONSTRAINT "holiday_calendars_scope_fields_check" + CHECK ( + ("scopeType" = 'COUNTRY' AND "stateCode" IS NULL AND "metroCityId" IS NULL) + OR ("scopeType" = 'STATE' AND "stateCode" IS NOT NULL AND "metroCityId" IS NULL) + OR ("scopeType" = 'CITY' AND "metroCityId" IS NOT NULL) + ); + +DROP INDEX IF EXISTS "holiday_calendars_country_scope_unique"; +DROP INDEX IF EXISTS "holiday_calendars_state_scope_unique"; +DROP INDEX IF EXISTS "holiday_calendars_city_scope_unique"; + +CREATE UNIQUE INDEX "holiday_calendars_country_scope_unique" + ON "holiday_calendars" ("countryId") + WHERE "scopeType" = 'COUNTRY'; + +CREATE UNIQUE INDEX "holiday_calendars_state_scope_unique" + ON "holiday_calendars" ("countryId", "stateCode") + WHERE "scopeType" = 'STATE' AND "stateCode" IS NOT NULL; + +CREATE UNIQUE INDEX "holiday_calendars_city_scope_unique" + ON "holiday_calendars" ("countryId", "metroCityId") + WHERE "scopeType" = 'CITY' AND "metroCityId" IS NOT NULL; + +COMMIT; diff --git a/packages/db/prisma/migrations/20260328_holiday_calendars.sql b/packages/db/prisma/migrations/20260328_holiday_calendars.sql new file mode 100644 index 0000000..f0a6f63 --- /dev/null +++ b/packages/db/prisma/migrations/20260328_holiday_calendars.sql @@ -0,0 +1,52 @@ +CREATE TYPE "HolidayCalendarScope" AS ENUM ('COUNTRY', 'STATE', 'CITY'); + +CREATE TABLE "holiday_calendars" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "scopeType" "HolidayCalendarScope" NOT NULL, + "countryId" TEXT NOT NULL, + "stateCode" TEXT, + "metroCityId" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "priority" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "holiday_calendars_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "holiday_calendar_entries" ( + "id" TEXT NOT NULL, + "holidayCalendarId" TEXT NOT NULL, + "date" DATE NOT NULL, + "name" TEXT NOT NULL, + "isRecurringAnnual" BOOLEAN NOT NULL DEFAULT false, + "source" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "holiday_calendar_entries_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "holiday_calendars_countryId_scopeType_idx" ON "holiday_calendars"("countryId", "scopeType"); +CREATE INDEX "holiday_calendars_countryId_stateCode_idx" ON "holiday_calendars"("countryId", "stateCode"); +CREATE INDEX "holiday_calendars_metroCityId_idx" ON "holiday_calendars"("metroCityId"); +CREATE INDEX "holiday_calendar_entries_date_idx" ON "holiday_calendar_entries"("date"); + +CREATE UNIQUE INDEX "holiday_calendar_entries_holidayCalendarId_date_name_key" + ON "holiday_calendar_entries"("holidayCalendarId", "date", "name"); + +ALTER TABLE "holiday_calendars" + ADD CONSTRAINT "holiday_calendars_countryId_fkey" + FOREIGN KEY ("countryId") REFERENCES "countries"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "holiday_calendars" + ADD CONSTRAINT "holiday_calendars_metroCityId_fkey" + FOREIGN KEY ("metroCityId") REFERENCES "metro_cities"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "holiday_calendar_entries" + ADD CONSTRAINT "holiday_calendar_entries_holidayCalendarId_fkey" + FOREIGN KEY ("holidayCalendarId") REFERENCES "holiday_calendars"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260328_report_templates.sql b/packages/db/prisma/migrations/20260328_report_templates.sql new file mode 100644 index 0000000..ae73f39 --- /dev/null +++ b/packages/db/prisma/migrations/20260328_report_templates.sql @@ -0,0 +1,26 @@ +CREATE TYPE "ReportTemplateEntity" AS ENUM ('RESOURCE', 'PROJECT', 'ASSIGNMENT', 'RESOURCE_MONTH'); + +CREATE TABLE "report_templates" ( + "id" TEXT NOT NULL, + "ownerId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "entity" "ReportTemplateEntity" NOT NULL, + "config" JSONB NOT NULL, + "isShared" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "report_templates_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "report_templates_ownerId_updatedAt_idx" + ON "report_templates"("ownerId", "updatedAt"); + +CREATE UNIQUE INDEX "report_templates_ownerId_name_key" + ON "report_templates"("ownerId", "name"); + +ALTER TABLE "report_templates" + ADD CONSTRAINT "report_templates_ownerId_fkey" + FOREIGN KEY ("ownerId") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/tooling/deploy/.env.production.example b/tooling/deploy/.env.production.example new file mode 100644 index 0000000..a51a4f6 --- /dev/null +++ b/tooling/deploy/.env.production.example @@ -0,0 +1,13 @@ +# Runtime settings consumed by the app and by docker-compose.cicd.yml on the target host. + +POSTGRES_PASSWORD=replace-with-a-long-random-password +NEXTAUTH_URL=https://capakraken.example.com +NEXTAUTH_SECRET=replace-with-a-long-random-secret + +# Optional but commonly needed application settings. +SENTRY_DSN= +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=CapaKraken diff --git a/tooling/deploy/README.md b/tooling/deploy/README.md new file mode 100644 index 0000000..b0e8cb6 --- /dev/null +++ b/tooling/deploy/README.md @@ -0,0 +1,41 @@ +# Deploy Tooling + +This directory contains the additive deployment scaffold for the image-based CI/CD target path. + +## Files + +- `deploy-compose.sh`: pulls images, runs migrations, starts the app, and waits for readiness +- `.env.production.example`: example host-side runtime configuration + +## Host Layout + +On the target host, the deploy directory should contain: + +```text +/ + docker-compose.cicd.yml + deploy.env + .env.production + tooling/deploy/deploy-compose.sh +``` + +`deploy.env` is ephemeral and written by GitHub Actions for one deployment. `.env.production` stays on the host and contains the long-lived runtime secrets and app configuration. + +## First Setup + +1. Copy `tooling/deploy/.env.production.example` to the target host as `.env.production`. +2. Fill in the required secrets and URLs. +3. Ensure Docker Engine and Docker Compose v2 are installed. +4. Ensure the target host can pull from `ghcr.io`. +5. Run the image release workflow, then the staging or production deploy workflow with the same image tag. + +## Manual Host Test + +After the files are present on the host, the flow can be tested manually: + +```bash +set -a +. ./deploy.env +set +a +bash tooling/deploy/deploy-compose.sh staging +``` diff --git a/tooling/deploy/deploy-compose.sh b/tooling/deploy/deploy-compose.sh new file mode 100755 index 0000000..f49dd8e --- /dev/null +++ b/tooling/deploy/deploy-compose.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEPLOY_ENV="${1:-unknown}" +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.cicd.yml}" +APP_ENV_FILE="${APP_ENV_FILE:-.env.production}" +DEPLOY_ENV_FILE="${DEPLOY_ENV_FILE:-deploy.env}" +READY_URL="${READY_URL:-http://127.0.0.1:${APP_HOST_PORT:-3000}/api/ready}" +READY_ATTEMPTS="${READY_ATTEMPTS:-60}" +READY_SLEEP_SECONDS="${READY_SLEEP_SECONDS:-5}" + +if [ -f "${APP_ENV_FILE}" ]; then + set -a + # Load application secrets so docker compose interpolation sees them. + . "${APP_ENV_FILE}" + set +a +fi + +if [ -f "${DEPLOY_ENV_FILE}" ]; then + set -a + . "${DEPLOY_ENV_FILE}" + set +a +fi + +: "${APP_IMAGE:?APP_IMAGE must be set}" +: "${MIGRATOR_IMAGE:?MIGRATOR_IMAGE must be set}" +: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}" + +echo "Deploy environment: ${DEPLOY_ENV}" +echo "Compose file: ${COMPOSE_FILE}" +echo "App env file: ${APP_ENV_FILE}" +echo "App image: ${APP_IMAGE}" +echo "Migrator image: ${MIGRATOR_IMAGE}" + +if [ -n "${GHCR_USERNAME:-}" ] && [ -n "${GHCR_TOKEN:-}" ]; then + printf '%s\n' "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USERNAME}" --password-stdin +fi + +docker compose -f "${COMPOSE_FILE}" pull app migrator +docker compose -f "${COMPOSE_FILE}" up -d postgres redis +docker compose -f "${COMPOSE_FILE}" run --rm migrator +docker compose -f "${COMPOSE_FILE}" up -d app + +for attempt in $(seq 1 "${READY_ATTEMPTS}"); do + if curl -fsS "${READY_URL}" >/dev/null 2>&1; then + echo "Application ready after attempt ${attempt}" + docker compose -f "${COMPOSE_FILE}" ps + exit 0 + fi + + sleep "${READY_SLEEP_SECONDS}" +done + +echo "Deployment failed readiness check: ${READY_URL}" >&2 +docker compose -f "${COMPOSE_FILE}" ps >&2 +docker compose -f "${COMPOSE_FILE}" logs --tail 200 app >&2 +exit 1