feat(platform): harden access scoping and delivery baseline
This commit is contained in:
+314
-109
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user