refactor(api): isolate timeline allocation mutations

This commit is contained in:
2026-03-31 07:37:07 +02:00
parent 5f52a39f6b
commit 857914a38f
2 changed files with 481 additions and 489 deletions
@@ -0,0 +1,475 @@
import {
buildSplitAllocationReadModel,
createAssignment,
findAllocationEntry,
loadAllocationEntry,
updateAllocationEntry,
} from "@capakraken/application";
import { Prisma, VacationType } from "@capakraken/db";
import type { PrismaClient } from "@capakraken/db";
import { calculateAllocation, DEFAULT_CALCULATION_RULES } from "@capakraken/engine";
import type { AbsenceDay, CalculationRule } from "@capakraken/shared";
import { AllocationStatus, PermissionKey, UpdateAllocationHoursSchema } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
emitAllocationCreated,
emitAllocationUpdated,
} from "../sse/event-bus.js";
import { logger } from "../lib/logger.js";
import { managerProcedure, requirePermission } from "../trpc.js";
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));
}
export 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;
}
export 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 vacation of vacations) {
const cur = new Date(vacation.startDate);
cur.setHours(0, 0, 0, 0);
const vacationEnd = new Date(vacation.endDate);
vacationEnd.setHours(0, 0, 0, 0);
const triggerType = vacation.type === VacationType.SICK
? "SICK" as const
: vacation.type === VacationType.PUBLIC_HOLIDAY
? "PUBLIC_HOLIDAY" as const
: "VACATION" as const;
while (cur <= vacationEnd) {
absenceDays.push({
date: new Date(cur),
type: triggerType,
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
});
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 timelineAllocationMutationProcedures = {
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",
});
}
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;
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;
const recurrence = newMeta.recurrence as import("@capakraken/shared").RecurrencePattern | undefined;
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;
}),
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;
}),
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);
for (const assignment of input.assignments) {
if (assignment.endDate < assignment.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 assignment of input.assignments) {
const percentage = Math.min(
100,
Math.round((assignment.hoursPerDay / 8) * 100),
);
const metadata = {
source: "batchQuickAssign",
} satisfies Record<string, unknown>;
const createdAssignment = await createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
{
resourceId: assignment.resourceId,
projectId: assignment.projectId,
startDate: assignment.startDate,
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
percentage,
role: assignment.role,
status: assignment.status,
metadata,
},
);
created.push(createdAssignment);
}
return created;
});
for (const assignment of results) {
emitAllocationCreated({
id: assignment.id,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
});
}
return { count: results.length };
}),
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 };
}
const entries = await Promise.all(
input.allocationIds.map((allocationId) => findAllocationEntry(ctx.db, allocationId)),
);
const resolved = entries.filter(
(entry): entry is NonNullable<typeof entry> => entry !== 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);
if (newStart > newEnd) {
newStart.setTime(newEnd.getTime());
}
} else {
newEnd.setDate(newEnd.getDate() + input.daysDelta);
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;
});
for (const allocation of results) {
emitAllocationUpdated({
id: allocation.id,
projectId: allocation.projectId,
resourceId: allocation.resourceId,
});
}
return { count: results.length };
}),
};