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; type TimelineSelfServiceContext = Pick; export function getAssignmentResourceIds( readModel: ReturnType, ): 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 { 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 { 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 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(); const resourceIds = new Set(); 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( entry: T, directory: Awaited>, ): 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[] = []; 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 }); }