From 93e03e0f653140b4c0f14e458a02f8f7614c247f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 15:38:39 +0200 Subject: [PATCH] refactor(api): extract timeline entry read builders --- .../__tests__/timeline-read-shared.test.ts | 189 ++++++++++++++++++ .../api/src/router/timeline-entry-read.ts | 45 ++--- .../api/src/router/timeline-read-shared.ts | 80 ++++++++ 3 files changed, 282 insertions(+), 32 deletions(-) create mode 100644 packages/api/src/__tests__/timeline-read-shared.test.ts diff --git a/packages/api/src/__tests__/timeline-read-shared.test.ts b/packages/api/src/__tests__/timeline-read-shared.test.ts new file mode 100644 index 0000000..766e577 --- /dev/null +++ b/packages/api/src/__tests__/timeline-read-shared.test.ts @@ -0,0 +1,189 @@ +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({ + startDate: "2026-04-01", + durationDays: 3, + resourceIds: [" resource_1 ", ""], + projectIds: ["project_1"], + chapters: [" Compositing "], + }), + ).toEqual({ + period: { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-03T00:00:00.000Z"), + }, + filters: { + resourceIds: ["resource_1"], + projectIds: ["project_1"], + clientIds: undefined, + chapters: ["Compositing"], + eids: undefined, + countryCodes: undefined, + }, + timelineInput: { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-03T00:00:00.000Z"), + resourceIds: ["resource_1"], + projectIds: ["project_1"], + clientIds: undefined, + chapters: ["Compositing"], + eids: undefined, + countryCodes: undefined, + }, + }); + }); + + 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 8816764..cd69ae5 100644 --- a/packages/api/src/router/timeline-entry-read.ts +++ b/packages/api/src/router/timeline-entry-read.ts @@ -2,14 +2,12 @@ import { z } from "zod"; import { getAnonymizationDirectory } from "../lib/anonymization.js"; import { controllerProcedure, protectedProcedure } from "../trpc.js"; import { - anonymizeResourceOnEntry, buildSelfServiceTimelineInput, + buildTimelineEntriesDetailInput, + buildTimelineEntriesDetailResponse, + buildTimelineEntriesViewResponse, createEmptyTimelineEntriesView, - createTimelineDateRange, - createTimelineFilters, - fmtDate, loadTimelineEntriesReadModel, - summarizeTimelineEntries, TimelineWindowFiltersSchema, } from "./timeline-read-shared.js"; import { @@ -24,7 +22,7 @@ export const timelineEntryReadProcedures = { .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)); + return buildTimelineEntriesViewResponse(readModel, directory).allocations; }), getEntriesView: controllerProcedure @@ -35,11 +33,7 @@ export const timelineEntryReadProcedures = { getAnonymizationDirectory(ctx.db), ]); - return { - ...readModel, - allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)), - assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)), - }; + return buildTimelineEntriesViewResponse(readModel, directory); }), getMyEntriesView: protectedProcedure @@ -55,11 +49,7 @@ export const timelineEntryReadProcedures = { getAnonymizationDirectory(ctx.db), ]); - return { - ...readModel, - allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)), - assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)), - }; + return buildTimelineEntriesViewResponse(readModel, directory); }), getEntriesDetail: controllerProcedure @@ -77,9 +67,7 @@ export const timelineEntryReadProcedures = { }), ) .query(async ({ ctx, input }) => { - const { startDate, endDate } = createTimelineDateRange(input); - const filters = createTimelineFilters(input); - const timelineInput = { ...filters, startDate, endDate }; + const { period, filters, timelineInput } = buildTimelineEntriesDetailInput(input); const [readModel, directory] = await Promise.all([ loadTimelineEntriesReadModel(ctx.db, timelineInput), @@ -92,20 +80,13 @@ export const timelineEntryReadProcedures = { ); const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays); - return { - period: { - startDate: fmtDate(startDate), - endDate: fmtDate(endDate), - }, + return buildTimelineEntriesDetailResponse({ + period, 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)), + readModel, + directory, holidayOverlays: formattedHolidayOverlays, - }; + holidaySummary: summarizeHolidayOverlays(formattedHolidayOverlays), + }); }), }; diff --git a/packages/api/src/router/timeline-read-shared.ts b/packages/api/src/router/timeline-read-shared.ts index 2b0b9ec..eb3253b 100644 --- a/packages/api/src/router/timeline-read-shared.ts +++ b/packages/api/src/router/timeline-read-shared.ts @@ -44,6 +44,7 @@ export const TimelineWindowFiltersSchema = z.object({ type TimelineWindowFiltersInput = z.infer; type TimelineSelfServiceContext = Pick; +type TimelineAnonymizationDirectory = Awaited>; export function getAssignmentResourceIds( readModel: ReturnType, @@ -142,6 +143,22 @@ 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)), + }; +} + async function findOwnedTimelineResourceId( ctx: TimelineSelfServiceContext, ): Promise { @@ -179,6 +196,31 @@ export async function buildSelfServiceTimelineInput( }; } +export function buildTimelineEntriesDetailInput(input: { + startDate?: string | undefined; + endDate?: string | undefined; + durationDays?: number | undefined; + resourceIds?: string[] | undefined; + projectIds?: string[] | undefined; + clientIds?: string[] | undefined; + chapters?: string[] | undefined; + eids?: string[] | undefined; + countryCodes?: string[] | undefined; +}) { + const period = createTimelineDateRange(input); + const filters = createTimelineFilters(input); + + return { + period, + filters, + timelineInput: { + ...filters, + startDate: period.startDate, + endDate: period.endDate, + }, + }; +} + export function summarizeTimelineEntries(readModel: { allocations: Array<{ projectId: string | null; resourceId: string | null }>; demands: Array<{ projectId: string | null }>; @@ -208,6 +250,44 @@ export function summarizeTimelineEntries(readModel: { }; } +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,