From fda6bcab74ed73e23d44975124a14d97b49b5b24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 15:54:56 +0200 Subject: [PATCH] refactor(api): extract timeline filter support --- .../__tests__/timeline-filter-support.test.ts | 122 +++++++++++++++ .../api/src/router/timeline-filter-support.ts | 143 ++++++++++++++++++ .../api/src/router/timeline-read-shared.ts | 142 +---------------- 3 files changed, 271 insertions(+), 136 deletions(-) create mode 100644 packages/api/src/__tests__/timeline-filter-support.test.ts create mode 100644 packages/api/src/router/timeline-filter-support.ts diff --git a/packages/api/src/__tests__/timeline-filter-support.test.ts b/packages/api/src/__tests__/timeline-filter-support.test.ts new file mode 100644 index 0000000..4411e32 --- /dev/null +++ b/packages/api/src/__tests__/timeline-filter-support.test.ts @@ -0,0 +1,122 @@ +import { TRPCError } from "@trpc/server"; +import { describe, expect, it, vi } from "vitest"; +import { + buildSelfServiceTimelineInput, + buildTimelineEntriesDetailInput, + createTimelineDateRange, + createTimelineFilters, +} from "../router/timeline-filter-support.js"; + +describe("timeline filter support", () => { + it("builds date ranges and rejects inverted ranges", () => { + expect( + createTimelineDateRange({ + startDate: "2026-04-01", + durationDays: 3, + }), + ).toEqual({ + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-03T00:00:00.000Z"), + }); + + expect(() => + createTimelineDateRange({ + startDate: "2026-04-03", + endDate: "2026-04-01", + }), + ).toThrowError(new TRPCError({ + code: "BAD_REQUEST", + message: "endDate must be on or after startDate.", + })); + }); + + it("normalizes timeline filters and assembles detail input", () => { + expect( + createTimelineFilters({ + resourceIds: [" resource_1 ", ""], + projectIds: ["project_1"], + chapters: [" Compositing "], + }), + ).toEqual({ + resourceIds: ["resource_1"], + projectIds: ["project_1"], + clientIds: undefined, + chapters: ["Compositing"], + eids: undefined, + countryCodes: undefined, + }); + + 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 self-service timeline input only for owned resources", async () => { + const ctx = { + dbUser: { id: "user_1" }, + db: { + resource: { + findFirst: vi.fn().mockResolvedValue({ id: "resource_1" }), + }, + }, + }; + + await expect( + buildSelfServiceTimelineInput(ctx as never, { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-03T00:00:00.000Z"), + projectIds: [" project_1 "], + clientIds: [" client_1 "], + }), + ).resolves.toEqual({ + 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: ["client_1"], + }); + + await expect( + buildSelfServiceTimelineInput({ + dbUser: { id: "user_2" }, + db: { + resource: { + findFirst: vi.fn().mockResolvedValue(null), + }, + }, + } as never, { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-03T00:00:00.000Z"), + }), + ).resolves.toBeNull(); + }); +}); diff --git a/packages/api/src/router/timeline-filter-support.ts b/packages/api/src/router/timeline-filter-support.ts new file mode 100644 index 0000000..2aa47c0 --- /dev/null +++ b/packages/api/src/router/timeline-filter-support.ts @@ -0,0 +1,143 @@ +import { TRPCError } from "@trpc/server"; +import type { TRPCContext } from "../trpc.js"; +import type { TimelineEntriesFilters } from "./timeline-read-shared.js"; + +type TimelineSelfServiceContext = Pick; + +function createUtcDate(year: number, month: number, day: number): Date { + return new Date(Date.UTC(year, month, day, 0, 0, 0, 0)); +} + +export function createTimelineDateRange(input: { + startDate?: string | undefined; + endDate?: string | undefined; + durationDays?: number | undefined; +}): { startDate: Date; endDate: Date } { + const now = new Date(); + const startDate = input.startDate + ? new Date(`${input.startDate}T00:00:00.000Z`) + : createUtcDate(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + + if (Number.isNaN(startDate.getTime())) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid startDate: ${input.startDate}`, + }); + } + + const endDate = input.endDate + ? new Date(`${input.endDate}T00:00:00.000Z`) + : createUtcDate( + startDate.getUTCFullYear(), + startDate.getUTCMonth(), + startDate.getUTCDate() + Math.max((input.durationDays ?? 21) - 1, 0), + ); + + if (Number.isNaN(endDate.getTime())) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid endDate: ${input.endDate}`, + }); + } + if (endDate < startDate) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "endDate must be on or after startDate.", + }); + } + + return { startDate, endDate }; +} + +export function normalizeTimelineStringList(values?: string[] | undefined): string[] | undefined { + const normalized = values + ?.map((value) => value.trim()) + .filter((value) => value.length > 0); + + return normalized && normalized.length > 0 ? normalized : undefined; +} + +export function createTimelineFilters(input: { + resourceIds?: string[] | undefined; + projectIds?: string[] | undefined; + clientIds?: string[] | undefined; + chapters?: string[] | undefined; + eids?: string[] | undefined; + countryCodes?: string[] | undefined; +}): Omit { + return { + resourceIds: normalizeTimelineStringList(input.resourceIds), + projectIds: normalizeTimelineStringList(input.projectIds), + clientIds: normalizeTimelineStringList(input.clientIds), + chapters: normalizeTimelineStringList(input.chapters), + eids: normalizeTimelineStringList(input.eids), + countryCodes: normalizeTimelineStringList(input.countryCodes), + }; +} + +async function findOwnedTimelineResourceId( + ctx: TimelineSelfServiceContext, +): Promise { + if (!ctx.dbUser?.id) { + return null; + } + + if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") { + return null; + } + + const resource = await ctx.db.resource.findFirst({ + where: { userId: ctx.dbUser.id }, + select: { id: true }, + }); + + return resource?.id ?? null; +} + +export async function buildSelfServiceTimelineInput( + ctx: TimelineSelfServiceContext, + input: { + startDate: Date; + endDate: Date; + projectIds?: string[] | undefined; + clientIds?: string[] | undefined; + }, +): Promise { + const ownedResourceId = await findOwnedTimelineResourceId(ctx); + if (!ownedResourceId) { + return null; + } + + return { + startDate: input.startDate, + endDate: input.endDate, + resourceIds: [ownedResourceId], + projectIds: normalizeTimelineStringList(input.projectIds), + clientIds: normalizeTimelineStringList(input.clientIds), + }; +} + +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, + }, + }; +} diff --git a/packages/api/src/router/timeline-read-shared.ts b/packages/api/src/router/timeline-read-shared.ts index 591123c..751217d 100644 --- a/packages/api/src/router/timeline-read-shared.ts +++ b/packages/api/src/router/timeline-read-shared.ts @@ -1,10 +1,14 @@ import { buildSplitAllocationReadModel } from "@capakraken/application"; 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 { loadTimelineEntryRecords } from "./timeline-entry-query-support.js"; +export { + buildSelfServiceTimelineInput, + buildTimelineEntriesDetailInput, + createTimelineDateRange, + createTimelineFilters, +} from "./timeline-filter-support.js"; export type ShiftDbClient = Pick< PrismaClient, @@ -39,7 +43,6 @@ export const TimelineWindowFiltersSchema = z.object({ }); type TimelineWindowFiltersInput = z.infer; -type TimelineSelfServiceContext = Pick; type TimelineAnonymizationDirectory = Awaited>; export function getAssignmentResourceIds( @@ -61,77 +64,6 @@ export function fmtDate(value: Date | null | undefined): string | null { return value.toISOString().slice(0, 10); } -function createUtcDate(year: number, month: number, day: number): Date { - return new Date(Date.UTC(year, month, day, 0, 0, 0, 0)); -} - -export function createTimelineDateRange(input: { - startDate?: string | undefined; - endDate?: string | undefined; - durationDays?: number | undefined; -}): { startDate: Date; endDate: Date } { - const now = new Date(); - const startDate = input.startDate - ? new Date(`${input.startDate}T00:00:00.000Z`) - : createUtcDate(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); - - if (Number.isNaN(startDate.getTime())) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid startDate: ${input.startDate}`, - }); - } - - const endDate = input.endDate - ? new Date(`${input.endDate}T00:00:00.000Z`) - : createUtcDate( - startDate.getUTCFullYear(), - startDate.getUTCMonth(), - startDate.getUTCDate() + Math.max((input.durationDays ?? 21) - 1, 0), - ); - - if (Number.isNaN(endDate.getTime())) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid endDate: ${input.endDate}`, - }); - } - if (endDate < startDate) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "endDate must be on or after startDate.", - }); - } - - return { startDate, endDate }; -} - -function normalizeStringList(values?: string[] | undefined): string[] | undefined { - const normalized = values - ?.map((value) => value.trim()) - .filter((value) => value.length > 0); - - return normalized && normalized.length > 0 ? normalized : undefined; -} - -export function createTimelineFilters(input: { - resourceIds?: string[] | undefined; - projectIds?: string[] | undefined; - clientIds?: string[] | undefined; - chapters?: string[] | undefined; - eids?: string[] | undefined; - countryCodes?: string[] | undefined; -}): Omit { - return { - resourceIds: normalizeStringList(input.resourceIds), - projectIds: normalizeStringList(input.projectIds), - clientIds: normalizeStringList(input.clientIds), - chapters: normalizeStringList(input.chapters), - eids: normalizeStringList(input.eids), - countryCodes: normalizeStringList(input.countryCodes), - }; -} - export function createEmptyTimelineEntriesView() { return buildSplitAllocationReadModel({ demandRequirements: [], @@ -155,68 +87,6 @@ export function buildTimelineEntriesViewResponse< }; } -async function findOwnedTimelineResourceId( - ctx: TimelineSelfServiceContext, -): Promise { - if (!ctx.dbUser?.id) { - return null; - } - - if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") { - return null; - } - - const resource = await ctx.db.resource.findFirst({ - where: { userId: ctx.dbUser.id }, - select: { id: true }, - }); - - return resource?.id ?? null; -} - -export async function buildSelfServiceTimelineInput( - ctx: TimelineSelfServiceContext, - input: TimelineWindowFiltersInput, -): Promise { - const ownedResourceId = await findOwnedTimelineResourceId(ctx); - if (!ownedResourceId) { - return null; - } - - return { - startDate: input.startDate, - endDate: input.endDate, - resourceIds: [ownedResourceId], - projectIds: normalizeStringList(input.projectIds), - clientIds: normalizeStringList(input.clientIds), - }; -} - -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 }>;