refactor(api): extract timeline entry read builders
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ export const TimelineWindowFiltersSchema = z.object({
|
||||
|
||||
type TimelineWindowFiltersInput = z.infer<typeof TimelineWindowFiltersSchema>;
|
||||
type TimelineSelfServiceContext = Pick<TRPCContext, "db" | "dbUser">;
|
||||
type TimelineAnonymizationDirectory = Awaited<ReturnType<typeof getAnonymizationDirectory>>;
|
||||
|
||||
export function getAssignmentResourceIds(
|
||||
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
|
||||
@@ -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<string | null> {
|
||||
@@ -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<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,
|
||||
|
||||
Reference in New Issue
Block a user