3c0179fcec
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>
412 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|