From 345e9dd6232c94c271c527697147ef864f0d3259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 15:46:17 +0200 Subject: [PATCH] refactor(api): extract timeline entry query support --- .../timeline-entry-query-support.test.ts | 106 ++++++++++++++++++ .../router/timeline-entry-query-support.ts | 99 ++++++++++++++++ .../api/src/router/timeline-read-shared.ts | 73 +----------- 3 files changed, 207 insertions(+), 71 deletions(-) create mode 100644 packages/api/src/__tests__/timeline-entry-query-support.test.ts create mode 100644 packages/api/src/router/timeline-entry-query-support.ts diff --git a/packages/api/src/__tests__/timeline-entry-query-support.test.ts b/packages/api/src/__tests__/timeline-entry-query-support.test.ts new file mode 100644 index 0000000..eb8add3 --- /dev/null +++ b/packages/api/src/__tests__/timeline-entry-query-support.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; +import { + loadTimelineEntryRecords, + resolveTimelineEntryProjectIds, + resolveTimelineEntryResourceIds, +} from "../router/timeline-entry-query-support.js"; + +describe("timeline entry query support", () => { + it("resolves resource ids from direct filters before chapter and geography filters", async () => { + const db = { + resource: { + findMany: vi.fn(), + }, + }; + + await expect( + resolveTimelineEntryResourceIds(db as never, { + resourceIds: ["resource_1"], + chapters: ["Comp"], + eids: ["E-001"], + countryCodes: ["DE"], + }), + ).resolves.toEqual(["resource_1"]); + expect(db.resource.findMany).not.toHaveBeenCalled(); + + db.resource.findMany.mockResolvedValueOnce([{ id: "resource_2" }, { id: "resource_3" }]); + + await expect( + resolveTimelineEntryResourceIds(db as never, { + chapters: ["Comp"], + eids: ["E-001"], + countryCodes: ["DE"], + }), + ).resolves.toEqual(["resource_2", "resource_3"]); + expect(db.resource.findMany).toHaveBeenCalledWith({ + where: { + AND: [ + { chapter: { in: ["Comp"] } }, + { eid: { in: ["E-001"] } }, + { country: { code: { in: ["DE"] } } }, + ], + }, + select: { id: true }, + }); + }); + + it("resolves project ids from client filters and intersects explicit project filters", async () => { + const db = { + project: { + findMany: vi.fn().mockResolvedValue([{ id: "project_1" }, { id: "project_2" }]), + }, + }; + + await expect( + resolveTimelineEntryProjectIds(db as never, { + clientIds: ["client_1"], + }), + ).resolves.toEqual(["project_1", "project_2"]); + + await expect( + resolveTimelineEntryProjectIds(db as never, { + clientIds: ["client_1"], + projectIds: ["project_2", "project_3"], + }), + ).resolves.toEqual(["project_2"]); + }); + + it("loads assignments and skips demands when resource filters are active", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([{ id: "resource_1" }]), + }, + project: { + findMany: vi.fn().mockResolvedValue([{ id: "project_1" }]), + }, + demandRequirement: { + findMany: vi.fn(), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([{ id: "assignment_1" }]), + }, + }; + + await expect( + loadTimelineEntryRecords(db as never, { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + chapters: ["Comp"], + clientIds: ["client_1"], + }), + ).resolves.toEqual({ + demandRequirements: [], + assignments: [{ id: "assignment_1" }], + }); + + expect(db.demandRequirement.findMany).not.toHaveBeenCalled(); + expect(db.assignment.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + resourceId: { in: ["resource_1"] }, + projectId: { in: ["project_1"] }, + }), + }), + ); + }); +}); diff --git a/packages/api/src/router/timeline-entry-query-support.ts b/packages/api/src/router/timeline-entry-query-support.ts new file mode 100644 index 0000000..5439dee --- /dev/null +++ b/packages/api/src/router/timeline-entry-query-support.ts @@ -0,0 +1,99 @@ +import { Prisma } from "@capakraken/db"; +import { + PROJECT_PLANNING_DEMAND_INCLUDE, + TIMELINE_ASSIGNMENT_INCLUDE, +} from "./project-planning-read-model.js"; +import type { TimelineEntriesDbClient, TimelineEntriesFilters } from "./timeline-read-shared.js"; + +export async function resolveTimelineEntryResourceIds( + db: TimelineEntriesDbClient, + input: Pick, +): Promise { + if (input.resourceIds && input.resourceIds.length > 0) { + return input.resourceIds; + } + + const hasChapters = !!input.chapters?.length; + const hasEids = !!input.eids?.length; + const hasCountry = !!input.countryCodes?.length; + if (!hasChapters && !hasEids && !hasCountry) { + return undefined; + } + + const andConditions: Record[] = []; + if (hasChapters) { + andConditions.push({ chapter: { in: input.chapters } }); + } + if (hasEids) { + andConditions.push({ eid: { in: input.eids } }); + } + if (hasCountry) { + andConditions.push({ country: { code: { in: input.countryCodes } } }); + } + + const matching = await db.resource.findMany({ + where: (andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }) as Prisma.ResourceWhereInput, + select: { id: true }, + }); + + return matching.map((resource) => resource.id); +} + +export async function resolveTimelineEntryProjectIds( + db: TimelineEntriesDbClient, + input: Pick, +): Promise { + if (!input.clientIds?.length) { + return input.projectIds; + } + + const matchingProjects = await db.project.findMany({ + where: { clientId: { in: input.clientIds } }, + select: { id: true }, + }); + const clientProjectIds = matchingProjects.map((project) => project.id); + + if (!input.projectIds?.length) { + return clientProjectIds; + } + + const allowedIds = new Set(clientProjectIds); + return input.projectIds.filter((projectId) => allowedIds.has(projectId)); +} + +export async function loadTimelineEntryRecords( + db: TimelineEntriesDbClient, + input: TimelineEntriesFilters, +) { + const effectiveResourceIds = await resolveTimelineEntryResourceIds(db, input); + const effectiveProjectIds = await resolveTimelineEntryProjectIds(db, input); + const excludeDemands = effectiveResourceIds !== undefined; + + const [demandRequirements, assignments] = await Promise.all([ + excludeDemands + ? Promise.resolve([]) + : db.demandRequirement.findMany({ + where: { + status: { not: "CANCELLED" }, + startDate: { lte: input.endDate }, + endDate: { gte: input.startDate }, + ...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}), + }, + include: PROJECT_PLANNING_DEMAND_INCLUDE, + orderBy: [{ startDate: "asc" }, { projectId: "asc" }], + }), + db.assignment.findMany({ + where: { + status: { not: "CANCELLED" }, + startDate: { lte: input.endDate }, + endDate: { gte: input.startDate }, + ...(effectiveResourceIds ? { resourceId: { in: effectiveResourceIds } } : {}), + ...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}), + }, + include: TIMELINE_ASSIGNMENT_INCLUDE, + orderBy: [{ startDate: "asc" }, { resourceId: "asc" }], + }), + ]); + + return { demandRequirements, assignments }; +} diff --git a/packages/api/src/router/timeline-read-shared.ts b/packages/api/src/router/timeline-read-shared.ts index eb3253b..591123c 100644 --- a/packages/api/src/router/timeline-read-shared.ts +++ b/packages/api/src/router/timeline-read-shared.ts @@ -1,14 +1,10 @@ import { buildSplitAllocationReadModel } from "@capakraken/application"; -import { Prisma } from "@capakraken/db"; import type { PrismaClient } from "@capakraken/db"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js"; import type { TRPCContext } from "../trpc.js"; -import { - PROJECT_PLANNING_DEMAND_INCLUDE, - TIMELINE_ASSIGNMENT_INCLUDE, -} from "./project-planning-read-model.js"; +import { loadTimelineEntryRecords } from "./timeline-entry-query-support.js"; export type ShiftDbClient = Pick< PrismaClient, @@ -318,71 +314,6 @@ export async function loadTimelineEntriesReadModel( db: TimelineEntriesDbClient, input: TimelineEntriesFilters, ) { - const { startDate, endDate, resourceIds, projectIds, clientIds, chapters, eids, countryCodes } = input; - - const effectiveResourceIds = await (async () => { - if (resourceIds && resourceIds.length > 0) return resourceIds; - const hasChapters = chapters && chapters.length > 0; - const hasEids = eids && eids.length > 0; - const hasCountry = countryCodes && countryCodes.length > 0; - if (!hasChapters && !hasEids && !hasCountry) return undefined; - - const andConditions: Record[] = []; - if (hasChapters) andConditions.push({ chapter: { in: chapters } }); - if (hasEids) andConditions.push({ eid: { in: eids } }); - if (hasCountry) andConditions.push({ country: { code: { in: countryCodes } } }); - - const matching = await db.resource.findMany({ - where: (andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }) as Prisma.ResourceWhereInput, - select: { id: true }, - }); - return matching.map((resource) => resource.id); - })(); - - const effectiveProjectIds = await (async () => { - if (!clientIds || clientIds.length === 0) return projectIds; - - const matchingProjects = await db.project.findMany({ - where: { clientId: { in: clientIds } }, - select: { id: true }, - }); - const clientProjectIds = matchingProjects.map((project) => project.id); - - if (!projectIds || projectIds.length === 0) { - return clientProjectIds; - } - - const allowedIds = new Set(clientProjectIds); - return projectIds.filter((projectId) => allowedIds.has(projectId)); - })(); - - const excludeDemands = effectiveResourceIds !== undefined; - - const [demandRequirements, assignments] = await Promise.all([ - excludeDemands - ? Promise.resolve([]) - : db.demandRequirement.findMany({ - where: { - status: { not: "CANCELLED" }, - startDate: { lte: endDate }, - endDate: { gte: startDate }, - ...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}), - }, - include: PROJECT_PLANNING_DEMAND_INCLUDE, - orderBy: [{ startDate: "asc" }, { projectId: "asc" }], - }), - db.assignment.findMany({ - where: { - status: { not: "CANCELLED" }, - startDate: { lte: endDate }, - endDate: { gte: startDate }, - ...(effectiveResourceIds ? { resourceId: { in: effectiveResourceIds } } : {}), - ...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}), - }, - include: TIMELINE_ASSIGNMENT_INCLUDE, - orderBy: [{ startDate: "asc" }, { resourceId: "asc" }], - }), - ]); - + const { demandRequirements, assignments } = await loadTimelineEntryRecords(db, input); return buildSplitAllocationReadModel({ demandRequirements, assignments }); }