From ba2bf007125cf90a8d22277f0438499d35c8b325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 22:45:05 +0200 Subject: [PATCH] refactor(api): extract estimate procedure support --- .../src/router/estimate-procedure-support.ts | 391 ++++++++++++++++++ .../src/router/estimate-version-workflow.ts | 34 +- packages/api/src/router/estimate.ts | 358 +--------------- 3 files changed, 408 insertions(+), 375 deletions(-) create mode 100644 packages/api/src/router/estimate-procedure-support.ts diff --git a/packages/api/src/router/estimate-procedure-support.ts b/packages/api/src/router/estimate-procedure-support.ts new file mode 100644 index 0000000..885a5eb --- /dev/null +++ b/packages/api/src/router/estimate-procedure-support.ts @@ -0,0 +1,391 @@ +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; + +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; +type CloneEstimateInput = z.infer; +type UpdateEstimateDraftInput = z.infer; +type CreateEstimateExportInput = z.infer; +type CreateEstimatePlanningHandoffInput = z.infer< + typeof CreateEstimatePlanningHandoffSchema +>; +type LookupDemandLineRateInput = z.infer; + +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 createEstimate( + ctx.db as unknown as Parameters[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, + }, + }); + + return estimate; +} + +export async function cloneEstimateRecord( + ctx: EstimateProcedureContext, + input: CloneEstimateInput, +) { + let estimate; + try { + estimate = await cloneEstimate( + ctx.db as unknown as Parameters[0], + input, + ); + } catch (error) { + rethrowEstimateRouterError(error, [ + { + code: "NOT_FOUND", + messages: ["Source estimate not found", "Source estimate has no versions"], + }, + ]); + } + + 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; +} + +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 updateEstimateDraft( + ctx.db as unknown as Parameters[0], + withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"), + ); + } catch (error) { + rethrowEstimateRouterError(error, [ + { + code: "NOT_FOUND", + messages: ["Estimate not found"], + }, + { + code: "PRECONDITION_FAILED", + messages: ["Estimate has no working version"], + }, + ]); + } + + 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; +} + +export async function createEstimateExportRecord( + ctx: EstimateProcedureContext, + input: CreateEstimateExportInput, +) { + let estimate; + try { + estimate = await createEstimateExport( + ctx.db as unknown as Parameters[0], + input, + ); + } catch (error) { + rethrowEstimateRouterError(error, [ + { + code: "NOT_FOUND", + messages: [ + "Estimate not found", + "Estimate version not found", + "Estimate has no version to export", + ], + }, + ]); + } + + 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; +} + +export async function createEstimatePlanningHandoffRecord( + ctx: EstimateProcedureContext, + input: CreateEstimatePlanningHandoffInput, +) { + let result; + try { + result = await createEstimatePlanningHandoff( + ctx.db as unknown as Parameters[0], + input, + ); + } 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"), + ], + }, + ]); + } + + 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, + 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, + }; +} diff --git a/packages/api/src/router/estimate-version-workflow.ts b/packages/api/src/router/estimate-version-workflow.ts index be9d2f4..e4df7fc 100644 --- a/packages/api/src/router/estimate-version-workflow.ts +++ b/packages/api/src/router/estimate-version-workflow.ts @@ -10,40 +10,8 @@ import { 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; -} +import { rethrowEstimateRouterError } from "./estimate-procedure-support.js"; export const estimateVersionWorkflowProcedures = { submitVersion: managerProcedure diff --git a/packages/api/src/router/estimate.ts b/packages/api/src/router/estimate.ts index 6c108f5..f3d1214 100644 --- a/packages/api/src/router/estimate.ts +++ b/packages/api/src/router/estimate.ts @@ -1,12 +1,3 @@ -import { - cloneEstimate, - createEstimateExport, - createEstimate, - createEstimatePlanningHandoff, - getEstimateById, - updateEstimateDraft, -} from "@capakraken/application"; -import type { Prisma } from "@capakraken/db"; import { CloneEstimateSchema, CreateEstimateExportSchema, @@ -15,58 +6,26 @@ import { PermissionKey, 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 { + cloneEstimateRecord, + createEstimateExportRecord, + createEstimatePlanningHandoffRecord, + createEstimateRecord, + lookupDemandLineRateForEstimate, + lookupDemandLineRateInputSchema, + updateEstimateDraftRecord, +} from "./estimate-procedure-support.js"; import { controllerProcedure, createTRPCRouter, managerProcedure, requirePermission, } from "../trpc.js"; -import { emitAllocationCreated } from "../sse/event-bus.js"; import { estimateCommercialProcedures } from "./estimate-commercial.js"; -import { - autoFillDemandLineRates, - withComputedMetrics, -} from "./estimate-demand-lines.js"; import { estimatePhasingProcedures } from "./estimate-phasing.js"; import { estimateReadProcedures } from "./estimate-read.js"; import { estimateVersionWorkflowProcedures } from "./estimate-version-workflow.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 estimateRouter = createTRPCRouter({ ...estimateReadProcedures, ...estimateCommercialProcedures, @@ -77,323 +36,38 @@ export const estimateRouter = createTRPCRouter({ .input(CreateEstimateSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); - - if (input.projectId) { - await findUniqueOrThrow( - ctx.db.project.findUnique({ - where: { id: input.projectId }, - select: { id: true }, - }), - "Project", - ); - } - - // Auto-fill rates from rate cards for demand lines with default (zero) rates - const { demandLines: enrichedLines, autoFilledIndices } = - await autoFillDemandLineRates(ctx.db, input.demandLines, input.projectId); - const enrichedInput = { ...input, demandLines: enrichedLines }; - - const estimate = await createEstimate( - ctx.db as unknown as Parameters[0], - withComputedMetrics(enrichedInput, input.baseCurrency), - ); - - await ctx.db.auditLog.create({ - data: { - entityType: "Estimate", - entityId: estimate.id, - action: "CREATE", - ...(ctx.dbUser?.id ? { userId: 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, - }, - }); - - return estimate; + return createEstimateRecord(ctx, input); }), clone: managerProcedure .input(CloneEstimateSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); - - let estimate; - try { - estimate = await cloneEstimate( - ctx.db as unknown as Parameters[0], - input, - ); - } catch (error) { - rethrowEstimateRouterError(error, [ - { - code: "NOT_FOUND", - messages: ["Source estimate not found", "Source estimate has no versions"], - }, - ]); - } - - await ctx.db.auditLog.create({ - data: { - entityType: "Estimate", - entityId: estimate.id, - action: "CREATE", - ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), - changes: { - after: { - id: estimate.id, - name: estimate.name, - clonedFrom: input.sourceEstimateId, - }, - } as Prisma.InputJsonValue, - }, - }); - - return estimate; + return cloneEstimateRecord(ctx, input); }), updateDraft: managerProcedure .input(UpdateEstimateDraftSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); - - if (input.projectId) { - await findUniqueOrThrow( - ctx.db.project.findUnique({ - where: { id: input.projectId }, - select: { id: true }, - }), - "Project", - ); - } - - // Auto-fill rates from rate cards for demand lines with default (zero) rates - // Resolve projectId: explicit input or existing estimate's projectId - 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 updateEstimateDraft( - ctx.db as unknown as Parameters[0], - withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"), - ); - } catch (error) { - rethrowEstimateRouterError(error, [ - { - code: "NOT_FOUND", - messages: ["Estimate not found"], - }, - { - code: "PRECONDITION_FAILED", - messages: ["Estimate has no working version"], - }, - ]); - } - - 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, - 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; + return updateEstimateDraftRecord(ctx, input); }), createExport: managerProcedure .input(CreateEstimateExportSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); - - let estimate; - try { - estimate = await createEstimateExport( - ctx.db as unknown as Parameters[0], - input, - ); - } catch (error) { - rethrowEstimateRouterError(error, [ - { - code: "NOT_FOUND", - messages: [ - "Estimate not found", - "Estimate version not found", - "Estimate has no version to export", - ], - }, - ]); - } - - 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", - ...(ctx.dbUser?.id ? { userId: 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; + return createEstimateExportRecord(ctx, input); }), createPlanningHandoff: managerProcedure .input(CreateEstimatePlanningHandoffSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); - - let result; - try { - result = await createEstimatePlanningHandoff( - ctx.db as unknown as Parameters[0], - input, - ); - } 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"), - ], - }, - ]); - } - - await ctx.db.auditLog.create({ - data: { - entityType: "Estimate", - entityId: result.estimateId, - action: "UPDATE", - ...(ctx.dbUser?.id ? { userId: 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, - projectId: allocation.projectId, - resourceId: allocation.resourceId ?? null, - }); - } - - return result; + return createEstimatePlanningHandoffRecord(ctx, input); }), - // ─── Rate Card Lookup for Demand Lines ────────────────────────────────── - lookupDemandLineRate: controllerProcedure - .input(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(), - })) - .query(async ({ ctx, input }) => { - // Resolve clientId from project if not provided directly - 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, - }; - }), + .input(lookupDemandLineRateInputSchema) + .query(({ ctx, input }) => lookupDemandLineRateForEstimate(ctx, input)), });