chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
+757
View File
@@ -0,0 +1,757 @@
import {
approveEstimateVersion,
cloneEstimate,
createEstimateExport,
createEstimate,
createEstimatePlanningHandoff,
createEstimateRevision,
getEstimateById,
listEstimates,
submitEstimateVersion,
updateEstimateDraft,
} from "@planarchy/application";
import type { Prisma } from "@planarchy/db";
import {
normalizeEstimateDemandLine,
summarizeEstimateDemandLines,
generateWeekRange,
distributeHoursToWeeks,
aggregateWeeklyToMonthly,
aggregateWeeklyByChapter,
} from "@planarchy/engine";
import {
ApproveEstimateVersionSchema,
CloneEstimateSchema,
CreateEstimateExportSchema,
CreateEstimatePlanningHandoffSchema,
CreateEstimateSchema,
CreateEstimateRevisionSchema,
EstimateListFiltersSchema,
GenerateWeeklyPhasingSchema,
PermissionKey,
SubmitEstimateVersionSchema,
UpdateEstimateDraftSchema,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
controllerProcedure,
createTRPCRouter,
managerProcedure,
protectedProcedure,
requirePermission,
} from "../trpc.js";
import { emitAllocationCreated } from "../sse/event-bus.js";
function buildComputedMetrics(
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"],
) {
const summary = summarizeEstimateDemandLines(demandLines);
return [
{
key: "total_hours",
label: "Total Hours",
metricGroup: "summary",
valueDecimal: summary.totalHours,
metadata: {},
},
{
key: "total_cost",
label: "Total Cost",
metricGroup: "summary",
valueDecimal: summary.totalCostCents / 100,
valueCents: summary.totalCostCents,
currency: demandLines[0]?.currency ?? "EUR",
metadata: {},
},
{
key: "total_price",
label: "Total Price",
metricGroup: "summary",
valueDecimal: summary.totalPriceCents / 100,
valueCents: summary.totalPriceCents,
currency: demandLines[0]?.currency ?? "EUR",
metadata: {},
},
{
key: "margin",
label: "Margin",
metricGroup: "summary",
valueDecimal: summary.marginCents / 100,
valueCents: summary.marginCents,
currency: demandLines[0]?.currency ?? "EUR",
metadata: {},
},
{
key: "margin_percent",
label: "Margin %",
metricGroup: "summary",
valueDecimal: summary.marginPercent,
metadata: {},
},
];
}
function normalizeDemandLines<
T extends {
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"];
resourceSnapshots: z.infer<typeof CreateEstimateSchema>["resourceSnapshots"];
},
>(input: T, baseCurrency: string) {
const snapshotsByResourceId = new Map(
input.resourceSnapshots
.filter(
(snapshot): snapshot is (typeof input.resourceSnapshots)[number] & {
resourceId: string;
} => typeof snapshot.resourceId === "string" && snapshot.resourceId.length > 0,
)
.map((snapshot) => [snapshot.resourceId, snapshot]),
);
return input.demandLines.map((line) =>
normalizeEstimateDemandLine(line, {
resourceSnapshot:
line.resourceId != null ? snapshotsByResourceId.get(line.resourceId) : null,
defaultCurrency: baseCurrency,
}),
);
}
function withComputedMetrics<
T extends {
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"];
resourceSnapshots: z.infer<typeof CreateEstimateSchema>["resourceSnapshots"];
metrics: z.infer<typeof CreateEstimateSchema>["metrics"];
},
>(input: T, baseCurrency: string) {
const normalizedDemandLines = normalizeDemandLines(input, baseCurrency);
const computedMetrics = buildComputedMetrics(normalizedDemandLines);
const computedKeys = new Set(computedMetrics.map((metric) => metric.key));
return {
...input,
demandLines: normalizedDemandLines,
metrics: [
...input.metrics.filter((metric) => !computedKeys.has(metric.key)),
...computedMetrics,
],
};
}
export const estimateRouter = createTRPCRouter({
list: protectedProcedure
.input(EstimateListFiltersSchema.default({}))
.query(async ({ ctx, input }) =>
listEstimates(
ctx.db as unknown as Parameters<typeof listEstimates>[0],
input,
)),
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const estimate = await getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.id,
);
if (!estimate) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
}
return estimate;
}),
create: managerProcedure
.input(CreateEstimateSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
if (input.projectId) {
const project = await ctx.db.project.findUnique({
where: { id: input.projectId },
select: { id: true },
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
}
const estimate = await createEstimate(
ctx.db as unknown as Parameters<typeof createEstimate>[0],
withComputedMetrics(input, 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,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
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<typeof cloneEstimate>[0],
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;
}
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;
}),
updateDraft: managerProcedure
.input(UpdateEstimateDraftSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
if (input.projectId) {
const project = await ctx.db.project.findUnique({
where: { id: input.projectId },
select: { id: true },
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
}
let estimate;
try {
estimate = await updateEstimateDraft(
ctx.db as unknown as Parameters<typeof updateEstimateDraft>[0],
withComputedMetrics(input, 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({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
throw error;
}
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,
},
} as Prisma.InputJsonValue,
},
});
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) {
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;
}
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) {
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;
}
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) {
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;
}
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 }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await createEstimateExport(
ctx.db as unknown as Parameters<typeof createEstimateExport>[0],
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;
}
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;
}),
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<typeof createEstimatePlanningHandoff>[0],
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;
}
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;
}),
generateWeeklyPhasing: managerProcedure
.input(GenerateWeeklyPhasingSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const estimate = await getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.estimateId,
);
if (!estimate) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
}
const workingVersion = estimate.versions.find(
(v) => v.status === "WORKING",
);
if (!workingVersion) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Estimate has no working version",
});
}
const pattern = input.pattern ?? "even";
// Distribute hours for each demand line and update DB
const updates: Array<{ id: string; monthlySpread: Record<string, number>; metadata: Record<string, unknown> }> = [];
for (const line of workingVersion.demandLines) {
const result = distributeHoursToWeeks({
totalHours: line.hours,
startDate: input.startDate,
endDate: input.endDate,
pattern,
});
const monthlySpread = aggregateWeeklyToMonthly(result.weeklyHours);
const existingMetadata = (line.metadata ?? {}) as Record<string, unknown>;
const metadata = {
...existingMetadata,
weeklyPhasing: {
startDate: input.startDate,
endDate: input.endDate,
pattern,
weeklyHours: result.weeklyHours,
generatedAt: new Date().toISOString(),
},
};
updates.push({ id: line.id, monthlySpread, metadata });
}
// Batch update all demand lines
await Promise.all(
updates.map((update) =>
ctx.db.estimateDemandLine.update({
where: { id: update.id },
data: {
monthlySpread: update.monthlySpread as Prisma.InputJsonValue,
metadata: update.metadata as Prisma.InputJsonValue,
},
}),
),
);
return {
estimateId: input.estimateId,
versionId: workingVersion.id,
linesUpdated: updates.length,
startDate: input.startDate,
endDate: input.endDate,
pattern,
};
}),
getWeeklyPhasing: controllerProcedure
.input(z.object({ estimateId: z.string() }))
.query(async ({ ctx, input }) => {
const estimate = await getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.estimateId,
);
if (!estimate) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
}
// Get the latest version (first in the sorted array)
const version = estimate.versions[0];
if (!version) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Estimate has no versions",
});
}
// Extract weekly phasing from each demand line's metadata
type WeeklyPhasingMeta = {
startDate: string;
endDate: string;
pattern: string;
weeklyHours: Record<string, number>;
generatedAt: string;
};
const linesWithPhasing: Array<{
id: string;
name: string;
chapter: string | null;
hours: number;
weeklyHours: Record<string, number>;
}> = [];
let phasingConfig: { startDate: string; endDate: string; pattern: string } | null = null;
for (const line of version.demandLines) {
const meta = (line.metadata ?? {}) as Record<string, unknown>;
const phasing = meta["weeklyPhasing"] as WeeklyPhasingMeta | undefined;
if (phasing) {
if (!phasingConfig) {
phasingConfig = {
startDate: phasing.startDate,
endDate: phasing.endDate,
pattern: phasing.pattern,
};
}
linesWithPhasing.push({
id: line.id,
name: line.name,
chapter: line.chapter ?? null,
hours: line.hours,
weeklyHours: phasing.weeklyHours,
});
}
}
if (!phasingConfig || linesWithPhasing.length === 0) {
return {
estimateId: input.estimateId,
versionId: version.id,
hasPhasing: false as const,
config: null,
weeks: [],
lines: [],
chapterAggregation: {},
};
}
const weeks = generateWeekRange(phasingConfig.startDate, phasingConfig.endDate);
const chapterAggregation = aggregateWeeklyByChapter(linesWithPhasing);
return {
estimateId: input.estimateId,
versionId: version.id,
hasPhasing: true as const,
config: phasingConfig,
weeks,
lines: linesWithPhasing,
chapterAggregation,
};
}),
});