Files
CapaKraken/packages/api/src/router/timeline.ts
T

1609 lines
52 KiB
TypeScript

import {
buildSplitAllocationReadModel,
createAssignment,
findAllocationEntry,
loadAllocationEntry,
listAssignmentBookings,
updateAssignment,
updateDemandRequirement,
updateAllocationEntry,
} from "@capakraken/application";
import { Prisma, VacationType } from "@capakraken/db";
import type { PrismaClient } from "@capakraken/db";
import { calculateAllocation, computeBudgetStatus, validateShift, DEFAULT_CALCULATION_RULES } from "@capakraken/engine";
import type { CalculationRule, AbsenceDay } from "@capakraken/shared";
import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import {
loadProjectPlanningReadModel,
TIMELINE_ASSIGNMENT_INCLUDE,
PROJECT_PLANNING_DEMAND_INCLUDE,
} from "./project-planning-read-model.js";
import {
emitAllocationCreated,
emitAllocationUpdated,
emitProjectShifted,
} from "../sse/event-bus.js";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
import { controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
import { logger } from "../lib/logger.js";
import type { TRPCContext } from "../trpc.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;
};
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),
),
];
}
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<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),
};
}
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;
}
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),
};
}
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,
};
}
function formatHolidayOverlays(
overlays: Array<{
id: string;
resourceId: string;
startDate: Date;
endDate: Date;
note?: string | null;
scope?: string | null;
calendarName?: string | null;
sourceType?: 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,
}));
}
function summarizeHolidayOverlays(
overlays: ReturnType<typeof formatHolidayOverlays>,
) {
const resourceIds = new Set<string>();
const byScope = new Map<string, number>();
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;
// When resource-level filters are active (resourceIds, chapters, eids, or countryCodes),
// resolve matching resource IDs so we can push the filter to the DB query.
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({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
where: (andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }) as any,
select: { id: true },
});
return matching.map((r) => r.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));
})();
// When filtering by resource (either explicit resourceIds or derived from chapters),
// demands without a resource are excluded.
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<typeof buildSplitAllocationReadModel>,
) {
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: Record<string, unknown>[] = [];
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({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
where: (andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }) as any,
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 } },
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,
};
});
}),
);
return overlays.flat().sort((left, right) => {
if (left.resourceId !== right.resourceId) {
return left.resourceId.localeCompare(right.resourceId);
}
return left.startDate.getTime() - right.startDate.getTime();
});
}
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<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),
};
}
/** Load active calculation rules from DB, falling back to defaults if none configured. */
function isMissingOptionalTableError(error: unknown, tableHints: string[]): boolean {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code !== "P2021") {
return false;
}
const table = typeof error.meta?.table === "string" ? error.meta.table.toLowerCase() : "";
const message = error.message.toLowerCase();
return tableHints.some((hint) => table.includes(hint) || message.includes(hint));
}
if (typeof error !== "object" || error === null || !("code" in error)) {
return false;
}
const candidate = error as {
code?: unknown;
message?: unknown;
meta?: { table?: unknown };
};
const code = typeof candidate.code === "string" ? candidate.code : "";
if (code !== "P2021") {
return false;
}
const table = typeof candidate.meta?.table === "string" ? candidate.meta.table.toLowerCase() : "";
const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : "";
return tableHints.some((hint) => table.includes(hint) || message.includes(hint));
}
async function loadCalculationRules(db: PrismaClient): Promise<CalculationRule[]> {
const calculationRuleModel = (db as PrismaClient & {
calculationRule?: { findMany?: (args: unknown) => Promise<unknown[]> };
}).calculationRule;
if (!calculationRuleModel || typeof calculationRuleModel.findMany !== "function") {
return DEFAULT_CALCULATION_RULES;
}
try {
const rules = await calculationRuleModel.findMany({
where: { isActive: true },
orderBy: [{ priority: "desc" }],
});
if (rules.length > 0) {
return rules as unknown as CalculationRule[];
}
} catch (error) {
if (!isMissingOptionalTableError(error, ["calculationrule", "calculation_rule", "calculation_rules"])) {
logger.error({ err: error }, "Failed to load active calculation rules for timeline");
throw error;
}
}
return DEFAULT_CALCULATION_RULES;
}
/** Build typed absence days from vacations for a resource in a date range. */
async function buildAbsenceDays(
db: PrismaClient,
resourceId: string,
startDate: Date,
endDate: Date,
): Promise<{ absenceDays: AbsenceDay[]; legacyVacationDates: Date[] }> {
const absenceDays: AbsenceDay[] = [];
const legacyVacationDates: Date[] = [];
try {
const vacations = await db.vacation.findMany({
where: {
resourceId,
status: "APPROVED",
startDate: { lte: endDate },
endDate: { gte: startDate },
},
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
});
for (const v of vacations) {
const cur = new Date(v.startDate);
cur.setHours(0, 0, 0, 0);
const vEnd = new Date(v.endDate);
vEnd.setHours(0, 0, 0, 0);
// Map Prisma VacationType to AbsenceTrigger
const triggerType = v.type === VacationType.SICK ? "SICK" as const
: v.type === VacationType.PUBLIC_HOLIDAY ? "PUBLIC_HOLIDAY" as const
: "VACATION" as const;
while (cur <= vEnd) {
absenceDays.push({
date: new Date(cur),
type: triggerType,
...(v.isHalfDay ? { isHalfDay: true } : {}),
});
// Also populate legacy vacation dates for backward compat
if (triggerType === "VACATION") {
legacyVacationDates.push(new Date(cur));
}
cur.setDate(cur.getDate() + 1);
}
}
} catch (error) {
if (!isMissingOptionalTableError(error, ["vacation", "vacations"])) {
logger.error(
{ err: error, resourceId, startDate, endDate },
"Failed to load timeline absence days",
);
throw error;
}
}
return { absenceDays, legacyVacationDates };
}
export const timelineRouter = createTRPCRouter({
/**
* Get all timeline entries (projects + allocations) for a date range.
* Includes project startDate, endDate, staffingReqs for demand overlay.
*/
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,
};
}),
/**
* Get full project context for a project:
* - project with staffingReqs and budget
* - all active planning entries on this project
* - all assignment bookings for the same resources (for cross-project overlap display)
* Used when: drag starts or project panel opens.
*/
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,
};
}),
/**
* Inline update of an allocation's hours, dates, includeSaturday, or role.
* Recalculates dailyCostCents and emits SSE.
*/
updateAllocationInline: managerProcedure
.input(UpdateAllocationHoursSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const resolved = await loadAllocationEntry(ctx.db, input.allocationId);
const existing = resolved.entry;
const existingResource = resolved.resourceId
? await ctx.db.resource.findUnique({
where: { id: resolved.resourceId },
select: { id: true, lcrCents: true, availability: true },
})
: null;
const newHoursPerDay = input.hoursPerDay ?? existing.hoursPerDay;
const newStartDate = input.startDate ?? existing.startDate;
const newEndDate = input.endDate ?? existing.endDate;
if (newEndDate < newStartDate) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "End date must be after start date",
});
}
// Merge includeSaturday into metadata
const existingMeta = (existing.metadata as Record<string, unknown>) ?? {};
const newMeta: Record<string, unknown> = {
...existingMeta,
...(input.includeSaturday !== undefined
? { includeSaturday: input.includeSaturday }
: {}),
};
const includeSaturday =
input.includeSaturday ?? (existingMeta.includeSaturday as boolean | undefined) ?? false;
// For placeholder allocations (no resource), dailyCostCents stays 0
let newDailyCostCents = 0;
if (resolved.resourceId) {
if (!existingResource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
const availability =
existingResource.availability as unknown as import("@capakraken/shared").WeekdayAvailability;
// Load recurrence from merged metadata
const recurrence = (newMeta.recurrence as import("@capakraken/shared").RecurrencePattern | undefined);
// Load typed absences + calculation rules for rules-aware cost computation
const [absenceData, calculationRules] = await Promise.all([
buildAbsenceDays(ctx.db as PrismaClient, resolved.resourceId, newStartDate, newEndDate),
loadCalculationRules(ctx.db as PrismaClient),
]);
newDailyCostCents = calculateAllocation({
lcrCents: existingResource.lcrCents,
hoursPerDay: newHoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
availability,
includeSaturday,
...(recurrence ? { recurrence } : {}),
vacationDates: absenceData.legacyVacationDates,
absenceDays: absenceData.absenceDays,
calculationRules,
}).dailyCostCents;
}
const updated = await ctx.db.$transaction(async (tx) => {
const { allocation: updatedAllocation } = await updateAllocationEntry(
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
{
id: input.allocationId,
demandRequirementUpdate: {
hoursPerDay: newHoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
metadata: newMeta,
...(input.role !== undefined ? { role: input.role } : {}),
},
assignmentUpdate: {
hoursPerDay: newHoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
dailyCostCents: newDailyCostCents,
metadata: newMeta,
...(input.role !== undefined ? { role: input.role } : {}),
},
},
);
await tx.auditLog.create({
data: {
entityType: "Allocation",
entityId: input.allocationId,
action: "UPDATE",
changes: {
before: {
id: resolved.entry.id,
hoursPerDay: existing.hoursPerDay,
startDate: existing.startDate,
endDate: existing.endDate,
},
after: {
id: updatedAllocation.id,
hoursPerDay: newHoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
includeSaturday,
},
},
},
});
return updatedAllocation;
});
emitAllocationUpdated({
id: updated.id,
projectId: updated.projectId,
resourceId: updated.resourceId,
});
return updated;
}),
/**
* Preview a project shift — validate without committing.
* Returns cost impact, conflicts, warnings.
*/
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,
};
}),
/**
* Apply a project shift — validate, then commit all allocation date changes.
* Reads includeSaturday from each allocation's metadata.
*/
applyShift: managerProcedure
.input(ShiftProjectSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const { projectId, newStartDate, newEndDate } = input;
const { project, demandRequirements, assignments, shiftPlan } = await loadProjectShiftContext(
ctx.db,
projectId,
);
// Re-validate before committing
const validation = validateShift({
project: {
id: project.id,
budgetCents: project.budgetCents,
winProbability: project.winProbability,
startDate: project.startDate,
endDate: project.endDate,
},
newStartDate,
newEndDate,
allocations: shiftPlan.validationAllocations,
});
if (!validation.valid) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Shift validation failed: ${validation.errors.map((e) => e.message).join(", ")}`,
});
}
// Pre-load calculation rules for cost recalculation
const shiftRules = await loadCalculationRules(ctx.db as PrismaClient);
// Apply shift in a transaction
const updatedProject = await ctx.db.$transaction(async (tx) => {
// Update project dates
const proj = await tx.project.update({
where: { id: projectId },
data: { startDate: newStartDate, endDate: newEndDate },
});
for (const demandRequirement of demandRequirements) {
await updateDemandRequirement(
tx as unknown as Parameters<typeof updateDemandRequirement>[0],
demandRequirement.id,
{
startDate: newStartDate,
endDate: newEndDate,
},
);
}
for (const assignment of assignments) {
const metadata = (assignment.metadata as Record<string, unknown> | null | undefined) ?? {};
const includeSaturday = (metadata.includeSaturday as boolean | undefined) ?? false;
const shiftAbsenceData = await buildAbsenceDays(
ctx.db as PrismaClient,
assignment.resourceId!,
newStartDate,
newEndDate,
);
const newDailyCost = calculateAllocation({
lcrCents: assignment.resource!.lcrCents,
hoursPerDay: assignment.hoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
availability:
assignment.resource!.availability as unknown as import("@capakraken/shared").WeekdayAvailability,
includeSaturday,
vacationDates: shiftAbsenceData.legacyVacationDates,
absenceDays: shiftAbsenceData.absenceDays,
calculationRules: shiftRules,
}).dailyCostCents;
await updateAssignment(
tx as unknown as Parameters<typeof updateAssignment>[0],
assignment.id,
{
startDate: newStartDate,
endDate: newEndDate,
dailyCostCents: newDailyCost,
},
);
}
// Write audit log
await tx.auditLog.create({
data: {
entityType: "Project",
entityId: projectId,
action: "SHIFT",
changes: {
before: { startDate: project.startDate, endDate: project.endDate },
after: { startDate: newStartDate, endDate: newEndDate },
costImpact: validation.costImpact,
} as unknown as import("@capakraken/db").Prisma.InputJsonValue,
},
});
return proj;
});
// Emit SSE event for live updates
emitProjectShifted({
projectId,
newStartDate: newStartDate.toISOString(),
newEndDate: newEndDate.toISOString(),
costDeltaCents: validation.costImpact.deltaCents,
resourceIds: assignments.map((assignment) => assignment.resourceId),
});
return { project: updatedProject, validation };
}),
/**
* Quick-assign a resource to a project for a date range.
* Overbooking is intentionally allowed — no availability throw.
* For use from the timeline drag-to-assign UI.
*/
quickAssign: managerProcedure
.input(
z.object({
resourceId: z.string(),
projectId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0.5).max(24).default(8),
role: z.string().min(1).max(200).default("Team Member"),
roleId: z.string().optional(),
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
if (input.endDate < input.startDate) {
throw new TRPCError({ code: "BAD_REQUEST", message: "End date must be after start date" });
}
const percentage = Math.min(100, Math.round((input.hoursPerDay / 8) * 100));
const metadata = { source: "quickAssign" } satisfies Record<string, unknown>;
const allocation = await ctx.db.$transaction(async (tx) => {
const assignment = await createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
{
resourceId: input.resourceId,
projectId: input.projectId,
startDate: input.startDate,
endDate: input.endDate,
hoursPerDay: input.hoursPerDay,
percentage,
role: input.role,
roleId: input.roleId ?? undefined,
status: input.status,
metadata,
},
);
return buildSplitAllocationReadModel({
demandRequirements: [],
assignments: [assignment],
}).allocations[0]!;
});
emitAllocationCreated({
id: allocation.id,
projectId: allocation.projectId,
resourceId: allocation.resourceId,
});
return allocation;
}),
/**
* Batch quick-assign multiple resources to a project for a date range.
* Used by the multi-selection floating action bar.
*/
batchQuickAssign: managerProcedure
.input(
z.object({
assignments: z
.array(
z.object({
resourceId: z.string(),
projectId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0.5).max(24).default(8),
role: z.string().min(1).max(200).default("Team Member"),
status: z
.nativeEnum(AllocationStatus)
.default(AllocationStatus.PROPOSED),
}),
)
.min(1)
.max(50),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
// Validate all date ranges
for (const a of input.assignments) {
if (a.endDate < a.startDate) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "End date must be after start date",
});
}
}
const results = await ctx.db.$transaction(async (tx) => {
const created = [];
for (const a of input.assignments) {
const percentage = Math.min(
100,
Math.round((a.hoursPerDay / 8) * 100),
);
const metadata = {
source: "batchQuickAssign",
} satisfies Record<string, unknown>;
const assignment = await createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
{
resourceId: a.resourceId,
projectId: a.projectId,
startDate: a.startDate,
endDate: a.endDate,
hoursPerDay: a.hoursPerDay,
percentage,
role: a.role,
status: a.status,
metadata,
},
);
created.push(assignment);
}
return created;
});
// Fire SSE events
for (const assignment of results) {
emitAllocationCreated({
id: assignment.id,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
});
}
return { count: results.length };
}),
/**
* Batch-shift multiple allocations by the same number of days.
* Used by multi-select drag on the timeline.
*/
batchShiftAllocations: managerProcedure
.input(
z.object({
allocationIds: z.array(z.string()).min(1).max(100),
daysDelta: z.number().int().min(-3650).max(3650),
mode: z.enum(["move", "resize-start", "resize-end"]).default("move"),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
if (input.daysDelta === 0) return { count: 0 };
// Load all allocations
const entries = await Promise.all(
input.allocationIds.map((id) => findAllocationEntry(ctx.db, id)),
);
const resolved = entries.filter(
(e): e is NonNullable<typeof e> => e !== null,
);
if (resolved.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "No allocations found" });
}
const results = await ctx.db.$transaction(async (tx) => {
const updated = [];
for (const entry of resolved) {
const existing = entry.entry;
const newStart = new Date(existing.startDate);
const newEnd = new Date(existing.endDate);
if (input.mode === "move") {
newStart.setDate(newStart.getDate() + input.daysDelta);
newEnd.setDate(newEnd.getDate() + input.daysDelta);
} else if (input.mode === "resize-start") {
newStart.setDate(newStart.getDate() + input.daysDelta);
// Clamp: start must not exceed end
if (newStart > newEnd) newStart.setTime(newEnd.getTime());
} else {
// resize-end
newEnd.setDate(newEnd.getDate() + input.daysDelta);
// Clamp: end must not precede start
if (newEnd < newStart) newEnd.setTime(newStart.getTime());
}
const result = await updateAllocationEntry(
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
{
id: existing.id,
demandRequirementUpdate: {
startDate: newStart,
endDate: newEnd,
},
assignmentUpdate: {
startDate: newStart,
endDate: newEnd,
},
},
);
updated.push(result.allocation);
}
await tx.auditLog.create({
data: {
entityType: "Allocation",
entityId: input.allocationIds.join(","),
action: "UPDATE",
changes: {
operation: "batchShift",
mode: input.mode,
daysDelta: input.daysDelta,
count: resolved.length,
} as unknown as import("@capakraken/db").Prisma.InputJsonValue,
},
});
return updated;
});
// Fire SSE events
for (const alloc of results) {
emitAllocationUpdated({
id: alloc.id,
projectId: alloc.projectId,
resourceId: alloc.resourceId,
});
}
return { count: results.length };
}),
/**
* Get budget status for a project.
*/
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",
);
// Use wide date range to catch all assignments (including those extending beyond project dates)
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,
};
}),
});