refactor(api): extract estimate version workflow

This commit is contained in:
2026-03-31 09:29:53 +02:00
parent 75d61a5ef8
commit dfbe46bddb
2 changed files with 196 additions and 152 deletions
@@ -0,0 +1,194 @@
import {
approveEstimateVersion,
createEstimateRevision,
submitEstimateVersion,
} from "@capakraken/application";
import type { Prisma } from "@capakraken/db";
import {
ApproveEstimateVersionSchema,
CreateEstimateRevisionSchema,
PermissionKey,
SubmitEstimateVersionSchema,
} from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { managerProcedure, requirePermission } from "../trpc.js";
type EstimateRouterErrorCode = "NOT_FOUND" | "PRECONDITION_FAILED";
type EstimateRouterErrorRule = {
code: EstimateRouterErrorCode;
messages?: readonly string[];
predicates?: readonly ((message: string) => boolean)[];
};
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 estimateVersionWorkflowProcedures = {
submitVersion: managerProcedure
.input(SubmitEstimateVersionSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await submitEstimateVersion(
ctx.db as unknown as Parameters<typeof submitEstimateVersion>[0],
input,
);
} catch (error) {
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found", "Estimate version not found"],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate has no working version",
"Only working versions can be submitted",
],
},
]);
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
status: estimate.status,
submittedVersionId: estimate.versions.find(
(version) => version.status === "SUBMITTED",
)?.id,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
approveVersion: managerProcedure
.input(ApproveEstimateVersionSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await approveEstimateVersion(
ctx.db as unknown as Parameters<typeof approveEstimateVersion>[0],
input,
);
} catch (error) {
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found", "Estimate version not found"],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate has no submitted version",
"Only submitted versions can be approved",
],
},
]);
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
status: estimate.status,
approvedVersionId: estimate.versions.find(
(version) => version.status === "APPROVED",
)?.id,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
createRevision: managerProcedure
.input(CreateEstimateRevisionSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await createEstimateRevision(
ctx.db as unknown as Parameters<typeof createEstimateRevision>[0],
input,
);
} catch (error) {
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found", "Estimate version not found"],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate already has a working version",
"Estimate has no locked version to revise",
"Source version must be locked before creating a revision",
],
},
]);
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
status: estimate.status,
latestVersionNumber: estimate.latestVersionNumber,
workingVersionId: estimate.versions.find(
(version) => version.status === "WORKING",
)?.id,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
};
+2 -152
View File
@@ -1,12 +1,9 @@
import { import {
approveEstimateVersion,
cloneEstimate, cloneEstimate,
createEstimateExport, createEstimateExport,
createEstimate, createEstimate,
createEstimatePlanningHandoff, createEstimatePlanningHandoff,
createEstimateRevision,
getEstimateById, getEstimateById,
submitEstimateVersion,
updateEstimateDraft, updateEstimateDraft,
} from "@capakraken/application"; } from "@capakraken/application";
import type { Prisma } from "@capakraken/db"; import type { Prisma } from "@capakraken/db";
@@ -19,15 +16,12 @@ import {
aggregateWeeklyByChapter, aggregateWeeklyByChapter,
} from "@capakraken/engine"; } from "@capakraken/engine";
import { import {
ApproveEstimateVersionSchema,
CloneEstimateSchema, CloneEstimateSchema,
CreateEstimateExportSchema, CreateEstimateExportSchema,
CreateEstimatePlanningHandoffSchema, CreateEstimatePlanningHandoffSchema,
CreateEstimateSchema, CreateEstimateSchema,
CreateEstimateRevisionSchema,
GenerateWeeklyPhasingSchema, GenerateWeeklyPhasingSchema,
PermissionKey, PermissionKey,
SubmitEstimateVersionSchema,
UpdateEstimateDraftSchema, UpdateEstimateDraftSchema,
} from "@capakraken/shared"; } from "@capakraken/shared";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@@ -43,6 +37,7 @@ import {
import { emitAllocationCreated } from "../sse/event-bus.js"; import { emitAllocationCreated } from "../sse/event-bus.js";
import { estimateCommercialProcedures } from "./estimate-commercial.js"; import { estimateCommercialProcedures } from "./estimate-commercial.js";
import { estimateReadProcedures } from "./estimate-read.js"; import { estimateReadProcedures } from "./estimate-read.js";
import { estimateVersionWorkflowProcedures } from "./estimate-version-workflow.js";
type EstimateRouterErrorCode = "NOT_FOUND" | "PRECONDITION_FAILED"; type EstimateRouterErrorCode = "NOT_FOUND" | "PRECONDITION_FAILED";
@@ -244,6 +239,7 @@ async function autoFillDemandLineRates(
export const estimateRouter = createTRPCRouter({ export const estimateRouter = createTRPCRouter({
...estimateReadProcedures, ...estimateReadProcedures,
...estimateCommercialProcedures, ...estimateCommercialProcedures,
...estimateVersionWorkflowProcedures,
create: managerProcedure create: managerProcedure
.input(CreateEstimateSchema) .input(CreateEstimateSchema)
@@ -403,152 +399,6 @@ export const estimateRouter = createTRPCRouter({
return estimate; return estimate;
}), }),
submitVersion: managerProcedure
.input(SubmitEstimateVersionSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await submitEstimateVersion(
ctx.db as unknown as Parameters<typeof submitEstimateVersion>[0],
input,
);
} catch (error) {
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found", "Estimate version not found"],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate has no working version",
"Only working versions can be submitted",
],
},
]);
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
status: estimate.status,
submittedVersionId: estimate.versions.find(
(version) => version.status === "SUBMITTED",
)?.id,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
approveVersion: managerProcedure
.input(ApproveEstimateVersionSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await approveEstimateVersion(
ctx.db as unknown as Parameters<typeof approveEstimateVersion>[0],
input,
);
} catch (error) {
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found", "Estimate version not found"],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate has no submitted version",
"Only submitted versions can be approved",
],
},
]);
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
status: estimate.status,
approvedVersionId: estimate.versions.find(
(version) => version.status === "APPROVED",
)?.id,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
createRevision: managerProcedure
.input(CreateEstimateRevisionSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await createEstimateRevision(
ctx.db as unknown as Parameters<typeof createEstimateRevision>[0],
input,
);
} catch (error) {
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found", "Estimate version not found"],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate already has a working version",
"Estimate has no locked version to revise",
"Source version must be locked before creating a revision",
],
},
]);
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
status: estimate.status,
latestVersionNumber: estimate.latestVersionNumber,
workingVersionId: estimate.versions.find(
(version) => version.status === "WORKING",
)?.id,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
createExport: managerProcedure createExport: managerProcedure
.input(CreateEstimateExportSchema) .input(CreateEstimateExportSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {