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>
This commit is contained in:
2026-04-09 08:34:59 +02:00
parent b103e79e92
commit 1d6d75ecf6
6 changed files with 245 additions and 129 deletions
+44 -42
View File
@@ -22,56 +22,58 @@ export async function applyProjectScenario(
const created: string[] = [];
for (const change of changes) {
if (change.remove && change.assignmentId) {
await db.assignment.update({
where: { id: change.assignmentId },
data: { status: "CANCELLED" },
});
continue;
}
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 db.assignment.update({
where: { id: change.assignmentId },
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,
...(change.resourceId ? { resourceId: change.resourceId } : {}),
...(change.roleId ? { roleId: change.roleId } : {}),
percentage: 100,
dailyCostCents,
status: "PROPOSED",
metadata: {},
},
});
created.push(change.assignmentId);
continue;
created.push(newAssignment.id);
}
if (!change.resourceId) {
continue;
}
const resource = await db.resource.findUnique({
where: { id: change.resourceId },
select: { lcrCents: true },
});
const dailyCostCents = Math.round((resource?.lcrCents ?? 0) * change.hoursPerDay);
const newAssignment = await db.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,