From 803de725ad9e7c3dfc4bc72b9403ff6f098a9bc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 16:01:35 +0200 Subject: [PATCH] refactor(api): extract timeline project query and read helpers --- .../timeline-project-context-support.test.ts | 114 ----------------- .../timeline-project-query-support.test.ts | 39 ++++++ .../timeline-project-read-support.test.ts | 120 ++++++++++++++++++ .../timeline-project-context-support.ts | 108 ---------------- .../router/timeline-project-query-support.ts | 60 +++++++++ .../router/timeline-project-read-support.ts | 109 ++++++++++++++++ .../api/src/router/timeline-project-read.ts | 95 +++++--------- 7 files changed, 357 insertions(+), 288 deletions(-) create mode 100644 packages/api/src/__tests__/timeline-project-query-support.test.ts create mode 100644 packages/api/src/__tests__/timeline-project-read-support.test.ts create mode 100644 packages/api/src/router/timeline-project-query-support.ts create mode 100644 packages/api/src/router/timeline-project-read-support.ts 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 7b45ba1..00eea48 100644 --- a/packages/api/src/__tests__/timeline-project-context-support.test.ts +++ b/packages/api/src/__tests__/timeline-project-context-support.test.ts @@ -14,13 +14,9 @@ vi.mock("../router/timeline-holiday-read.js", async () => { }); import { - buildTimelineBudgetStatusAllocations, - buildTimelineBudgetStatusResponse, buildTimelineProjectContextDetailResponse, buildTimelineProjectContextResponse, buildTimelineProjectContextSummary, - buildTimelineShiftValidationBookings, - buildTimelineShiftPreviewDetailResponse, loadTimelineProjectContextDetailArtifacts, resolveTimelineProjectContextPeriod, } from "../router/timeline-project-context-support.js"; @@ -366,116 +362,6 @@ describe("timeline project context support", () => { }); }); - 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 }, - }); - }); - - it("maps shift validation bookings and budget status payloads", () => { - expect( - buildTimelineShiftValidationBookings([ - { - id: "booking_1", - resourceId: "resource_1", - projectId: "project_1", - startDate: new Date("2026-04-01T00:00:00.000Z"), - endDate: new Date("2026-04-05T00:00:00.000Z"), - hoursPerDay: 8, - status: "CONFIRMED", - }, - { - id: "booking_2", - resourceId: null, - projectId: "project_1", - startDate: new Date("2026-04-01T00:00:00.000Z"), - endDate: new Date("2026-04-05T00:00:00.000Z"), - hoursPerDay: 4, - status: "PROPOSED", - }, - ]), - ).toEqual([ - { - id: "booking_1", - resourceId: "resource_1", - projectId: "project_1", - startDate: new Date("2026-04-01T00:00:00.000Z"), - endDate: new Date("2026-04-05T00:00:00.000Z"), - hoursPerDay: 8, - status: "CONFIRMED", - }, - ]); - - expect( - buildTimelineBudgetStatusAllocations([ - { - status: "CONFIRMED", - dailyCostCents: 40000, - startDate: new Date("2026-04-01T00:00:00.000Z"), - endDate: new Date("2026-04-05T00:00:00.000Z"), - hoursPerDay: 8, - }, - ]), - ).toEqual([ - { - status: "CONFIRMED", - dailyCostCents: 40000, - startDate: new Date("2026-04-01T00:00:00.000Z"), - endDate: new Date("2026-04-05T00:00:00.000Z"), - hoursPerDay: 8, - }, - ]); - - expect( - buildTimelineBudgetStatusResponse({ - project: { - name: "Project One", - shortCode: "PRJ", - budgetCents: 120000, - }, - budgetStatus: { - withinBudget: true, - varianceCents: 2000, - }, - totalAllocations: 3, - }), - ).toEqual({ - withinBudget: true, - varianceCents: 2000, - projectName: "Project One", - projectCode: "PRJ", - totalAllocations: 3, - budgetCents: 120000, - }); - }); it("loads detail artifacts with formatted holiday overlays only when resources exist", async () => { loadTimelineHolidayOverlaysMock.mockResolvedValueOnce([ diff --git a/packages/api/src/__tests__/timeline-project-query-support.test.ts b/packages/api/src/__tests__/timeline-project-query-support.test.ts new file mode 100644 index 0000000..805549b --- /dev/null +++ b/packages/api/src/__tests__/timeline-project-query-support.test.ts @@ -0,0 +1,39 @@ +import { TRPCError } from "@trpc/server"; +import { describe, expect, it, vi } from "vitest"; +import { + findTimelineProjectOrThrow, + timelineBudgetStatusProjectSelect, +} from "../router/timeline-project-query-support.js"; + +describe("timeline project query support", () => { + it("loads a project with the requested select", async () => { + const project = { id: "project_1", name: "Project One", budgetCents: 120000 }; + const findUnique = vi.fn().mockResolvedValue(project); + + await expect(findTimelineProjectOrThrow({ + project: { findUnique }, + } as never, { + projectId: "project_1", + select: timelineBudgetStatusProjectSelect, + })).resolves.toEqual(project); + + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "project_1" }, + select: timelineBudgetStatusProjectSelect, + }); + }); + + it("throws a not found error when the project does not exist", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + + await expect(findTimelineProjectOrThrow({ + project: { findUnique }, + } as never, { + projectId: "missing_project", + select: timelineBudgetStatusProjectSelect, + })).rejects.toThrowError(new TRPCError({ + code: "NOT_FOUND", + message: "Project not found", + })); + }); +}); diff --git a/packages/api/src/__tests__/timeline-project-read-support.test.ts b/packages/api/src/__tests__/timeline-project-read-support.test.ts new file mode 100644 index 0000000..dd3d7b8 --- /dev/null +++ b/packages/api/src/__tests__/timeline-project-read-support.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { + buildTimelineBudgetStatusAllocations, + buildTimelineBudgetStatusResponse, + buildTimelineShiftPreviewDetailResponse, + buildTimelineShiftValidationBookings, +} from "../router/timeline-project-read-support.js"; + +describe("timeline project read support", () => { + 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 }, + }); + }); + + it("maps shift validation bookings and budget status payloads", () => { + expect( + buildTimelineShiftValidationBookings([ + { + id: "booking_1", + resourceId: "resource_1", + projectId: "project_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + hoursPerDay: 8, + status: "CONFIRMED", + }, + { + id: "booking_2", + resourceId: null, + projectId: "project_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + hoursPerDay: 4, + status: "PROPOSED", + }, + ]), + ).toEqual([ + { + id: "booking_1", + resourceId: "resource_1", + projectId: "project_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + hoursPerDay: 8, + status: "CONFIRMED", + }, + ]); + + expect( + buildTimelineBudgetStatusAllocations([ + { + status: "CONFIRMED", + dailyCostCents: 40000, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + hoursPerDay: 8, + }, + ]), + ).toEqual([ + { + status: "CONFIRMED", + dailyCostCents: 40000, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + hoursPerDay: 8, + }, + ]); + + expect( + buildTimelineBudgetStatusResponse({ + project: { + name: "Project One", + shortCode: "PRJ", + budgetCents: 120000, + }, + budgetStatus: { + withinBudget: true, + varianceCents: 2000, + }, + totalAllocations: 3, + }), + ).toEqual({ + withinBudget: true, + varianceCents: 2000, + projectName: "Project One", + projectCode: "PRJ", + totalAllocations: 3, + budgetCents: 120000, + }); + }); +}); diff --git a/packages/api/src/router/timeline-project-context-support.ts b/packages/api/src/router/timeline-project-context-support.ts index f613eb3..c78917f 100644 --- a/packages/api/src/router/timeline-project-context-support.ts +++ b/packages/api/src/router/timeline-project-context-support.ts @@ -1,5 +1,4 @@ import { TRPCError } from "@trpc/server"; -import type { Allocation } from "@capakraken/shared"; import { buildTimelineProjectAssignmentConflicts, type TimelineProjectAssignmentConflict, @@ -87,79 +86,6 @@ export function buildTimelineProjectContextSummary(input: { }; } -export function buildTimelineShiftValidationBookings( - bookings: Array<{ - id: string; - resourceId: string | null; - projectId: string | null; - startDate: Date; - endDate: Date; - hoursPerDay: number; - status: string; - }>, -) { - return bookings - .filter( - ( - booking, - ): booking is typeof booking & { resourceId: string; projectId: string } => - booking.resourceId !== null && booking.projectId !== null, - ) - .map((booking) => ({ - id: booking.id, - resourceId: booking.resourceId, - projectId: booking.projectId, - startDate: booking.startDate, - endDate: booking.endDate, - hoursPerDay: booking.hoursPerDay, - status: booking.status, - })); -} - -export function buildTimelineBudgetStatusAllocations( - bookings: Array<{ - status: string; - dailyCostCents: number; - startDate: Date; - endDate: Date; - hoursPerDay: number; - }>, -): Pick< - Allocation, - "status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay" ->[] { - return bookings.map((booking) => ({ - status: booking.status as Allocation["status"], - dailyCostCents: booking.dailyCostCents, - startDate: booking.startDate, - endDate: booking.endDate, - hoursPerDay: booking.hoursPerDay, - })); -} - -export function buildTimelineBudgetStatusResponse(input: { - project: { - name: string; - shortCode: string | null; - budgetCents: number; - }; - budgetStatus: TBudgetStatus; - totalAllocations: number; -}): TBudgetStatus & { - projectName: string; - projectCode: string; - totalAllocations: number; - budgetCents: number; -} { - return { - ...input.budgetStatus, - projectName: input.project.name, - projectCode: input.project.shortCode ?? "", - totalAllocations: input.totalAllocations, - budgetCents: input.project.budgetCents, - }; -} - export function buildTimelineProjectContextResponse< TProject, TAllocation extends { resource?: { id: string } | null }, @@ -303,37 +229,3 @@ export async function loadTimelineProjectContextDetailArtifacts( assignmentConflicts, }; } - -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-query-support.ts b/packages/api/src/router/timeline-project-query-support.ts new file mode 100644 index 0000000..677bcc4 --- /dev/null +++ b/packages/api/src/router/timeline-project-query-support.ts @@ -0,0 +1,60 @@ +import type { Prisma } from "@capakraken/db"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import type { ShiftDbClient } from "./timeline-read-shared.js"; + +export const projectShiftContextSelect = { + id: true, + budgetCents: true, + winProbability: true, + startDate: true, + endDate: true, +} as const satisfies Prisma.ProjectSelect; + +export const timelineProjectContextSelect = { + id: true, + name: true, + shortCode: true, + orderType: true, + budgetCents: true, + winProbability: true, + status: true, + startDate: true, + endDate: true, + staffingReqs: true, +} as const satisfies Prisma.ProjectSelect; + +export const timelineShiftPreviewProjectSelect = { + id: true, + name: true, + shortCode: true, + status: true, + responsiblePerson: true, + startDate: true, + endDate: true, +} as const satisfies Prisma.ProjectSelect; + +export const timelineBudgetStatusProjectSelect = { + id: true, + name: true, + shortCode: true, + budgetCents: true, + winProbability: true, + startDate: true, + endDate: true, +} as const satisfies Prisma.ProjectSelect; + +export async function findTimelineProjectOrThrow( + db: Pick, + input: { + projectId: string; + select: TSelect; + }, +): Promise> { + return findUniqueOrThrow( + db.project.findUnique({ + where: { id: input.projectId }, + select: input.select, + }), + "Project", + ) as Promise>; +} diff --git a/packages/api/src/router/timeline-project-read-support.ts b/packages/api/src/router/timeline-project-read-support.ts new file mode 100644 index 0000000..c5c0102 --- /dev/null +++ b/packages/api/src/router/timeline-project-read-support.ts @@ -0,0 +1,109 @@ +import type { Allocation } from "@capakraken/shared"; +import { fmtDate } from "./timeline-read-shared.js"; + +export function buildTimelineShiftValidationBookings( + bookings: Array<{ + id: string; + resourceId: string | null; + projectId: string | null; + startDate: Date; + endDate: Date; + hoursPerDay: number; + status: string; + }>, +) { + return bookings + .filter( + ( + booking, + ): booking is typeof booking & { resourceId: string; projectId: string } => + booking.resourceId !== null && booking.projectId !== null, + ) + .map((booking) => ({ + id: booking.id, + resourceId: booking.resourceId, + projectId: booking.projectId, + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + status: booking.status, + })); +} + +export function buildTimelineBudgetStatusAllocations( + bookings: Array<{ + status: string; + dailyCostCents: number; + startDate: Date; + endDate: Date; + hoursPerDay: number; + }>, +): Pick< + Allocation, + "status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay" +>[] { + return bookings.map((booking) => ({ + status: booking.status as Allocation["status"], + dailyCostCents: booking.dailyCostCents, + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + })); +} + +export function buildTimelineBudgetStatusResponse(input: { + project: { + name: string; + shortCode: string | null; + budgetCents: number; + }; + budgetStatus: TBudgetStatus; + totalAllocations: number; +}): TBudgetStatus & { + projectName: string; + projectCode: string; + totalAllocations: number; + budgetCents: number; +} { + return { + ...input.budgetStatus, + projectName: input.project.name, + projectCode: input.project.shortCode ?? "", + totalAllocations: input.totalAllocations, + budgetCents: input.project.budgetCents, + }; +} + +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 253f72c..e27f9e1 100644 --- a/packages/api/src/router/timeline-project-read.ts +++ b/packages/api/src/router/timeline-project-read.ts @@ -2,38 +2,37 @@ import { listAssignmentBookings } from "@capakraken/application"; import { computeBudgetStatus } from "@capakraken/engine"; import { ShiftProjectSchema } from "@capakraken/shared"; import { z } from "zod"; -import { findUniqueOrThrow } from "../db/helpers.js"; import { getAnonymizationDirectory } from "../lib/anonymization.js"; import { controllerProcedure } from "../trpc.js"; import { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; import { - buildTimelineBudgetStatusAllocations, - buildTimelineBudgetStatusResponse, - buildTimelineShiftValidationBookings, - buildTimelineShiftPreviewDetailResponse, buildTimelineProjectContextDetailResponse, buildTimelineProjectContextResponse, loadTimelineProjectContextDetailArtifacts, } from "./timeline-project-context-support.js"; +import { + buildTimelineBudgetStatusAllocations, + buildTimelineBudgetStatusResponse, + buildTimelineShiftPreviewDetailResponse, + buildTimelineShiftValidationBookings, +} from "./timeline-project-read-support.js"; +import { + findTimelineProjectOrThrow, + projectShiftContextSelect, + timelineBudgetStatusProjectSelect, + timelineProjectContextSelect, + timelineShiftPreviewProjectSelect, +} from "./timeline-project-query-support.js"; import { buildTimelineShiftPlan } from "./timeline-shift-planning.js"; import { getAssignmentResourceIds, ShiftDbClient } from "./timeline-read-shared.js"; import { buildTimelineProjectShiftValidation } from "./timeline-shift-support.js"; export async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) { const [project, planningRead] = await Promise.all([ - findUniqueOrThrow( - db.project.findUnique({ - where: { id: projectId }, - select: { - id: true, - budgetCents: true, - winProbability: true, - startDate: true, - endDate: true, - }, - }), - "Project", - ), + findTimelineProjectOrThrow(db, { + projectId, + select: projectShiftContextSelect, + }), loadProjectPlanningReadModel(db, { projectId, activeOnly: true }), ]); @@ -65,24 +64,10 @@ export async function loadProjectShiftContext(db: ShiftDbClient, projectId: stri export async function loadTimelineProjectContext(db: ShiftDbClient, projectId: string) { const [project, planningRead] = await Promise.all([ - findUniqueOrThrow( - db.project.findUnique({ - where: { id: projectId }, - select: { - id: true, - name: true, - shortCode: true, - orderType: true, - budgetCents: true, - winProbability: true, - status: true, - startDate: true, - endDate: true, - staffingReqs: true, - }, - }), - "Project", - ), + findTimelineProjectOrThrow(db, { + projectId, + select: timelineProjectContextSelect, + }), loadProjectPlanningReadModel(db, { projectId, activeOnly: true, @@ -198,21 +183,10 @@ export const timelineProjectReadProcedures = { .input(ShiftProjectSchema) .query(async ({ ctx, input }) => { const [project, preview] = await Promise.all([ - findUniqueOrThrow( - ctx.db.project.findUnique({ - where: { id: input.projectId }, - select: { - id: true, - name: true, - shortCode: true, - status: true, - responsiblePerson: true, - startDate: true, - endDate: true, - }, - }), - "Project", - ), + findTimelineProjectOrThrow(ctx.db, { + projectId: input.projectId, + select: timelineShiftPreviewProjectSelect, + }), previewTimelineProjectShift(ctx.db, input), ]); @@ -229,21 +203,10 @@ export const timelineProjectReadProcedures = { getBudgetStatus: controllerProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { - const project = await findUniqueOrThrow( - ctx.db.project.findUnique({ - where: { id: input.projectId }, - select: { - id: true, - name: true, - shortCode: true, - budgetCents: true, - winProbability: true, - startDate: true, - endDate: true, - }, - }), - "Project", - ); + const project = await findTimelineProjectOrThrow(ctx.db, { + projectId: input.projectId, + select: timelineBudgetStatusProjectSelect, + }); const bookings = await listAssignmentBookings(ctx.db, { projectIds: [project.id],