refactor(api): extract timeline entry response support

This commit is contained in:
2026-03-31 17:30:04 +02:00
parent 7d3792e9dc
commit acb4ec5243
6 changed files with 273 additions and 249 deletions
@@ -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,
});
});
});
@@ -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,
});
});
});
@@ -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,
@@ -0,0 +1,112 @@
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
import { fmtDate, type TimelineEntriesFilters } from "./timeline-read-shared.js";
type TimelineAnonymizationDirectory = Awaited<ReturnType<typeof getAnonymizationDirectory>>;
export function anonymizeResourceOnEntry<T extends { resource?: { id: string } | null }>(
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<string>();
const resourceIds = new Set<string>();
for (const entry of [...readModel.allocations, ...readModel.demands, ...readModel.assignments]) {
if (entry.projectId) {
projectIds.add(entry.projectId);
}
}
for (const assignment of [...readModel.allocations, ...readModel.assignments]) {
if (assignment.resourceId) {
resourceIds.add(assignment.resourceId);
}
}
return {
allocationCount: readModel.allocations.length,
demandCount: readModel.demands.length,
assignmentCount: readModel.assignments.length,
projectCount: projectIds.size,
resourceCount: resourceIds.size,
};
}
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<TimelineEntriesFilters, "startDate" | "endDate">;
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,
};
}
@@ -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";
@@ -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<typeof TimelineWindowFiltersSchema>;
type TimelineAnonymizationDirectory = Awaited<ReturnType<typeof getAnonymizationDirectory>>;
export function getAssignmentResourceIds(
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
@@ -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<string>();
const resourceIds = new Set<string>();
for (const entry of [...readModel.allocations, ...readModel.demands, ...readModel.assignments]) {
if (entry.projectId) {
projectIds.add(entry.projectId);
}
}
for (const assignment of [...readModel.allocations, ...readModel.assignments]) {
if (assignment.resourceId) {
resourceIds.add(assignment.resourceId);
}
}
return {
allocationCount: readModel.allocations.length,
demandCount: readModel.demands.length,
assignmentCount: readModel.assignments.length,
projectCount: projectIds.size,
resourceCount: resourceIds.size,
};
}
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<TimelineEntriesFilters, "startDate" | "endDate">;
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<T extends { resource?: { id: string } | null }>(
entry: T,
directory: Awaited<ReturnType<typeof getAnonymizationDirectory>>,
): T {
if (!entry.resource) {
return entry;
}
return {
...entry,
resource: anonymizeResource(entry.resource, directory),
};
}
export async function loadTimelineEntriesReadModel(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,