fix(api): wrap audit log writes inside their parent transactions

Prevents mutations from committing without an audit trail if the
auditLog.create call fails after the main write already succeeded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 16:40:10 +02:00
parent a01f99561d
commit 3c0179fcec
25 changed files with 758 additions and 656 deletions
@@ -100,28 +100,32 @@ export async function createEstimateRecord(
await autoFillDemandLineRates(ctx.db, input.demandLines, input.projectId);
const enrichedInput = { ...input, demandLines: enrichedLines };
const estimate = await createEstimate(
ctx.db as unknown as Parameters<typeof createEstimate>[0],
withComputedMetrics(enrichedInput, input.baseCurrency),
);
const estimate = await ctx.db.$transaction(async (tx) => {
const created = await createEstimate(
tx as unknown as Parameters<typeof createEstimate>[0],
withComputedMetrics(enrichedInput, input.baseCurrency),
);
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "CREATE",
...withAuditUser(ctx.dbUser?.id),
changes: {
after: {
id: estimate.id,
name: estimate.name,
status: estimate.status,
projectId: estimate.projectId,
latestVersionNumber: estimate.latestVersionNumber,
autoFilledRateCardLines: autoFilledIndices.length,
},
} as Prisma.InputJsonValue,
},
await tx.auditLog.create({
data: {
entityType: "Estimate",
entityId: created.id,
action: "CREATE",
...withAuditUser(ctx.dbUser?.id),
changes: {
after: {
id: created.id,
name: created.name,
status: created.status,
projectId: created.projectId,
latestVersionNumber: created.latestVersionNumber,
autoFilledRateCardLines: autoFilledIndices.length,
},
} as Prisma.InputJsonValue,
},
});
return created;
});
return estimate;
@@ -133,10 +137,30 @@ export async function cloneEstimateRecord(
) {
let estimate;
try {
estimate = await cloneEstimate(
ctx.db as unknown as Parameters<typeof cloneEstimate>[0],
input,
);
estimate = await ctx.db.$transaction(async (tx) => {
const cloned = await cloneEstimate(
tx as unknown as Parameters<typeof cloneEstimate>[0],
input,
);
await tx.auditLog.create({
data: {
entityType: "Estimate",
entityId: cloned.id,
action: "CREATE",
...withAuditUser(ctx.dbUser?.id),
changes: {
after: {
id: cloned.id,
name: cloned.name,
clonedFrom: input.sourceEstimateId,
},
} as Prisma.InputJsonValue,
},
});
return cloned;
});
} catch (error) {
rethrowEstimateRouterError(error, [
{
@@ -146,22 +170,6 @@ export async function cloneEstimateRecord(
]);
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "CREATE",
...withAuditUser(ctx.dbUser?.id),
changes: {
after: {
id: estimate.id,
name: estimate.name,
clonedFrom: input.sourceEstimateId,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}
@@ -194,10 +202,35 @@ export async function updateEstimateDraftRecord(
let estimate;
try {
estimate = await updateEstimateDraft(
ctx.db as unknown as Parameters<typeof updateEstimateDraft>[0],
withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"),
);
estimate = await ctx.db.$transaction(async (tx) => {
const updated = await updateEstimateDraft(
tx as unknown as Parameters<typeof updateEstimateDraft>[0],
withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"),
);
await tx.auditLog.create({
data: {
entityType: "Estimate",
entityId: updated.id,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
changes: {
after: {
id: updated.id,
name: updated.name,
status: updated.status,
latestVersionNumber: updated.latestVersionNumber,
workingVersionId: updated.versions.find(
(version) => version.status === "WORKING",
)?.id,
autoFilledRateCardLines: autoFilledIndices.length,
},
} as Prisma.InputJsonValue,
},
});
return updated;
});
} catch (error) {
rethrowEstimateRouterError(error, [
{
@@ -211,27 +244,6 @@ export async function updateEstimateDraftRecord(
]);
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
changes: {
after: {
id: estimate.id,
name: estimate.name,
status: estimate.status,
latestVersionNumber: estimate.latestVersionNumber,
workingVersionId: estimate.versions.find(
(version) => version.status === "WORKING",
)?.id,
autoFilledRateCardLines: autoFilledIndices.length,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}
@@ -241,10 +253,35 @@ export async function createEstimateExportRecord(
) {
let estimate;
try {
estimate = await createEstimateExport(
ctx.db as unknown as Parameters<typeof createEstimateExport>[0],
input,
);
estimate = await ctx.db.$transaction(async (tx) => {
const exported = await createEstimateExport(
tx as unknown as Parameters<typeof createEstimateExport>[0],
input,
);
const exportedVersion = input.versionId
? exported.versions.find((version) => version.id === input.versionId)
: exported.versions[0];
await tx.auditLog.create({
data: {
entityType: "Estimate",
entityId: exported.id,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
changes: {
after: {
id: exported.id,
exportFormat: input.format,
exportCount: exportedVersion?.exports.length ?? null,
versionId: exportedVersion?.id ?? null,
},
} as Prisma.InputJsonValue,
},
});
return exported;
});
} catch (error) {
rethrowEstimateRouterError(error, [
{
@@ -258,27 +295,6 @@ export async function createEstimateExportRecord(
]);
}
const exportedVersion = input.versionId
? estimate.versions.find((version) => version.id === input.versionId)
: estimate.versions[0];
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
changes: {
after: {
id: estimate.id,
exportFormat: input.format,
exportCount: exportedVersion?.exports.length ?? null,
versionId: exportedVersion?.id ?? null,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}
@@ -288,10 +304,36 @@ export async function createEstimatePlanningHandoffRecord(
) {
let result;
try {
result = await createEstimatePlanningHandoff(
ctx.db as unknown as Parameters<typeof createEstimatePlanningHandoff>[0],
input,
);
result = await ctx.db.$transaction(async (tx) => {
const handoff = await createEstimatePlanningHandoff(
tx as unknown as Parameters<typeof createEstimatePlanningHandoff>[0],
input,
);
await tx.auditLog.create({
data: {
entityType: "Estimate",
entityId: handoff.estimateId,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
changes: {
after: {
planningHandoff: {
versionId: handoff.estimateVersionId,
versionNumber: handoff.estimateVersionNumber,
projectId: handoff.projectId,
createdCount: handoff.createdCount,
assignedCount: handoff.assignedCount,
placeholderCount: handoff.placeholderCount,
fallbackPlaceholderCount: handoff.fallbackPlaceholderCount,
},
},
} as Prisma.InputJsonValue,
},
});
return handoff;
});
} catch (error) {
rethrowEstimateRouterError(error, [
{
@@ -319,28 +361,6 @@ export async function createEstimatePlanningHandoffRecord(
]);
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: result.estimateId,
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
changes: {
after: {
planningHandoff: {
versionId: result.estimateVersionId,
versionNumber: result.estimateVersionNumber,
projectId: result.projectId,
createdCount: result.createdCount,
assignedCount: result.assignedCount,
placeholderCount: result.placeholderCount,
fallbackPlaceholderCount: result.fallbackPlaceholderCount,
},
},
} as Prisma.InputJsonValue,
},
});
for (const allocation of result.allocations) {
emitAllocationCreated({
id: allocation.id,