1d6d75ecf6
- 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>
92 lines
2.6 KiB
TypeScript
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 };
|
|
}
|