Files
CapaKraken/packages/api/src/router/scenario-apply.ts
T
Hartmut 1d6d75ecf6 fix(api): wrap critical mutations in transactions and fix TOCTOU race conditions
- applyProjectScenario: wrap assignment loop in db.$transaction to prevent partial updates
- vacation approve/reject: fix TOCTOU race via updateMany with status-guard in WHERE + CONFLICT on count=0
- vacation cancel: wrap vacation.update + entitlement.updateMany in $transaction
- batchApprove: collect mutations, wrap in $transaction, dispatch SSE/notifications after commit
- Fix dead-code bug in createHappyPathDb where $transaction was assigned after return
- Add atomicity and concurrency tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 08:34:59 +02:00

92 lines
2.6 KiB
TypeScript

import { TRPCError } from "@trpc/server";
import { createAuditEntry } from "../lib/audit.js";
import type { ScenarioChangeInput, ScenarioDb } from "./scenario-shared.js";
export async function applyProjectScenario(
db: ScenarioDb,
input: {
projectId: string;
changes: ScenarioChangeInput[];
userId?: string;
},
) {
const { projectId, changes, userId } = input;
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, name: true },
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const created: string[] = [];
await db.$transaction(async (tx) => {
for (const change of changes) {
if (change.remove && change.assignmentId) {
await tx.assignment.update({
where: { id: change.assignmentId },
data: { status: "CANCELLED" },
});
continue;
}
if (change.assignmentId) {
await tx.assignment.update({
where: { id: change.assignmentId },
data: {
startDate: change.startDate,
endDate: change.endDate,
hoursPerDay: change.hoursPerDay,
...(change.resourceId ? { resourceId: change.resourceId } : {}),
...(change.roleId ? { roleId: change.roleId } : {}),
},
});
created.push(change.assignmentId);
continue;
}
if (!change.resourceId) {
continue;
}
const resource = await tx.resource.findUnique({
where: { id: change.resourceId },
select: { lcrCents: true },
});
const dailyCostCents = Math.round((resource?.lcrCents ?? 0) * change.hoursPerDay);
const newAssignment = await tx.assignment.create({
data: {
projectId,
resourceId: change.resourceId,
...(change.roleId ? { roleId: change.roleId } : {}),
startDate: change.startDate,
endDate: change.endDate,
hoursPerDay: change.hoursPerDay,
percentage: 100,
dailyCostCents,
status: "PROPOSED",
metadata: {},
},
});
created.push(newAssignment.id);
}
});
void createAuditEntry({
db,
entityType: "ScenarioApplication",
entityId: projectId,
entityName: project.name,
action: "CREATE",
...(userId ? { userId } : {}),
summary: `Applied scenario to project "${project.name}" (${created.length} allocations created/modified)`,
metadata: { appliedCount: created.length, assignmentIds: created },
source: "ui",
});
return { appliedCount: created.length };
}