refactor(api): extract estimate version workflow
This commit is contained in:
@@ -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;
|
||||
}),
|
||||
};
|
||||
@@ -1,12 +1,9 @@
|
||||
import {
|
||||
approveEstimateVersion,
|
||||
cloneEstimate,
|
||||
createEstimateExport,
|
||||
createEstimate,
|
||||
createEstimatePlanningHandoff,
|
||||
createEstimateRevision,
|
||||
getEstimateById,
|
||||
submitEstimateVersion,
|
||||
updateEstimateDraft,
|
||||
} from "@capakraken/application";
|
||||
import type { Prisma } from "@capakraken/db";
|
||||
@@ -19,15 +16,12 @@ import {
|
||||
aggregateWeeklyByChapter,
|
||||
} from "@capakraken/engine";
|
||||
import {
|
||||
ApproveEstimateVersionSchema,
|
||||
CloneEstimateSchema,
|
||||
CreateEstimateExportSchema,
|
||||
CreateEstimatePlanningHandoffSchema,
|
||||
CreateEstimateSchema,
|
||||
CreateEstimateRevisionSchema,
|
||||
GenerateWeeklyPhasingSchema,
|
||||
PermissionKey,
|
||||
SubmitEstimateVersionSchema,
|
||||
UpdateEstimateDraftSchema,
|
||||
} from "@capakraken/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -43,6 +37,7 @@ import {
|
||||
import { emitAllocationCreated } from "../sse/event-bus.js";
|
||||
import { estimateCommercialProcedures } from "./estimate-commercial.js";
|
||||
import { estimateReadProcedures } from "./estimate-read.js";
|
||||
import { estimateVersionWorkflowProcedures } from "./estimate-version-workflow.js";
|
||||
|
||||
type EstimateRouterErrorCode = "NOT_FOUND" | "PRECONDITION_FAILED";
|
||||
|
||||
@@ -244,6 +239,7 @@ async function autoFillDemandLineRates(
|
||||
export const estimateRouter = createTRPCRouter({
|
||||
...estimateReadProcedures,
|
||||
...estimateCommercialProcedures,
|
||||
...estimateVersionWorkflowProcedures,
|
||||
|
||||
create: managerProcedure
|
||||
.input(CreateEstimateSchema)
|
||||
@@ -403,152 +399,6 @@ export const estimateRouter = createTRPCRouter({
|
||||
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
|
||||
.input(CreateEstimateExportSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
Reference in New Issue
Block a user