diff --git a/packages/api/src/__tests__/timeline-project-context-support.test.ts b/packages/api/src/__tests__/timeline-project-context-support.test.ts index dfdb378..248540b 100644 --- a/packages/api/src/__tests__/timeline-project-context-support.test.ts +++ b/packages/api/src/__tests__/timeline-project-context-support.test.ts @@ -2,7 +2,10 @@ import { TRPCError } from "@trpc/server"; import { describe, expect, it } from "vitest"; import { buildTimelineProjectAssignmentConflicts, + buildTimelineProjectContextDetailResponse, + buildTimelineProjectContextResponse, buildTimelineProjectContextSummary, + buildTimelineShiftPreviewDetailResponse, resolveTimelineProjectContextPeriod, } from "../router/timeline-project-context-support.js"; @@ -178,4 +181,194 @@ describe("timeline project context support", () => { ], }); }); + + it("builds anonymized project context responses and detail summaries", () => { + const directory = { + config: { enabled: true, mode: "alias", domain: "example.test" }, + byResourceId: new Map([ + [ + "resource_1", + { + displayName: "Anon Alice", + eid: "ANON-001", + email: "anon-alice@example.test", + }, + ], + ]), + byAliasEid: new Map([["anon-001", "resource_1"]]), + }; + + expect(buildTimelineProjectContextResponse({ + project: { id: "project_1" }, + allocations: [ + { + id: "allocation_1", + projectId: "project_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Alice", eid: "E-001" }, + }, + ], + demands: [{ id: "demand_1", projectId: "project_1" }], + assignments: [ + { + id: "assignment_1", + projectId: "project_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Alice", eid: "E-001" }, + }, + ], + allResourceAllocations: [ + { + id: "booking_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Alice", eid: "E-001" }, + }, + ], + resourceIds: ["resource_1"], + directory: directory as never, + })).toEqual({ + project: { id: "project_1" }, + allocations: [ + { + id: "allocation_1", + projectId: "project_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Anon Alice", eid: "ANON-001" }, + }, + ], + demands: [{ id: "demand_1", projectId: "project_1" }], + assignments: [ + { + id: "assignment_1", + projectId: "project_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Anon Alice", eid: "ANON-001" }, + }, + ], + allResourceAllocations: [ + { + id: "booking_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Anon Alice", eid: "ANON-001" }, + }, + ], + resourceIds: ["resource_1"], + }); + + expect(buildTimelineProjectContextDetailResponse({ + project: { id: "project_1" }, + period: { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + }, + allocations: [ + { + id: "allocation_1", + projectId: "project_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Alice", eid: "E-001" }, + }, + ], + demands: [{ id: "demand_1", projectId: "project_1" }], + assignments: [ + { + id: "assignment_1", + projectId: "project_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Alice", eid: "E-001" }, + }, + ], + allResourceAllocations: [ + { + id: "booking_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Alice", eid: "E-001" }, + }, + ], + resourceIds: ["resource_1"], + assignmentConflicts: [{ crossProjectOverlapCount: 1, assignmentId: "assignment_1" }], + holidayOverlays: [], + directory: directory as never, + })).toEqual({ + project: { id: "project_1" }, + period: { + startDate: "2026-04-01", + endDate: "2026-04-05", + }, + summary: { + allocationCount: 1, + demandCount: 1, + assignmentCount: 1, + projectCount: 1, + resourceCount: 1, + resourceIds: 1, + allResourceAllocationCount: 1, + conflictedAssignmentCount: 1, + overlayCount: 0, + holidayResourceCount: 0, + byScope: [], + }, + allocations: [ + { + id: "allocation_1", + projectId: "project_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Anon Alice", eid: "ANON-001" }, + }, + ], + demands: [{ id: "demand_1", projectId: "project_1" }], + assignments: [ + { + id: "assignment_1", + projectId: "project_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Anon Alice", eid: "ANON-001" }, + }, + ], + allResourceAllocations: [ + { + id: "booking_1", + resourceId: "resource_1", + resource: { id: "resource_1", displayName: "Anon Alice", eid: "ANON-001" }, + }, + ], + assignmentConflicts: [{ crossProjectOverlapCount: 1, assignmentId: "assignment_1" }], + holidayOverlays: [], + resourceIds: ["resource_1"], + }); + }); + + it("builds shift preview detail payloads with formatted dates", () => { + expect(buildTimelineShiftPreviewDetailResponse({ + project: { + id: "project_1", + name: "Project One", + shortCode: "PRJ", + status: "ACTIVE", + responsiblePerson: "Alice", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + }, + requestedShift: { + newStartDate: new Date("2026-04-03T00:00:00.000Z"), + newEndDate: new Date("2026-04-12T00:00:00.000Z"), + }, + preview: { valid: true }, + })).toEqual({ + project: { + id: "project_1", + name: "Project One", + shortCode: "PRJ", + status: "ACTIVE", + responsiblePerson: "Alice", + startDate: "2026-04-01", + endDate: "2026-04-10", + }, + requestedShift: { + newStartDate: "2026-04-03", + newEndDate: "2026-04-12", + }, + preview: { valid: true }, + }); + }); }); diff --git a/packages/api/src/router/timeline-project-context-support.ts b/packages/api/src/router/timeline-project-context-support.ts index 286ab30..1fcb266 100644 --- a/packages/api/src/router/timeline-project-context-support.ts +++ b/packages/api/src/router/timeline-project-context-support.ts @@ -1,6 +1,7 @@ import { TRPCError } from "@trpc/server"; import { summarizeHolidayOverlays, type formatHolidayOverlays } from "./timeline-holiday-read.js"; import { + anonymizeResourceOnEntry, createTimelineDateRange, fmtDate, rangesOverlap, @@ -8,6 +9,8 @@ import { toDate, } from "./timeline-read-shared.js"; +type TimelineAnonymizationDirectory = Parameters[1]; + export interface ResolveTimelineProjectContextPeriodInput { requestedStartDate?: string | undefined; requestedEndDate?: string | undefined; @@ -158,3 +161,117 @@ export function buildTimelineProjectContextSummary(input: { ...summarizeHolidayOverlays(input.holidayOverlays), }; } + +export function buildTimelineProjectContextResponse< + TProject, + TAllocation extends { resource?: { id: string } | null }, + TDemand, + TAssignment extends { resource?: { id: string } | null }, + TBooking extends { resource?: { id: string } | null }, +>(input: { + project: TProject; + allocations: TAllocation[]; + demands: TDemand[]; + assignments: TAssignment[]; + allResourceAllocations: TBooking[]; + resourceIds: string[]; + directory: TimelineAnonymizationDirectory; +}) { + return { + project: input.project, + allocations: input.allocations.map((allocation) => + anonymizeResourceOnEntry(allocation, input.directory), + ), + demands: input.demands, + assignments: input.assignments.map((assignment) => + anonymizeResourceOnEntry(assignment, input.directory), + ), + allResourceAllocations: input.allResourceAllocations.map((allocation) => + anonymizeResourceOnEntry(allocation, input.directory), + ), + resourceIds: input.resourceIds, + }; +} + +export function buildTimelineProjectContextDetailResponse< + TProject, + TAllocation extends { projectId: string | null; resourceId: string | null; resource?: { id: string } | null }, + TDemand extends { projectId: string | null }, + TAssignment extends { projectId: string | null; resourceId: string | null; resource?: { id: string } | null }, + TBooking extends { resource?: { id: string } | null }, + TConflict extends { crossProjectOverlapCount: number }, +>(input: { + project: TProject; + period: { startDate: Date; endDate: Date }; + allocations: TAllocation[]; + demands: TDemand[]; + assignments: TAssignment[]; + allResourceAllocations: TBooking[]; + resourceIds: string[]; + assignmentConflicts: TConflict[]; + holidayOverlays: ReturnType; + directory: TimelineAnonymizationDirectory; +}) { + const base = buildTimelineProjectContextResponse({ + project: input.project, + allocations: input.allocations, + demands: input.demands, + assignments: input.assignments, + allResourceAllocations: input.allResourceAllocations, + resourceIds: input.resourceIds, + directory: input.directory, + }); + + return { + ...base, + period: { + startDate: fmtDate(input.period.startDate), + endDate: fmtDate(input.period.endDate), + }, + summary: buildTimelineProjectContextSummary({ + allocations: input.allocations, + demands: input.demands, + assignments: input.assignments, + resourceIds: input.resourceIds, + allResourceAllocations: input.allResourceAllocations, + assignmentConflicts: input.assignmentConflicts, + holidayOverlays: input.holidayOverlays, + }), + assignmentConflicts: input.assignmentConflicts, + holidayOverlays: input.holidayOverlays, + }; +} + +export function buildTimelineShiftPreviewDetailResponse(input: { + project: { + id: string; + name: string; + shortCode: string | null; + status: string; + responsiblePerson: string | null; + startDate: Date; + endDate: Date; + }; + requestedShift: { + newStartDate: Date; + newEndDate: Date; + }; + preview: TPreview; +}) { + return { + project: { + id: input.project.id, + name: input.project.name, + shortCode: input.project.shortCode, + status: input.project.status, + responsiblePerson: input.project.responsiblePerson, + startDate: fmtDate(input.project.startDate), + endDate: fmtDate(input.project.endDate), + }, + requestedShift: { + newStartDate: fmtDate(input.requestedShift.newStartDate), + newEndDate: fmtDate(input.requestedShift.newEndDate), + }, + preview: input.preview, + }; +} diff --git a/packages/api/src/router/timeline-project-read.ts b/packages/api/src/router/timeline-project-read.ts index 2cce64b..418d422 100644 --- a/packages/api/src/router/timeline-project-read.ts +++ b/packages/api/src/router/timeline-project-read.ts @@ -7,8 +7,11 @@ import { getAnonymizationDirectory } from "../lib/anonymization.js"; import { controllerProcedure } from "../trpc.js"; import { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; import { + buildTimelineProjectContextDetailResponse, + buildTimelineProjectContextResponse, buildTimelineProjectAssignmentConflicts, buildTimelineProjectContextSummary, + buildTimelineShiftPreviewDetailResponse, resolveTimelineProjectContextPeriod, } from "./timeline-project-context-support.js"; import { buildTimelineShiftPlan } from "./timeline-shift-planning.js"; @@ -148,20 +151,15 @@ export const timelineProjectReadProcedures = { } = await loadTimelineProjectContext(ctx.db, input.projectId); const directory = await getAnonymizationDirectory(ctx.db); - return { + return buildTimelineProjectContextResponse({ project, - allocations: allocations.map((allocation) => - anonymizeResourceOnEntry(allocation, directory), - ), + allocations, demands, - assignments: assignments.map((assignment) => - anonymizeResourceOnEntry(assignment, directory), - ), - allResourceAllocations: allResourceAllocations.map((allocation) => - anonymizeResourceOnEntry(allocation, directory), - ), + assignments, + allResourceAllocations, resourceIds, - }; + directory, + }); }), getProjectContextDetail: controllerProcedure @@ -201,35 +199,18 @@ export const timelineProjectReadProcedures = { allResourceAllocations: projectContext.allResourceAllocations, }); - return { + return buildTimelineProjectContextDetailResponse({ project: projectContext.project, - period: { - startDate: fmtDate(period.startDate), - endDate: fmtDate(period.endDate), - }, - summary: buildTimelineProjectContextSummary({ - allocations: projectContext.allocations, - demands: projectContext.demands, - assignments: projectContext.assignments, - resourceIds: projectContext.resourceIds, - allResourceAllocations: projectContext.allResourceAllocations, - assignmentConflicts, - holidayOverlays: formattedHolidayOverlays, - }), - allocations: projectContext.allocations.map((allocation) => - anonymizeResourceOnEntry(allocation, directory), - ), + period, + allocations: projectContext.allocations, demands: projectContext.demands, - assignments: projectContext.assignments.map((assignment) => - anonymizeResourceOnEntry(assignment, directory), - ), - allResourceAllocations: projectContext.allResourceAllocations.map((allocation) => - anonymizeResourceOnEntry(allocation, directory), - ), + assignments: projectContext.assignments, + allResourceAllocations: projectContext.allResourceAllocations, + resourceIds: projectContext.resourceIds, assignmentConflicts, holidayOverlays: formattedHolidayOverlays, - resourceIds: projectContext.resourceIds, - }; + directory, + }); }), previewShift: controllerProcedure @@ -258,22 +239,14 @@ export const timelineProjectReadProcedures = { 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), - }, + return buildTimelineShiftPreviewDetailResponse({ + project, requestedShift: { - newStartDate: fmtDate(input.newStartDate), - newEndDate: fmtDate(input.newEndDate), + newStartDate: input.newStartDate, + newEndDate: input.newEndDate, }, preview, - }; + }); }), getBudgetStatus: controllerProcedure