refactor(api): extract timeline filter support
This commit is contained in:
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { buildSplitAllocationReadModel } from "@capakraken/application";
|
import { buildSplitAllocationReadModel } from "@capakraken/application";
|
||||||
import type { PrismaClient } from "@capakraken/db";
|
import type { PrismaClient } from "@capakraken/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
|
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||||
import type { TRPCContext } from "../trpc.js";
|
|
||||||
import { loadTimelineEntryRecords } from "./timeline-entry-query-support.js";
|
import { loadTimelineEntryRecords } from "./timeline-entry-query-support.js";
|
||||||
|
export {
|
||||||
|
buildSelfServiceTimelineInput,
|
||||||
|
buildTimelineEntriesDetailInput,
|
||||||
|
createTimelineDateRange,
|
||||||
|
createTimelineFilters,
|
||||||
|
} from "./timeline-filter-support.js";
|
||||||
|
|
||||||
export type ShiftDbClient = Pick<
|
export type ShiftDbClient = Pick<
|
||||||
PrismaClient,
|
PrismaClient,
|
||||||
@@ -39,7 +43,6 @@ export const TimelineWindowFiltersSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
type TimelineWindowFiltersInput = z.infer<typeof TimelineWindowFiltersSchema>;
|
type TimelineWindowFiltersInput = z.infer<typeof TimelineWindowFiltersSchema>;
|
||||||
type TimelineSelfServiceContext = Pick<TRPCContext, "db" | "dbUser">;
|
|
||||||
type TimelineAnonymizationDirectory = Awaited<ReturnType<typeof getAnonymizationDirectory>>;
|
type TimelineAnonymizationDirectory = Awaited<ReturnType<typeof getAnonymizationDirectory>>;
|
||||||
|
|
||||||
export function getAssignmentResourceIds(
|
export function getAssignmentResourceIds(
|
||||||
@@ -61,77 +64,6 @@ export function fmtDate(value: Date | null | undefined): string | null {
|
|||||||
return value.toISOString().slice(0, 10);
|
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() {
|
export function createEmptyTimelineEntriesView() {
|
||||||
return buildSplitAllocationReadModel({
|
return buildSplitAllocationReadModel({
|
||||||
demandRequirements: [],
|
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: {
|
export function summarizeTimelineEntries(readModel: {
|
||||||
allocations: Array<{ projectId: string | null; resourceId: string | null }>;
|
allocations: Array<{ projectId: string | null; resourceId: string | null }>;
|
||||||
demands: Array<{ projectId: string | null }>;
|
demands: Array<{ projectId: string | null }>;
|
||||||
|
|||||||
Reference in New Issue
Block a user