Files
CapaKraken/packages/api/src/router/estimate-procedure-support.ts
T
Hartmut 3c0179fcec 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>
2026-04-09 16:40:10 +02:00

412 lines
11 KiB
TypeScript

import {
cloneEstimate,
createEstimate,
createEstimateExport,
createEstimatePlanningHandoff,
updateEstimateDraft,
} from "@capakraken/application";
import type { Prisma } from "@capakraken/db";
import {
CloneEstimateSchema,
CreateEstimateExportSchema,
CreateEstimatePlanningHandoffSchema,
CreateEstimateSchema,
UpdateEstimateDraftSchema,
} from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { lookupRate } from "../lib/rate-card-lookup.js";
import { emitAllocationCreated } from "../sse/event-bus.js";
import type { TRPCContext } from "../trpc.js";
import {
autoFillDemandLineRates,
withComputedMetrics,
} from "./estimate-demand-lines.js";
type EstimateProcedureContext = Pick<TRPCContext, "db" | "dbUser">;
type EstimateRouterErrorCode = "NOT_FOUND" | "PRECONDITION_FAILED";
type EstimateRouterErrorRule = {
code: EstimateRouterErrorCode;
messages?: readonly string[];
predicates?: readonly ((message: string) => boolean)[];
};
function withAuditUser(userId: string | undefined) {
return userId ? { userId } : {};
}
export function rethrowEstimateRouterError(
error: unknown,
rules: readonly EstimateRouterErrorRule[],
): never {
if (!(error instanceof Error)) {
throw error;
}
const matchingRule = rules.find(
(rule) =>
rule.messages?.includes(error.message) === true ||
rule.predicates?.some((predicate) => predicate(error.message)) === true,
);
if (matchingRule) {
throw new TRPCError({
code: matchingRule.code,
message: error.message,
});
}
throw error;
}
export const lookupDemandLineRateInputSchema = z.object({
projectId: z.string().optional(),
clientId: z.string().optional(),
roleId: z.string().optional(),
chapter: z.string().optional(),
seniority: z.string().optional(),
location: z.string().optional(),
workType: z.string().optional(),
effectiveDate: z.coerce.date().optional(),
});
type CreateEstimateInput = z.infer<typeof CreateEstimateSchema>;
type CloneEstimateInput = z.infer<typeof CloneEstimateSchema>;
type UpdateEstimateDraftInput = z.infer<typeof UpdateEstimateDraftSchema>;
type CreateEstimateExportInput = z.infer<typeof CreateEstimateExportSchema>;
type CreateEstimatePlanningHandoffInput = z.infer<
typeof CreateEstimatePlanningHandoffSchema
>;
type LookupDemandLineRateInput = z.infer<typeof lookupDemandLineRateInputSchema>;
export async function createEstimateRecord(
ctx: EstimateProcedureContext,
input: CreateEstimateInput,
) {
if (input.projectId) {
await findUniqueOrThrow(
ctx.db.project.findUnique({
where: { id: input.projectId },
select: { id: true },
}),
"Project",
);
}
const { demandLines: enrichedLines, autoFilledIndices } =
await autoFillDemandLineRates(ctx.db, input.demandLines, input.projectId);
const enrichedInput = { ...input, demandLines: enrichedLines };
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 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;
}
export async function cloneEstimateRecord(
ctx: EstimateProcedureContext,
input: CloneEstimateInput,
) {
let estimate;
try {
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, [
{
code: "NOT_FOUND",
messages: ["Source estimate not found", "Source estimate has no versions"],
},
]);
}
return estimate;
}
export async function updateEstimateDraftRecord(
ctx: EstimateProcedureContext,
input: UpdateEstimateDraftInput,
) {
if (input.projectId) {
await findUniqueOrThrow(
ctx.db.project.findUnique({
where: { id: input.projectId },
select: { id: true },
}),
"Project",
);
}
let effectiveProjectId = input.projectId;
if (!effectiveProjectId) {
const existing = await ctx.db.estimate.findUnique({
where: { id: input.id },
select: { projectId: true },
});
effectiveProjectId = existing?.projectId ?? undefined;
}
const { demandLines: enrichedLines, autoFilledIndices } =
await autoFillDemandLineRates(ctx.db, input.demandLines, effectiveProjectId);
const enrichedInput = { ...input, demandLines: enrichedLines };
let estimate;
try {
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, [
{
code: "NOT_FOUND",
messages: ["Estimate not found"],
},
{
code: "PRECONDITION_FAILED",
messages: ["Estimate has no working version"],
},
]);
}
return estimate;
}
export async function createEstimateExportRecord(
ctx: EstimateProcedureContext,
input: CreateEstimateExportInput,
) {
let estimate;
try {
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, [
{
code: "NOT_FOUND",
messages: [
"Estimate not found",
"Estimate version not found",
"Estimate has no version to export",
],
},
]);
}
return estimate;
}
export async function createEstimatePlanningHandoffRecord(
ctx: EstimateProcedureContext,
input: CreateEstimatePlanningHandoffInput,
) {
let result;
try {
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, [
{
code: "NOT_FOUND",
messages: [
"Estimate not found",
"Estimate version not found",
"Linked project not found",
],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate has no approved version",
"Only approved versions can be handed off to planning",
"Estimate must be linked to a project before planning handoff",
"Planning handoff already exists for this approved version",
"Linked project has an invalid date range",
],
predicates: [
(message) =>
message.startsWith("Project window has no working days for demand line"),
],
},
]);
}
for (const allocation of result.allocations) {
emitAllocationCreated({
id: allocation.id,
projectId: allocation.projectId,
resourceId: allocation.resourceId ?? null,
});
}
return result;
}
export async function lookupDemandLineRateForEstimate(
ctx: EstimateProcedureContext,
input: LookupDemandLineRateInput,
) {
let clientId = input.clientId ?? null;
if (!clientId && input.projectId) {
const project = await ctx.db.project.findUnique({
where: { id: input.projectId },
select: { clientId: true },
});
clientId = project?.clientId ?? null;
}
const result = await lookupRate(ctx.db, {
clientId,
chapter: input.chapter ?? null,
roleId: input.roleId ?? null,
seniority: input.seniority ?? null,
location: input.location ?? null,
workType: input.workType ?? null,
effectiveDate: input.effectiveDate ?? null,
});
if (!result) {
return { found: false as const };
}
return {
found: true as const,
costRateCents: result.costRateCents,
billRateCents: result.billRateCents,
currency: result.currency,
rateCardId: result.rateCardId,
rateCardLineId: result.rateCardLineId,
rateCardName: result.rateCardName,
};
}