refactor(api): extract timeline filter support

This commit is contained in:
2026-03-31 15:54:56 +02:00
parent 153b90cc11
commit fda6bcab74
3 changed files with 271 additions and 136 deletions
@@ -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();
});
});
@@ -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<TRPCContext, "db" | "dbUser">;
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<TimelineEntriesFilters, "startDate" | "endDate"> {
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<string | null> {
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<TimelineEntriesFilters | null> {
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,
},
};
}
+6 -136
View File
@@ -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<typeof TimelineWindowFiltersSchema>;
type TimelineSelfServiceContext = Pick<TRPCContext, "db" | "dbUser">;
type TimelineAnonymizationDirectory = Awaited<ReturnType<typeof getAnonymizationDirectory>>;
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<TimelineEntriesFilters, "startDate" | "endDate"> {
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<string | null> {
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<TimelineEntriesFilters | null> {
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 }>;