feat(platform): harden access scoping and delivery baseline

This commit is contained in:
2026-03-30 00:27:31 +02:00
parent 00b936fa1f
commit 819345acfa
109 changed files with 26142 additions and 8081 deletions
+314 -109
View File
@@ -47,6 +47,38 @@ import {
} from "../trpc.js";
import { emitAllocationCreated } from "../sse/event-bus.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;
}
function buildComputedMetrics(
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"],
) {
@@ -235,6 +267,199 @@ export const estimateRouter = createTRPCRouter({
return estimate;
}),
listVersions: controllerProcedure
.input(z.object({ estimateId: z.string() }))
.query(async ({ ctx, input }) => {
const estimate = await findUniqueOrThrow(
ctx.db.estimate.findUnique({
where: { id: input.estimateId },
select: {
id: true,
name: true,
status: true,
latestVersionNumber: true,
versions: {
orderBy: { versionNumber: "desc" },
select: {
id: true,
versionNumber: true,
label: true,
status: true,
notes: true,
lockedAt: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
assumptions: true,
scopeItems: true,
demandLines: true,
resourceSnapshots: true,
exports: true,
},
},
},
},
},
}),
"Estimate",
);
return estimate;
}),
getVersionSnapshot: controllerProcedure
.input(z.object({ estimateId: z.string(), versionId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const estimate = await ctx.db.estimate.findUnique({
where: { id: input.estimateId },
select: {
id: true,
name: true,
status: true,
baseCurrency: true,
versions: {
...(input.versionId
? { where: { id: input.versionId } }
: { orderBy: { versionNumber: "desc" as const }, take: 1 }),
select: {
id: true,
versionNumber: true,
label: true,
status: true,
notes: true,
lockedAt: true,
createdAt: true,
updatedAt: true,
assumptions: {
select: { id: true, category: true, key: true, label: true },
},
scopeItems: {
select: { id: true, scopeType: true, sequenceNo: true, name: true },
orderBy: [{ sequenceNo: "asc" }, { name: "asc" }],
},
demandLines: {
select: {
id: true,
name: true,
chapter: true,
hours: true,
costTotalCents: true,
priceTotalCents: true,
currency: true,
},
},
resourceSnapshots: {
select: {
id: true,
displayName: true,
chapter: true,
currency: true,
lcrCents: true,
ucrCents: true,
},
},
exports: {
select: {
id: true,
format: true,
fileName: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
},
},
},
},
});
if (!estimate || estimate.versions.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" });
}
const version = estimate.versions[0]!;
const demandSummary = summarizeEstimateDemandLines(version.demandLines);
const chapterTotals = version.demandLines.reduce<Record<string, {
lineCount: number;
hours: number;
costTotalCents: number;
priceTotalCents: number;
currency: string;
}>>((acc, line) => {
const key = line.chapter ?? "Unassigned";
const current = acc[key] ?? {
lineCount: 0,
hours: 0,
costTotalCents: 0,
priceTotalCents: 0,
currency: line.currency,
};
current.lineCount += 1;
current.hours += line.hours;
current.costTotalCents += line.costTotalCents;
current.priceTotalCents += line.priceTotalCents;
acc[key] = current;
return acc;
}, {});
const scopeTypeTotals = version.scopeItems.reduce<Record<string, number>>((acc, item) => {
acc[item.scopeType] = (acc[item.scopeType] ?? 0) + 1;
return acc;
}, {});
const assumptionCategoryTotals = version.assumptions.reduce<Record<string, number>>((acc, assumption) => {
acc[assumption.category] = (acc[assumption.category] ?? 0) + 1;
return acc;
}, {});
return {
estimate: {
id: estimate.id,
name: estimate.name,
status: estimate.status,
baseCurrency: estimate.baseCurrency,
},
version: {
id: version.id,
versionNumber: version.versionNumber,
label: version.label,
status: version.status,
notes: version.notes,
lockedAt: version.lockedAt,
createdAt: version.createdAt,
updatedAt: version.updatedAt,
},
counts: {
assumptions: version.assumptions.length,
scopeItems: version.scopeItems.length,
demandLines: version.demandLines.length,
resourceSnapshots: version.resourceSnapshots.length,
exports: version.exports.length,
},
totals: {
hours: demandSummary.totalHours,
costTotalCents: demandSummary.totalCostCents,
priceTotalCents: demandSummary.totalPriceCents,
marginCents: demandSummary.marginCents,
marginPercent: demandSummary.marginPercent,
},
chapterBreakdown: Object.entries(chapterTotals)
.sort((left, right) => right[1].hours - left[1].hours)
.map(([chapter, totals]) => ({
chapter,
...totals,
})),
scopeTypeBreakdown: Object.entries(scopeTypeTotals)
.sort((left, right) => right[1] - left[1])
.map(([scopeType, count]) => ({ scopeType, count })),
assumptionCategoryBreakdown: Object.entries(assumptionCategoryTotals)
.sort((left, right) => right[1] - left[1])
.map(([category, count]) => ({ category, count })),
exports: version.exports,
};
}),
create: managerProcedure
.input(CreateEstimateSchema)
.mutation(async ({ ctx, input }) => {
@@ -294,15 +519,12 @@ export const estimateRouter = createTRPCRouter({
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Source estimate not found" ||
error.message === "Source estimate has no versions"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
}
throw error;
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Source estimate not found", "Source estimate has no versions"],
},
]);
}
await ctx.db.auditLog.create({
@@ -360,19 +582,16 @@ export const estimateRouter = createTRPCRouter({
withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"),
);
} catch (error) {
if (error instanceof Error && error.message === "Estimate not found") {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error instanceof Error &&
error.message === "Estimate has no working version"
) {
throw new TRPCError({
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found"],
},
{
code: "PRECONDITION_FAILED",
message: error.message,
});
}
throw error;
messages: ["Estimate has no working version"],
},
]);
}
await ctx.db.auditLog.create({
@@ -411,24 +630,19 @@ export const estimateRouter = createTRPCRouter({
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate has no working version" ||
error.message === "Only working versions can be submitted"
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw 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({
@@ -464,24 +678,19 @@ export const estimateRouter = createTRPCRouter({
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate has no submitted version" ||
error.message === "Only submitted versions can be approved"
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw 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({
@@ -517,25 +726,20 @@ export const estimateRouter = createTRPCRouter({
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate already has a working version" ||
error.message === "Estimate has no locked version to revise" ||
error.message === "Source version must be locked before creating a revision"
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw 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({
@@ -572,16 +776,16 @@ export const estimateRouter = createTRPCRouter({
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found" ||
error.message === "Estimate has no version to export"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
}
throw error;
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: [
"Estimate not found",
"Estimate version not found",
"Estimate has no version to export",
],
},
]);
}
const exportedVersion = input.versionId
@@ -620,29 +824,30 @@ export const estimateRouter = createTRPCRouter({
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found" ||
error.message === "Linked project not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate has no approved version" ||
error.message === "Only approved versions can be handed off to planning" ||
error.message === "Estimate must be linked to a project before planning handoff" ||
error.message === "Planning handoff already exists for this approved version" ||
error.message === "Linked project has an invalid date range" ||
error.message.startsWith("Project window has no working days for demand line")
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw 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({