refactor(api): split timeline read router by concern

This commit is contained in:
2026-03-31 07:45:15 +02:00
parent 857914a38f
commit 5e4c0f3610
6 changed files with 1042 additions and 983 deletions
@@ -0,0 +1,308 @@
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";
export type ShiftDbClient = Pick<
PrismaClient,
"project" | "demandRequirement" | "assignment"
>;
export type TimelineEntriesDbClient = Pick<
PrismaClient,
"demandRequirement" | "assignment" | "resource" | "project" | "holidayCalendar" | "country" | "metroCity"
>;
export type TimelineEntriesFilters = {
startDate: Date;
endDate: Date;
resourceIds?: string[] | undefined;
projectIds?: string[] | undefined;
clientIds?: string[] | undefined;
chapters?: string[] | undefined;
eids?: string[] | undefined;
countryCodes?: string[] | undefined;
};
export const TimelineWindowFiltersSchema = z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
countryCodes: z.array(z.string()).optional(),
});
type TimelineWindowFiltersInput = z.infer<typeof TimelineWindowFiltersSchema>;
type TimelineSelfServiceContext = Pick<TRPCContext, "db" | "dbUser">;
export function getAssignmentResourceIds(
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
): string[] {
return [
...new Set(
readModel.assignments
.map((assignment) => assignment.resourceId)
.filter((resourceId): resourceId is string => resourceId !== null),
),
];
}
export function fmtDate(value: Date | null | undefined): string | null {
if (!value) {
return 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: [],
assignments: [],
});
}
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 summarizeTimelineEntries(readModel: {
allocations: Array<{ projectId: string | null; resourceId: string | null }>;
demands: Array<{ projectId: string | null }>;
assignments: Array<{ projectId: string | null; resourceId: string | null }>;
}) {
const projectIds = new Set<string>();
const resourceIds = new Set<string>();
for (const entry of [...readModel.allocations, ...readModel.demands, ...readModel.assignments]) {
if (entry.projectId) {
projectIds.add(entry.projectId);
}
}
for (const assignment of [...readModel.allocations, ...readModel.assignments]) {
if (assignment.resourceId) {
resourceIds.add(assignment.resourceId);
}
}
return {
allocationCount: readModel.allocations.length,
demandCount: readModel.demands.length,
assignmentCount: readModel.assignments.length,
projectCount: projectIds.size,
resourceCount: resourceIds.size,
};
}
export function rangesOverlap(
leftStart: Date,
leftEnd: Date,
rightStart: Date,
rightEnd: Date,
): boolean {
return leftStart <= rightEnd && rightStart <= leftEnd;
}
export function toDate(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
export function anonymizeResourceOnEntry<T extends { resource?: { id: string } | null }>(
entry: T,
directory: Awaited<ReturnType<typeof getAnonymizationDirectory>>,
): T {
if (!entry.resource) {
return entry;
}
return {
...entry,
resource: anonymizeResource(entry.resource, directory),
};
}
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<string, unknown>[] = [];
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" }],
}),
]);
return buildSplitAllocationReadModel({ demandRequirements, assignments });
}