diff --git a/packages/api/src/router/timeline-entry-read.ts b/packages/api/src/router/timeline-entry-read.ts new file mode 100644 index 0000000..8816764 --- /dev/null +++ b/packages/api/src/router/timeline-entry-read.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; +import { getAnonymizationDirectory } from "../lib/anonymization.js"; +import { controllerProcedure, protectedProcedure } from "../trpc.js"; +import { + anonymizeResourceOnEntry, + buildSelfServiceTimelineInput, + createEmptyTimelineEntriesView, + createTimelineDateRange, + createTimelineFilters, + fmtDate, + loadTimelineEntriesReadModel, + summarizeTimelineEntries, + TimelineWindowFiltersSchema, +} from "./timeline-read-shared.js"; +import { + formatHolidayOverlays, + loadTimelineHolidayOverlaysForReadModel, + summarizeHolidayOverlays, +} from "./timeline-holiday-read.js"; + +export const timelineEntryReadProcedures = { + getEntries: controllerProcedure + .input(TimelineWindowFiltersSchema) + .query(async ({ ctx, input }) => { + const readModel = await loadTimelineEntriesReadModel(ctx.db, input); + const directory = await getAnonymizationDirectory(ctx.db); + return readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)); + }), + + getEntriesView: controllerProcedure + .input(TimelineWindowFiltersSchema) + .query(async ({ ctx, input }) => { + const [readModel, directory] = await Promise.all([ + loadTimelineEntriesReadModel(ctx.db, input), + getAnonymizationDirectory(ctx.db), + ]); + + return { + ...readModel, + allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)), + assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)), + }; + }), + + getMyEntriesView: protectedProcedure + .input(TimelineWindowFiltersSchema) + .query(async ({ ctx, input }) => { + const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input); + if (!selfServiceInput) { + return createEmptyTimelineEntriesView(); + } + + const [readModel, directory] = await Promise.all([ + loadTimelineEntriesReadModel(ctx.db, selfServiceInput), + getAnonymizationDirectory(ctx.db), + ]); + + return { + ...readModel, + allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)), + assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)), + }; + }), + + getEntriesDetail: controllerProcedure + .input( + z.object({ + startDate: z.string().optional(), + endDate: z.string().optional(), + durationDays: z.number().int().min(1).max(366).optional(), + 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(), + }), + ) + .query(async ({ ctx, input }) => { + const { startDate, endDate } = createTimelineDateRange(input); + const filters = createTimelineFilters(input); + const timelineInput = { ...filters, startDate, endDate }; + + const [readModel, directory] = await Promise.all([ + loadTimelineEntriesReadModel(ctx.db, timelineInput), + getAnonymizationDirectory(ctx.db), + ]); + const holidayOverlays = await loadTimelineHolidayOverlaysForReadModel( + ctx.db, + timelineInput, + readModel, + ); + const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays); + + return { + period: { + startDate: fmtDate(startDate), + endDate: fmtDate(endDate), + }, + filters, + summary: { + ...summarizeTimelineEntries(readModel), + ...summarizeHolidayOverlays(formattedHolidayOverlays), + }, + allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)), + demands: readModel.demands, + assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)), + holidayOverlays: formattedHolidayOverlays, + }; + }), +}; diff --git a/packages/api/src/router/timeline-holiday-read.ts b/packages/api/src/router/timeline-holiday-read.ts new file mode 100644 index 0000000..be0784d --- /dev/null +++ b/packages/api/src/router/timeline-holiday-read.ts @@ -0,0 +1,234 @@ +import { buildSplitAllocationReadModel } from "@capakraken/application"; +import { Prisma, VacationType } from "@capakraken/db"; +import { z } from "zod"; +import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js"; +import { controllerProcedure, protectedProcedure } from "../trpc.js"; +import { + buildSelfServiceTimelineInput, + createTimelineDateRange, + createTimelineFilters, + fmtDate, + loadTimelineEntriesReadModel, + TimelineEntriesDbClient, + TimelineEntriesFilters, + TimelineWindowFiltersSchema, +} from "./timeline-read-shared.js"; + +export function formatHolidayOverlays( + overlays: Array<{ + id: string; + resourceId: string; + startDate: Date; + endDate: Date; + note?: string | null; + scope?: string | null; + calendarName?: string | null; + sourceType?: string | null; + countryCode?: string | null; + countryName?: string | null; + federalState?: string | null; + metroCityName?: string | null; + }>, +) { + return overlays.map((overlay) => ({ + id: overlay.id, + resourceId: overlay.resourceId, + startDate: fmtDate(overlay.startDate), + endDate: fmtDate(overlay.endDate), + note: overlay.note ?? null, + scope: overlay.scope ?? null, + calendarName: overlay.calendarName ?? null, + sourceType: overlay.sourceType ?? null, + countryCode: overlay.countryCode ?? null, + countryName: overlay.countryName ?? null, + federalState: overlay.federalState ?? null, + metroCityName: overlay.metroCityName ?? null, + })); +} + +export function summarizeHolidayOverlays( + overlays: ReturnType, +) { + const resourceIds = new Set(); + const byScope = new Map(); + + for (const overlay of overlays) { + resourceIds.add(overlay.resourceId); + const scope = overlay.scope ?? "UNKNOWN"; + byScope.set(scope, (byScope.get(scope) ?? 0) + 1); + } + + return { + overlayCount: overlays.length, + holidayResourceCount: resourceIds.size, + byScope: [...byScope.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([scope, count]) => ({ scope, count })), + }; +} + +export async function loadTimelineHolidayOverlays( + db: TimelineEntriesDbClient, + input: TimelineEntriesFilters, +) { + const readModel = await loadTimelineEntriesReadModel(db, input); + return loadTimelineHolidayOverlaysForReadModel(db, input, readModel); +} + +export async function loadTimelineHolidayOverlaysForReadModel( + db: TimelineEntriesDbClient, + input: TimelineEntriesFilters, + readModel: ReturnType, +) { + const resourceIds = [...new Set( + readModel.assignments + .map((assignment) => assignment.resourceId) + .filter((resourceId): resourceId is string => typeof resourceId === "string" && resourceId.length > 0), + )]; + + if (input.resourceIds && input.resourceIds.length > 0) { + for (const resourceId of input.resourceIds) { + if (resourceId && !resourceIds.includes(resourceId)) { + resourceIds.push(resourceId); + } + } + } + + const hasResourceFilters = + (input.chapters?.length ?? 0) > 0 || + (input.eids?.length ?? 0) > 0 || + (input.countryCodes?.length ?? 0) > 0; + + if (hasResourceFilters) { + const andConditions: Prisma.ResourceWhereInput[] = []; + if (input.chapters && input.chapters.length > 0) { + andConditions.push({ chapter: { in: input.chapters } }); + } + if (input.eids && input.eids.length > 0) { + andConditions.push({ eid: { in: input.eids } }); + } + if (input.countryCodes && input.countryCodes.length > 0) { + andConditions.push({ country: { code: { in: input.countryCodes } } }); + } + + const matchingResources = await db.resource.findMany({ + where: andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }, + select: { id: true }, + }); + + for (const resource of matchingResources) { + if (!resourceIds.includes(resource.id)) { + resourceIds.push(resource.id); + } + } + } + + if (resourceIds.length === 0) { + return []; + } + + const resources = await db.resource.findMany({ + where: { id: { in: resourceIds } }, + select: { + id: true, + countryId: true, + federalState: true, + metroCityId: true, + country: { select: { code: true, name: true } }, + metroCity: { select: { name: true } }, + }, + }); + + const overlays = await Promise.all( + resources.map(async (resource) => { + const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), { + periodStart: input.startDate, + periodEnd: input.endDate, + countryId: resource.countryId, + countryCode: resource.country?.code ?? null, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name ?? null, + }); + + return holidays.map((holiday) => { + const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`); + return { + id: `calendar-holiday:${resource.id}:${holiday.date}`, + resourceId: resource.id, + type: VacationType.PUBLIC_HOLIDAY, + status: "APPROVED" as const, + startDate: holidayDate, + endDate: holidayDate, + note: holiday.name, + scope: holiday.scope, + calendarName: holiday.calendarName, + sourceType: holiday.sourceType, + countryCode: resource.country?.code ?? null, + countryName: resource.country?.name ?? null, + federalState: resource.federalState ?? null, + metroCityName: resource.metroCity?.name ?? null, + }; + }); + }), + ); + + return overlays.flat().sort((left, right) => { + if (left.resourceId !== right.resourceId) { + return left.resourceId.localeCompare(right.resourceId); + } + return left.startDate.getTime() - right.startDate.getTime(); + }); +} + +export const timelineHolidayReadProcedures = { + getHolidayOverlays: controllerProcedure + .input(TimelineWindowFiltersSchema) + .query(async ({ ctx, input }) => loadTimelineHolidayOverlays(ctx.db, input)), + + getMyHolidayOverlays: protectedProcedure + .input(TimelineWindowFiltersSchema) + .query(async ({ ctx, input }) => { + const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input); + if (!selfServiceInput) { + return []; + } + + return loadTimelineHolidayOverlays(ctx.db, selfServiceInput); + }), + + getHolidayOverlayDetail: controllerProcedure + .input( + z.object({ + startDate: z.string().optional(), + endDate: z.string().optional(), + durationDays: z.number().int().min(1).max(366).optional(), + 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(), + }), + ) + .query(async ({ ctx, input }) => { + const { startDate, endDate } = createTimelineDateRange(input); + const filters = createTimelineFilters(input); + const holidayOverlays = await loadTimelineHolidayOverlays(ctx.db, { + ...filters, + startDate, + endDate, + }); + const formattedOverlays = formatHolidayOverlays(holidayOverlays); + + return { + period: { + startDate: fmtDate(startDate), + endDate: fmtDate(endDate), + }, + filters, + summary: summarizeHolidayOverlays(formattedOverlays), + overlays: formattedOverlays, + }; + }), +}; diff --git a/packages/api/src/router/timeline-project-read.ts b/packages/api/src/router/timeline-project-read.ts new file mode 100644 index 0000000..f30323e --- /dev/null +++ b/packages/api/src/router/timeline-project-read.ts @@ -0,0 +1,381 @@ +import { listAssignmentBookings } from "@capakraken/application"; +import { computeBudgetStatus, validateShift } from "@capakraken/engine"; +import { ShiftProjectSchema } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { getAnonymizationDirectory } from "../lib/anonymization.js"; +import { controllerProcedure } from "../trpc.js"; +import { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; +import { buildTimelineShiftPlan } from "./timeline-shift-planning.js"; +import { loadTimelineHolidayOverlays, formatHolidayOverlays, summarizeHolidayOverlays } from "./timeline-holiday-read.js"; +import { + anonymizeResourceOnEntry, + createTimelineDateRange, + fmtDate, + getAssignmentResourceIds, + rangesOverlap, + ShiftDbClient, + summarizeTimelineEntries, + toDate, +} from "./timeline-read-shared.js"; + +export async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) { + const [project, planningRead] = await Promise.all([ + findUniqueOrThrow( + db.project.findUnique({ + where: { id: projectId }, + select: { + id: true, + budgetCents: true, + winProbability: true, + startDate: true, + endDate: true, + }, + }), + "Project", + ), + loadProjectPlanningReadModel(db, { projectId, activeOnly: true }), + ]); + + const { demandRequirements, assignments, readModel: projectReadModel } = planningRead; + + const resourceIds = getAssignmentResourceIds(projectReadModel); + const allAssignmentWindows = + resourceIds.length === 0 + ? [] + : ( + await listAssignmentBookings(db, { + resourceIds, + }) + ).map((booking) => ({ + id: booking.id, + resourceId: booking.resourceId!, + projectId: booking.projectId, + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + status: booking.status, + })); + + const shiftPlan = buildTimelineShiftPlan({ + demandRequirements, + assignments, + allAssignmentWindows, + }); + + return { + project, + demandRequirements, + assignments, + shiftPlan, + }; +} + +export async function loadTimelineProjectContext(db: ShiftDbClient, projectId: string) { + const [project, planningRead] = await Promise.all([ + findUniqueOrThrow( + db.project.findUnique({ + where: { id: projectId }, + select: { + id: true, + name: true, + shortCode: true, + orderType: true, + budgetCents: true, + winProbability: true, + status: true, + startDate: true, + endDate: true, + staffingReqs: true, + }, + }), + "Project", + ), + loadProjectPlanningReadModel(db, { + projectId, + activeOnly: true, + }), + ]); + + const resourceIds = getAssignmentResourceIds(planningRead.readModel); + const allResourceAllocations = + resourceIds.length === 0 + ? [] + : await listAssignmentBookings(db, { + resourceIds, + }); + + return { + project, + allocations: planningRead.readModel.allocations, + demands: planningRead.readModel.demands, + assignments: planningRead.readModel.assignments, + allResourceAllocations, + resourceIds, + }; +} + +export async function previewTimelineProjectShift( + db: ShiftDbClient, + input: { + projectId: string; + newStartDate: Date; + newEndDate: Date; + }, +) { + const { project, shiftPlan } = await loadProjectShiftContext(db, input.projectId); + + return validateShift({ + project: { + id: project.id, + budgetCents: project.budgetCents, + winProbability: project.winProbability, + startDate: project.startDate, + endDate: project.endDate, + }, + newStartDate: input.newStartDate, + newEndDate: input.newEndDate, + allocations: shiftPlan.validationAllocations, + }); +} + +export const timelineProjectReadProcedures = { + getProjectContext: controllerProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }) => { + const { + project, + allocations, + demands, + assignments, + allResourceAllocations, + resourceIds, + } = await loadTimelineProjectContext(ctx.db, input.projectId); + const directory = await getAnonymizationDirectory(ctx.db); + + return { + project, + allocations: allocations.map((allocation) => + anonymizeResourceOnEntry(allocation, directory), + ), + demands, + assignments: assignments.map((assignment) => + anonymizeResourceOnEntry(assignment, directory), + ), + allResourceAllocations: allResourceAllocations.map((allocation) => + anonymizeResourceOnEntry(allocation, directory), + ), + resourceIds, + }; + }), + + getProjectContextDetail: controllerProcedure + .input( + z.object({ + projectId: z.string(), + startDate: z.string().optional(), + endDate: z.string().optional(), + durationDays: z.number().int().min(1).max(366).optional(), + }), + ) + .query(async ({ ctx, input }) => { + const projectContext = await loadTimelineProjectContext(ctx.db, input.projectId); + const directory = await getAnonymizationDirectory(ctx.db); + + const derivedStartDate = input.startDate + ? createTimelineDateRange({ startDate: input.startDate, durationDays: 1 }).startDate + : projectContext.project.startDate + ?? projectContext.assignments[0]?.startDate + ?? projectContext.demands[0]?.startDate + ?? createTimelineDateRange({ durationDays: 1 }).startDate; + const derivedEndDate = input.endDate + ? createTimelineDateRange({ startDate: fmtDate(derivedStartDate) ?? undefined, endDate: input.endDate }).endDate + : projectContext.project.endDate + ?? createTimelineDateRange({ + startDate: fmtDate(derivedStartDate) ?? undefined, + durationDays: input.durationDays ?? 21, + }).endDate; + + if (derivedEndDate < derivedStartDate) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "endDate must be on or after startDate.", + }); + } + + const holidayOverlays = projectContext.resourceIds.length > 0 + ? await loadTimelineHolidayOverlays(ctx.db, { + startDate: derivedStartDate, + endDate: derivedEndDate, + resourceIds: projectContext.resourceIds, + projectIds: [input.projectId], + }) + : []; + const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays); + + const assignmentConflicts = projectContext.assignments + .filter((assignment) => assignment.resourceId && assignment.resource) + .map((assignment) => { + const overlaps = projectContext.allResourceAllocations + .filter((booking) => ( + booking.resourceId === assignment.resourceId + && booking.id !== assignment.id + && rangesOverlap( + toDate(booking.startDate), + toDate(booking.endDate), + toDate(assignment.startDate), + toDate(assignment.endDate), + ) + )) + .map((booking) => ({ + id: booking.id, + projectId: booking.projectId, + projectName: booking.project?.name ?? null, + projectShortCode: booking.project?.shortCode ?? null, + startDate: fmtDate(toDate(booking.startDate)), + endDate: fmtDate(toDate(booking.endDate)), + hoursPerDay: booking.hoursPerDay, + status: booking.status, + sameProject: booking.projectId === input.projectId, + })); + + return { + assignmentId: assignment.id, + resourceId: assignment.resourceId!, + resourceName: assignment.resource?.displayName ?? null, + startDate: fmtDate(toDate(assignment.startDate)), + endDate: fmtDate(toDate(assignment.endDate)), + hoursPerDay: assignment.hoursPerDay, + overlapCount: overlaps.length, + crossProjectOverlapCount: overlaps.filter((booking) => !booking.sameProject).length, + overlaps, + }; + }); + + return { + project: projectContext.project, + period: { + startDate: fmtDate(derivedStartDate), + endDate: fmtDate(derivedEndDate), + }, + summary: { + ...summarizeTimelineEntries({ + allocations: projectContext.allocations, + demands: projectContext.demands, + assignments: projectContext.assignments, + }), + resourceIds: projectContext.resourceIds.length, + allResourceAllocationCount: projectContext.allResourceAllocations.length, + conflictedAssignmentCount: assignmentConflicts.filter((item) => item.crossProjectOverlapCount > 0).length, + ...summarizeHolidayOverlays(formattedHolidayOverlays), + }, + allocations: projectContext.allocations.map((allocation) => + anonymizeResourceOnEntry(allocation, directory), + ), + demands: projectContext.demands, + assignments: projectContext.assignments.map((assignment) => + anonymizeResourceOnEntry(assignment, directory), + ), + allResourceAllocations: projectContext.allResourceAllocations.map((allocation) => + anonymizeResourceOnEntry(allocation, directory), + ), + assignmentConflicts, + holidayOverlays: formattedHolidayOverlays, + resourceIds: projectContext.resourceIds, + }; + }), + + previewShift: controllerProcedure + .input(ShiftProjectSchema) + .query(async ({ ctx, input }) => previewTimelineProjectShift(ctx.db, input)), + + getShiftPreviewDetail: controllerProcedure + .input(ShiftProjectSchema) + .query(async ({ ctx, input }) => { + const [project, preview] = await Promise.all([ + findUniqueOrThrow( + ctx.db.project.findUnique({ + where: { id: input.projectId }, + select: { + id: true, + name: true, + shortCode: true, + status: true, + responsiblePerson: true, + startDate: true, + endDate: true, + }, + }), + "Project", + ), + previewTimelineProjectShift(ctx.db, input), + ]); + + return { + project: { + id: project.id, + name: project.name, + shortCode: project.shortCode, + status: project.status, + responsiblePerson: project.responsiblePerson, + startDate: fmtDate(project.startDate), + endDate: fmtDate(project.endDate), + }, + requestedShift: { + newStartDate: fmtDate(input.newStartDate), + newEndDate: fmtDate(input.newEndDate), + }, + preview, + }; + }), + + getBudgetStatus: controllerProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }) => { + const project = await findUniqueOrThrow( + ctx.db.project.findUnique({ + where: { id: input.projectId }, + select: { + id: true, + name: true, + shortCode: true, + budgetCents: true, + winProbability: true, + startDate: true, + endDate: true, + }, + }), + "Project", + ); + + const bookings = await listAssignmentBookings(ctx.db, { + projectIds: [project.id], + }); + + const budgetStatus = computeBudgetStatus( + project.budgetCents, + project.winProbability, + bookings.map((booking) => ({ + status: booking.status, + dailyCostCents: booking.dailyCostCents, + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + })) as unknown as Pick< + import("@capakraken/shared").Allocation, + "status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay" + >[], + project.startDate, + project.endDate, + ); + + return { + ...budgetStatus, + projectName: project.name, + projectCode: project.shortCode, + totalAllocations: bookings.length, + budgetCents: project.budgetCents, + }; + }), +}; diff --git a/packages/api/src/router/timeline-read-shared.ts b/packages/api/src/router/timeline-read-shared.ts new file mode 100644 index 0000000..2b0b9ec --- /dev/null +++ b/packages/api/src/router/timeline-read-shared.ts @@ -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; +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 }); +} diff --git a/packages/api/src/router/timeline-read.ts b/packages/api/src/router/timeline-read.ts index b60fe31..e20d0d1 100644 --- a/packages/api/src/router/timeline-read.ts +++ b/packages/api/src/router/timeline-read.ts @@ -1,985 +1,9 @@ -import { - buildSplitAllocationReadModel, - listAssignmentBookings, -} from "@capakraken/application"; -import { Prisma, VacationType } from "@capakraken/db"; -import type { PrismaClient } from "@capakraken/db"; -import { computeBudgetStatus, validateShift } from "@capakraken/engine"; -import { ShiftProjectSchema } from "@capakraken/shared"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod"; -import { findUniqueOrThrow } from "../db/helpers.js"; -import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js"; -import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js"; -import type { TRPCContext } from "../trpc.js"; -import { controllerProcedure, protectedProcedure } from "../trpc.js"; -import { - loadProjectPlanningReadModel, - PROJECT_PLANNING_DEMAND_INCLUDE, - TIMELINE_ASSIGNMENT_INCLUDE, -} from "./project-planning-read-model.js"; -import { buildTimelineShiftPlan } from "./timeline-shift-planning.js"; - -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), - ), - ]; -} - -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)); -} - -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; -} - -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), - }; -} - -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; -} - -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), - }; -} - -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, - }; -} - -function formatHolidayOverlays( - overlays: Array<{ - id: string; - resourceId: string; - startDate: Date; - endDate: Date; - note?: string | null; - scope?: string | null; - calendarName?: string | null; - sourceType?: string | null; - countryCode?: string | null; - countryName?: string | null; - federalState?: string | null; - metroCityName?: string | null; - }>, -) { - return overlays.map((overlay) => ({ - id: overlay.id, - resourceId: overlay.resourceId, - startDate: fmtDate(overlay.startDate), - endDate: fmtDate(overlay.endDate), - note: overlay.note ?? null, - scope: overlay.scope ?? null, - calendarName: overlay.calendarName ?? null, - sourceType: overlay.sourceType ?? null, - countryCode: overlay.countryCode ?? null, - countryName: overlay.countryName ?? null, - federalState: overlay.federalState ?? null, - metroCityName: overlay.metroCityName ?? null, - })); -} - -function summarizeHolidayOverlays( - overlays: ReturnType, -) { - const resourceIds = new Set(); - const byScope = new Map(); - - for (const overlay of overlays) { - resourceIds.add(overlay.resourceId); - const scope = overlay.scope ?? "UNKNOWN"; - byScope.set(scope, (byScope.get(scope) ?? 0) + 1); - } - - return { - overlayCount: overlays.length, - holidayResourceCount: resourceIds.size, - byScope: [...byScope.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([scope, count]) => ({ scope, count })), - }; -} - -function rangesOverlap( - leftStart: Date, - leftEnd: Date, - rightStart: Date, - rightEnd: Date, -): boolean { - return leftStart <= rightEnd && rightStart <= leftEnd; -} - -function toDate(value: Date | string): Date { - return value instanceof Date ? value : new Date(value); -} - -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 }); -} - -export async function loadTimelineHolidayOverlays( - db: TimelineEntriesDbClient, - input: TimelineEntriesFilters, -) { - const readModel = await loadTimelineEntriesReadModel(db, input); - return loadTimelineHolidayOverlaysForReadModel(db, input, readModel); -} - -async function loadTimelineHolidayOverlaysForReadModel( - db: TimelineEntriesDbClient, - input: TimelineEntriesFilters, - readModel: ReturnType, -) { - const resourceIds = [...new Set( - readModel.assignments - .map((assignment) => assignment.resourceId) - .filter((resourceId): resourceId is string => typeof resourceId === "string" && resourceId.length > 0), - )]; - - if (input.resourceIds && input.resourceIds.length > 0) { - for (const resourceId of input.resourceIds) { - if (resourceId && !resourceIds.includes(resourceId)) { - resourceIds.push(resourceId); - } - } - } - - const hasResourceFilters = - (input.chapters?.length ?? 0) > 0 || - (input.eids?.length ?? 0) > 0 || - (input.countryCodes?.length ?? 0) > 0; - - if (hasResourceFilters) { - const andConditions: Prisma.ResourceWhereInput[] = []; - if (input.chapters && input.chapters.length > 0) { - andConditions.push({ chapter: { in: input.chapters } }); - } - if (input.eids && input.eids.length > 0) { - andConditions.push({ eid: { in: input.eids } }); - } - if (input.countryCodes && input.countryCodes.length > 0) { - andConditions.push({ country: { code: { in: input.countryCodes } } }); - } - - const matchingResources = await db.resource.findMany({ - where: andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }, - select: { id: true }, - }); - - for (const resource of matchingResources) { - if (!resourceIds.includes(resource.id)) { - resourceIds.push(resource.id); - } - } - } - - if (resourceIds.length === 0) { - return []; - } - - const resources = await db.resource.findMany({ - where: { id: { in: resourceIds } }, - select: { - id: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { code: true, name: true } }, - metroCity: { select: { name: true } }, - }, - }); - - const overlays = await Promise.all( - resources.map(async (resource) => { - const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), { - periodStart: input.startDate, - periodEnd: input.endDate, - countryId: resource.countryId, - countryCode: resource.country?.code ?? null, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name ?? null, - }); - - return holidays.map((holiday) => { - const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`); - return { - id: `calendar-holiday:${resource.id}:${holiday.date}`, - resourceId: resource.id, - type: VacationType.PUBLIC_HOLIDAY, - status: "APPROVED" as const, - startDate: holidayDate, - endDate: holidayDate, - note: holiday.name, - scope: holiday.scope, - calendarName: holiday.calendarName, - sourceType: holiday.sourceType, - countryCode: resource.country?.code ?? null, - countryName: resource.country?.name ?? null, - federalState: resource.federalState ?? null, - metroCityName: resource.metroCity?.name ?? null, - }; - }); - }), - ); - - return overlays.flat().sort((left, right) => { - if (left.resourceId !== right.resourceId) { - return left.resourceId.localeCompare(right.resourceId); - } - return left.startDate.getTime() - right.startDate.getTime(); - }); -} - -export async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) { - const [project, planningRead] = await Promise.all([ - findUniqueOrThrow( - db.project.findUnique({ - where: { id: projectId }, - select: { - id: true, - budgetCents: true, - winProbability: true, - startDate: true, - endDate: true, - }, - }), - "Project", - ), - loadProjectPlanningReadModel(db, { projectId, activeOnly: true }), - ]); - - const { demandRequirements, assignments, readModel: projectReadModel } = planningRead; - - const resourceIds = getAssignmentResourceIds(projectReadModel); - const allAssignmentWindows = - resourceIds.length === 0 - ? [] - : ( - await listAssignmentBookings(db, { - resourceIds, - }) - ).map((booking) => ({ - id: booking.id, - resourceId: booking.resourceId!, - projectId: booking.projectId, - startDate: booking.startDate, - endDate: booking.endDate, - hoursPerDay: booking.hoursPerDay, - status: booking.status, - })); - - const shiftPlan = buildTimelineShiftPlan({ - demandRequirements, - assignments, - allAssignmentWindows, - }); - - return { - project, - demandRequirements, - assignments, - shiftPlan, - }; -} - -export async function loadTimelineProjectContext(db: ShiftDbClient, projectId: string) { - const [project, planningRead] = await Promise.all([ - findUniqueOrThrow( - db.project.findUnique({ - where: { id: projectId }, - select: { - id: true, - name: true, - shortCode: true, - orderType: true, - budgetCents: true, - winProbability: true, - status: true, - startDate: true, - endDate: true, - staffingReqs: true, - }, - }), - "Project", - ), - loadProjectPlanningReadModel(db, { - projectId, - activeOnly: true, - }), - ]); - - const resourceIds = getAssignmentResourceIds(planningRead.readModel); - const allResourceAllocations = - resourceIds.length === 0 - ? [] - : await listAssignmentBookings(db, { - resourceIds, - }); - - return { - project, - allocations: planningRead.readModel.allocations, - demands: planningRead.readModel.demands, - assignments: planningRead.readModel.assignments, - allResourceAllocations, - resourceIds, - }; -} - -export async function previewTimelineProjectShift( - db: ShiftDbClient, - input: { - projectId: string; - newStartDate: Date; - newEndDate: Date; - }, -) { - const { project, shiftPlan } = await loadProjectShiftContext(db, input.projectId); - - return validateShift({ - project: { - id: project.id, - budgetCents: project.budgetCents, - winProbability: project.winProbability, - startDate: project.startDate, - endDate: project.endDate, - }, - newStartDate: input.newStartDate, - newEndDate: input.newEndDate, - allocations: shiftPlan.validationAllocations, - }); -} - -function anonymizeResourceOnEntry( - entry: T, - directory: Awaited>, -): T { - if (!entry.resource) { - return entry; - } - return { - ...entry, - resource: anonymizeResource(entry.resource, directory), - }; -} +import { timelineEntryReadProcedures } from "./timeline-entry-read.js"; +import { timelineHolidayReadProcedures } from "./timeline-holiday-read.js"; +import { timelineProjectReadProcedures } from "./timeline-project-read.js"; export const timelineReadProcedures = { - getEntries: controllerProcedure - .input(TimelineWindowFiltersSchema) - .query(async ({ ctx, input }) => { - const readModel = await loadTimelineEntriesReadModel(ctx.db, input); - const directory = await getAnonymizationDirectory(ctx.db); - return readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)); - }), - - getEntriesView: controllerProcedure - .input(TimelineWindowFiltersSchema) - .query(async ({ ctx, input }) => { - const [readModel, directory] = await Promise.all([ - loadTimelineEntriesReadModel(ctx.db, input), - getAnonymizationDirectory(ctx.db), - ]); - - return { - ...readModel, - allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)), - assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)), - }; - }), - - getMyEntriesView: protectedProcedure - .input(TimelineWindowFiltersSchema) - .query(async ({ ctx, input }) => { - const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input); - if (!selfServiceInput) { - return createEmptyTimelineEntriesView(); - } - - const [readModel, directory] = await Promise.all([ - loadTimelineEntriesReadModel(ctx.db, selfServiceInput), - getAnonymizationDirectory(ctx.db), - ]); - - return { - ...readModel, - allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)), - assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)), - }; - }), - - getHolidayOverlays: controllerProcedure - .input(TimelineWindowFiltersSchema) - .query(async ({ ctx, input }) => loadTimelineHolidayOverlays(ctx.db, input)), - - getMyHolidayOverlays: protectedProcedure - .input(TimelineWindowFiltersSchema) - .query(async ({ ctx, input }) => { - const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input); - if (!selfServiceInput) { - return []; - } - - return loadTimelineHolidayOverlays(ctx.db, selfServiceInput); - }), - - getEntriesDetail: controllerProcedure - .input( - z.object({ - startDate: z.string().optional(), - endDate: z.string().optional(), - durationDays: z.number().int().min(1).max(366).optional(), - 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(), - }), - ) - .query(async ({ ctx, input }) => { - const { startDate, endDate } = createTimelineDateRange(input); - const filters = createTimelineFilters(input); - const timelineInput = { ...filters, startDate, endDate }; - - const [readModel, directory] = await Promise.all([ - loadTimelineEntriesReadModel(ctx.db, timelineInput), - getAnonymizationDirectory(ctx.db), - ]); - const holidayOverlays = await loadTimelineHolidayOverlaysForReadModel( - ctx.db, - timelineInput, - readModel, - ); - const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays); - - return { - period: { - startDate: fmtDate(startDate), - endDate: fmtDate(endDate), - }, - filters, - summary: { - ...summarizeTimelineEntries(readModel), - ...summarizeHolidayOverlays(formattedHolidayOverlays), - }, - allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)), - demands: readModel.demands, - assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)), - holidayOverlays: formattedHolidayOverlays, - }; - }), - - getHolidayOverlayDetail: controllerProcedure - .input( - z.object({ - startDate: z.string().optional(), - endDate: z.string().optional(), - durationDays: z.number().int().min(1).max(366).optional(), - 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(), - }), - ) - .query(async ({ ctx, input }) => { - const { startDate, endDate } = createTimelineDateRange(input); - const filters = createTimelineFilters(input); - const holidayOverlays = await loadTimelineHolidayOverlays(ctx.db, { - ...filters, - startDate, - endDate, - }); - const formattedOverlays = formatHolidayOverlays(holidayOverlays); - - return { - period: { - startDate: fmtDate(startDate), - endDate: fmtDate(endDate), - }, - filters, - summary: summarizeHolidayOverlays(formattedOverlays), - overlays: formattedOverlays, - }; - }), - - getProjectContext: controllerProcedure - .input(z.object({ projectId: z.string() })) - .query(async ({ ctx, input }) => { - const { - project, - allocations, - demands, - assignments, - allResourceAllocations, - resourceIds, - } = await loadTimelineProjectContext(ctx.db, input.projectId); - const directory = await getAnonymizationDirectory(ctx.db); - - return { - project, - allocations: allocations.map((allocation) => - anonymizeResourceOnEntry(allocation, directory), - ), - demands, - assignments: assignments.map((assignment) => - anonymizeResourceOnEntry(assignment, directory), - ), - allResourceAllocations: allResourceAllocations.map((allocation) => - anonymizeResourceOnEntry(allocation, directory), - ), - resourceIds, - }; - }), - - getProjectContextDetail: controllerProcedure - .input( - z.object({ - projectId: z.string(), - startDate: z.string().optional(), - endDate: z.string().optional(), - durationDays: z.number().int().min(1).max(366).optional(), - }), - ) - .query(async ({ ctx, input }) => { - const projectContext = await loadTimelineProjectContext(ctx.db, input.projectId); - const directory = await getAnonymizationDirectory(ctx.db); - - const derivedStartDate = input.startDate - ? createTimelineDateRange({ startDate: input.startDate, durationDays: 1 }).startDate - : projectContext.project.startDate - ?? projectContext.assignments[0]?.startDate - ?? projectContext.demands[0]?.startDate - ?? createTimelineDateRange({ durationDays: 1 }).startDate; - const derivedEndDate = input.endDate - ? createTimelineDateRange({ startDate: fmtDate(derivedStartDate) ?? undefined, endDate: input.endDate }).endDate - : projectContext.project.endDate - ?? createTimelineDateRange({ - startDate: fmtDate(derivedStartDate) ?? undefined, - durationDays: input.durationDays ?? 21, - }).endDate; - - if (derivedEndDate < derivedStartDate) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "endDate must be on or after startDate.", - }); - } - - const holidayOverlays = projectContext.resourceIds.length > 0 - ? await loadTimelineHolidayOverlays(ctx.db, { - startDate: derivedStartDate, - endDate: derivedEndDate, - resourceIds: projectContext.resourceIds, - projectIds: [input.projectId], - }) - : []; - const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays); - - const assignmentConflicts = projectContext.assignments - .filter((assignment) => assignment.resourceId && assignment.resource) - .map((assignment) => { - const overlaps = projectContext.allResourceAllocations - .filter((booking) => ( - booking.resourceId === assignment.resourceId - && booking.id !== assignment.id - && rangesOverlap( - toDate(booking.startDate), - toDate(booking.endDate), - toDate(assignment.startDate), - toDate(assignment.endDate), - ) - )) - .map((booking) => ({ - id: booking.id, - projectId: booking.projectId, - projectName: booking.project?.name ?? null, - projectShortCode: booking.project?.shortCode ?? null, - startDate: fmtDate(toDate(booking.startDate)), - endDate: fmtDate(toDate(booking.endDate)), - hoursPerDay: booking.hoursPerDay, - status: booking.status, - sameProject: booking.projectId === input.projectId, - })); - - return { - assignmentId: assignment.id, - resourceId: assignment.resourceId!, - resourceName: assignment.resource?.displayName ?? null, - startDate: fmtDate(toDate(assignment.startDate)), - endDate: fmtDate(toDate(assignment.endDate)), - hoursPerDay: assignment.hoursPerDay, - overlapCount: overlaps.length, - crossProjectOverlapCount: overlaps.filter((booking) => !booking.sameProject).length, - overlaps, - }; - }); - - return { - project: projectContext.project, - period: { - startDate: fmtDate(derivedStartDate), - endDate: fmtDate(derivedEndDate), - }, - summary: { - ...summarizeTimelineEntries({ - allocations: projectContext.allocations, - demands: projectContext.demands, - assignments: projectContext.assignments, - }), - resourceIds: projectContext.resourceIds.length, - allResourceAllocationCount: projectContext.allResourceAllocations.length, - conflictedAssignmentCount: assignmentConflicts.filter((item) => item.crossProjectOverlapCount > 0).length, - ...summarizeHolidayOverlays(formattedHolidayOverlays), - }, - allocations: projectContext.allocations.map((allocation) => - anonymizeResourceOnEntry(allocation, directory), - ), - demands: projectContext.demands, - assignments: projectContext.assignments.map((assignment) => - anonymizeResourceOnEntry(assignment, directory), - ), - allResourceAllocations: projectContext.allResourceAllocations.map((allocation) => - anonymizeResourceOnEntry(allocation, directory), - ), - assignmentConflicts, - holidayOverlays: formattedHolidayOverlays, - resourceIds: projectContext.resourceIds, - }; - }), - - previewShift: controllerProcedure - .input(ShiftProjectSchema) - .query(async ({ ctx, input }) => previewTimelineProjectShift(ctx.db, input)), - - getShiftPreviewDetail: controllerProcedure - .input(ShiftProjectSchema) - .query(async ({ ctx, input }) => { - const [project, preview] = await Promise.all([ - findUniqueOrThrow( - ctx.db.project.findUnique({ - where: { id: input.projectId }, - select: { - id: true, - name: true, - shortCode: true, - status: true, - responsiblePerson: true, - startDate: true, - endDate: true, - }, - }), - "Project", - ), - previewTimelineProjectShift(ctx.db, input), - ]); - - return { - project: { - id: project.id, - name: project.name, - shortCode: project.shortCode, - status: project.status, - responsiblePerson: project.responsiblePerson, - startDate: fmtDate(project.startDate), - endDate: fmtDate(project.endDate), - }, - requestedShift: { - newStartDate: fmtDate(input.newStartDate), - newEndDate: fmtDate(input.newEndDate), - }, - preview, - }; - }), - - getBudgetStatus: controllerProcedure - .input(z.object({ projectId: z.string() })) - .query(async ({ ctx, input }) => { - const project = await findUniqueOrThrow( - ctx.db.project.findUnique({ - where: { id: input.projectId }, - select: { - id: true, - name: true, - shortCode: true, - budgetCents: true, - winProbability: true, - startDate: true, - endDate: true, - }, - }), - "Project", - ); - - const bookings = await listAssignmentBookings(ctx.db, { - projectIds: [project.id], - }); - - const budgetStatus = computeBudgetStatus( - project.budgetCents, - project.winProbability, - bookings.map((booking) => ({ - status: booking.status, - dailyCostCents: booking.dailyCostCents, - startDate: booking.startDate, - endDate: booking.endDate, - hoursPerDay: booking.hoursPerDay, - })) as unknown as Pick< - import("@capakraken/shared").Allocation, - "status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay" - >[], - project.startDate, - project.endDate, - ); - - return { - ...budgetStatus, - projectName: project.name, - projectCode: project.shortCode, - totalAllocations: bookings.length, - budgetCents: project.budgetCents, - }; - }), + ...timelineEntryReadProcedures, + ...timelineHolidayReadProcedures, + ...timelineProjectReadProcedures, }; diff --git a/packages/api/src/router/timeline.ts b/packages/api/src/router/timeline.ts index df45427..799327f 100644 --- a/packages/api/src/router/timeline.ts +++ b/packages/api/src/router/timeline.ts @@ -6,7 +6,8 @@ import { TRPCError } from "@trpc/server"; import { emitProjectShifted } from "../sse/event-bus.js"; import { createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js"; import { buildAbsenceDays, loadCalculationRules, timelineAllocationMutationProcedures } from "./timeline-allocation-mutations.js"; -import { loadProjectShiftContext, timelineReadProcedures } from "./timeline-read.js"; +import { timelineReadProcedures } from "./timeline-read.js"; +import { loadProjectShiftContext } from "./timeline-project-read.js"; export const timelineRouter = createTRPCRouter({ ...timelineReadProcedures,