From acb4ec5243d500140a611781fb02d688bfcd3e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 17:30:04 +0200 Subject: [PATCH] refactor(api): extract timeline entry response support --- .../timeline-entry-response-support.test.ts | 153 ++++++++++++++++++ .../__tests__/timeline-read-shared.test.ts | 147 ----------------- .../api/src/router/timeline-entry-read.ts | 6 +- .../router/timeline-entry-response-support.ts | 112 +++++++++++++ ...meline-project-context-response-support.ts | 6 +- .../api/src/router/timeline-read-shared.ts | 98 ----------- 6 files changed, 273 insertions(+), 249 deletions(-) create mode 100644 packages/api/src/__tests__/timeline-entry-response-support.test.ts create mode 100644 packages/api/src/router/timeline-entry-response-support.ts diff --git a/packages/api/src/__tests__/timeline-entry-response-support.test.ts b/packages/api/src/__tests__/timeline-entry-response-support.test.ts new file mode 100644 index 0000000..378bda2 --- /dev/null +++ b/packages/api/src/__tests__/timeline-entry-response-support.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { + buildTimelineEntriesDetailResponse, + buildTimelineEntriesViewResponse, +} from "../router/timeline-entry-response-support.js"; + +describe("timeline entry response support", () => { + 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"]]), + }; + + it("builds anonymized entries view responses", () => { + const readModel = { + 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" }, + }, + ], + }; + + expect(buildTimelineEntriesViewResponse(readModel, directory as never)).toEqual({ + 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" }, + }, + ], + }); + }); + + it("builds detail responses with combined summary and anonymized entries", () => { + const readModel = { + 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" }, + }, + ], + }; + const overlays = [{ id: "overlay_1", resourceId: "resource_1" }]; + + expect( + buildTimelineEntriesDetailResponse({ + period: { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + }, + filters: { + resourceIds: ["resource_1"], + projectIds: undefined, + clientIds: undefined, + chapters: undefined, + eids: undefined, + countryCodes: undefined, + }, + readModel, + directory: directory as never, + holidayOverlays: overlays, + holidaySummary: { + overlayCount: 1, + holidayResourceCount: 1, + byScope: [{ scope: "COUNTRY", count: 1 }], + }, + }), + ).toEqual({ + period: { + startDate: "2026-04-01", + endDate: "2026-04-05", + }, + filters: { + resourceIds: ["resource_1"], + projectIds: undefined, + clientIds: undefined, + chapters: undefined, + eids: undefined, + countryCodes: undefined, + }, + summary: { + allocationCount: 1, + demandCount: 1, + assignmentCount: 1, + projectCount: 1, + resourceCount: 1, + overlayCount: 1, + holidayResourceCount: 1, + byScope: [{ scope: "COUNTRY", count: 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" }, + }, + ], + holidayOverlays: overlays, + }); + }); +}); diff --git a/packages/api/src/__tests__/timeline-read-shared.test.ts b/packages/api/src/__tests__/timeline-read-shared.test.ts index 766e577..112fd6e 100644 --- a/packages/api/src/__tests__/timeline-read-shared.test.ts +++ b/packages/api/src/__tests__/timeline-read-shared.test.ts @@ -1,68 +1,9 @@ import { describe, expect, it } from "vitest"; import { buildTimelineEntriesDetailInput, - buildTimelineEntriesDetailResponse, - buildTimelineEntriesViewResponse, } from "../router/timeline-read-shared.js"; describe("timeline read shared", () => { - 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"]]), - }; - - it("builds anonymized entries view responses", () => { - const readModel = { - 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" }, - }, - ], - }; - - expect(buildTimelineEntriesViewResponse(readModel, directory as never)).toEqual({ - 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" }, - }, - ], - }); - }); - it("builds detail input from period and normalized filters", () => { expect( buildTimelineEntriesDetailInput({ @@ -98,92 +39,4 @@ describe("timeline read shared", () => { }); }); - it("builds detail responses with combined summary and anonymized entries", () => { - const readModel = { - 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" }, - }, - ], - }; - const overlays = [{ id: "overlay_1", resourceId: "resource_1" }]; - - expect( - buildTimelineEntriesDetailResponse({ - period: { - startDate: new Date("2026-04-01T00:00:00.000Z"), - endDate: new Date("2026-04-05T00:00:00.000Z"), - }, - filters: { - resourceIds: ["resource_1"], - projectIds: undefined, - clientIds: undefined, - chapters: undefined, - eids: undefined, - countryCodes: undefined, - }, - readModel, - directory: directory as never, - holidayOverlays: overlays, - holidaySummary: { - overlayCount: 1, - holidayResourceCount: 1, - byScope: [{ scope: "COUNTRY", count: 1 }], - }, - }), - ).toEqual({ - period: { - startDate: "2026-04-01", - endDate: "2026-04-05", - }, - filters: { - resourceIds: ["resource_1"], - projectIds: undefined, - clientIds: undefined, - chapters: undefined, - eids: undefined, - countryCodes: undefined, - }, - summary: { - allocationCount: 1, - demandCount: 1, - assignmentCount: 1, - projectCount: 1, - resourceCount: 1, - overlayCount: 1, - holidayResourceCount: 1, - byScope: [{ scope: "COUNTRY", count: 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" }, - }, - ], - holidayOverlays: overlays, - }); - }); }); diff --git a/packages/api/src/router/timeline-entry-read.ts b/packages/api/src/router/timeline-entry-read.ts index cd69ae5..fdffc17 100644 --- a/packages/api/src/router/timeline-entry-read.ts +++ b/packages/api/src/router/timeline-entry-read.ts @@ -4,12 +4,14 @@ import { controllerProcedure, protectedProcedure } from "../trpc.js"; import { buildSelfServiceTimelineInput, buildTimelineEntriesDetailInput, - buildTimelineEntriesDetailResponse, - buildTimelineEntriesViewResponse, createEmptyTimelineEntriesView, loadTimelineEntriesReadModel, TimelineWindowFiltersSchema, } from "./timeline-read-shared.js"; +import { + buildTimelineEntriesDetailResponse, + buildTimelineEntriesViewResponse, +} from "./timeline-entry-response-support.js"; import { formatHolidayOverlays, loadTimelineHolidayOverlaysForReadModel, diff --git a/packages/api/src/router/timeline-entry-response-support.ts b/packages/api/src/router/timeline-entry-response-support.ts new file mode 100644 index 0000000..8c7fcc3 --- /dev/null +++ b/packages/api/src/router/timeline-entry-response-support.ts @@ -0,0 +1,112 @@ +import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js"; +import { fmtDate, type TimelineEntriesFilters } from "./timeline-read-shared.js"; + +type TimelineAnonymizationDirectory = Awaited>; + +export function anonymizeResourceOnEntry( + entry: T, + directory: TimelineAnonymizationDirectory, +): T { + if (!entry.resource) { + return entry; + } + return { + ...entry, + resource: anonymizeResource(entry.resource, directory), + }; +} + +export function buildTimelineEntriesViewResponse< + TReadModel extends { + allocations: Array<{ resource?: { id: string } | null }>; + assignments: Array<{ resource?: { id: string } | null }>; + }, +>( + readModel: TReadModel, + directory: TimelineAnonymizationDirectory, +): TReadModel { + return { + ...readModel, + allocations: readModel.allocations.map((allocation) => + anonymizeResourceOnEntry(allocation, directory), + ), + assignments: readModel.assignments.map((assignment) => + anonymizeResourceOnEntry(assignment, directory), + ), + }; +} + +export 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, + }; +} + +export function buildTimelineEntriesDetailResponse< + TReadModel extends { + allocations: Array<{ + projectId: string | null; + resourceId: string | null; + resource?: { id: string } | null; + }>; + demands: Array<{ projectId: string | null }>; + assignments: Array<{ + projectId: string | null; + resourceId: string | null; + resource?: { id: string } | null; + }>; + }, + THolidayOverlay, +>(input: { + period: { startDate: Date; endDate: Date }; + filters: Omit; + readModel: TReadModel; + directory: TimelineAnonymizationDirectory; + holidayOverlays: THolidayOverlay[]; + holidaySummary: { + overlayCount: number; + holidayResourceCount: number; + byScope: Array<{ scope: string; count: number }>; + }; +}) { + const view = buildTimelineEntriesViewResponse(input.readModel, input.directory); + + return { + period: { + startDate: fmtDate(input.period.startDate), + endDate: fmtDate(input.period.endDate), + }, + filters: input.filters, + summary: { + ...summarizeTimelineEntries(input.readModel), + ...input.holidaySummary, + }, + allocations: view.allocations, + demands: input.readModel.demands, + assignments: view.assignments, + holidayOverlays: input.holidayOverlays, + }; +} diff --git a/packages/api/src/router/timeline-project-context-response-support.ts b/packages/api/src/router/timeline-project-context-response-support.ts index 4fcf138..349ee0f 100644 --- a/packages/api/src/router/timeline-project-context-response-support.ts +++ b/packages/api/src/router/timeline-project-context-response-support.ts @@ -1,8 +1,10 @@ +import { + fmtDate, +} from "./timeline-read-shared.js"; import { anonymizeResourceOnEntry, - fmtDate, summarizeTimelineEntries, -} from "./timeline-read-shared.js"; +} from "./timeline-entry-response-support.js"; import { summarizeHolidayOverlays, } from "./timeline-holiday-read.js"; diff --git a/packages/api/src/router/timeline-read-shared.ts b/packages/api/src/router/timeline-read-shared.ts index 751217d..2f330f3 100644 --- a/packages/api/src/router/timeline-read-shared.ts +++ b/packages/api/src/router/timeline-read-shared.ts @@ -1,7 +1,6 @@ import { buildSplitAllocationReadModel } from "@capakraken/application"; import type { PrismaClient } from "@capakraken/db"; import { z } from "zod"; -import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js"; import { loadTimelineEntryRecords } from "./timeline-entry-query-support.js"; export { buildSelfServiceTimelineInput, @@ -43,7 +42,6 @@ export const TimelineWindowFiltersSchema = z.object({ }); type TimelineWindowFiltersInput = z.infer; -type TimelineAnonymizationDirectory = Awaited>; export function getAssignmentResourceIds( readModel: ReturnType, @@ -71,89 +69,6 @@ export function createEmptyTimelineEntriesView() { }); } -export function buildTimelineEntriesViewResponse< - TReadModel extends { - allocations: Array<{ resource?: { id: string } | null }>; - assignments: Array<{ resource?: { id: string } | null }>; - }, ->( - readModel: TReadModel, - directory: TimelineAnonymizationDirectory, -): TReadModel { - return { - ...readModel, - allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)), - assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)), - }; -} - -export 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, - }; -} - -export function buildTimelineEntriesDetailResponse< - TReadModel extends { - allocations: Array<{ projectId: string | null; resourceId: string | null; resource?: { id: string } | null }>; - demands: Array<{ projectId: string | null }>; - assignments: Array<{ projectId: string | null; resourceId: string | null; resource?: { id: string } | null }>; - }, - THolidayOverlay, ->(input: { - period: { startDate: Date; endDate: Date }; - filters: Omit; - readModel: TReadModel; - directory: TimelineAnonymizationDirectory; - holidayOverlays: THolidayOverlay[]; - holidaySummary: { - overlayCount: number; - holidayResourceCount: number; - byScope: Array<{ scope: string; count: number }>; - }; -}) { - const view = buildTimelineEntriesViewResponse(input.readModel, input.directory); - - return { - period: { - startDate: fmtDate(input.period.startDate), - endDate: fmtDate(input.period.endDate), - }, - filters: input.filters, - summary: { - ...summarizeTimelineEntries(input.readModel), - ...input.holidaySummary, - }, - allocations: view.allocations, - demands: input.readModel.demands, - assignments: view.assignments, - holidayOverlays: input.holidayOverlays, - }; -} - export function rangesOverlap( leftStart: Date, leftEnd: Date, @@ -167,19 +82,6 @@ export function toDate(value: Date | string): Date { return value instanceof Date ? value : new Date(value); } -export function anonymizeResourceOnEntry( - entry: T, - directory: Awaited>, -): T { - if (!entry.resource) { - return entry; - } - return { - ...entry, - resource: anonymizeResource(entry.resource, directory), - }; -} - export async function loadTimelineEntriesReadModel( db: TimelineEntriesDbClient, input: TimelineEntriesFilters,