Files
CapaKraken/apps/web/src/app/api/reports/allocations/route.ts
T
Hartmut 40ca0c3046
CI / Architecture Guardrails (pull_request) Successful in 2m6s
CI / Lint (pull_request) Successful in 7m29s
CI / Typecheck (pull_request) Successful in 8m3s
CI / Unit Tests (pull_request) Successful in 8m11s
CI / Build (pull_request) Successful in 5m24s
CI / E2E Tests (pull_request) Successful in 5m25s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 6m30s
CI / Release Images (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Successful in 3m47s
security: bound Zod inputs, add SSE per-user cap and tRPC body limit (#51)
Mechanical .max() bounds across 9 router schemas per the convention in
#51: IDs at 64, names at 200, search/filter strings at 500, arrays at
100-5000 depending on domain. Webhook secret bounded at min(16)/max(256).

Reports route now validates startDate/endDate via zod with year bounds
and rejects end<start. SSE timeline route enforces a per-user connection
cap of 8 (returns 429 with Retry-After). tRPC route rejects bodies over
2 MiB via Content-Length check before auth/DB work.

Covers 12 call-sites listed in #51. ESLint rule and zod conventions doc
remain as follow-up.
2026-04-18 13:31:18 +02:00

145 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { renderToBuffer } from "@react-pdf/renderer";
import { createElement } from "react";
import { NextResponse } from "next/server";
import { z } from "zod";
import { buildSplitAllocationReadModel } from "@capakraken/application";
import { anonymizeResource, getAnonymizationDirectory } from "@capakraken/api";
import { prisma } from "@capakraken/db";
import type { AllocationLike } from "@capakraken/shared";
import { auth } from "~/server/auth.js";
import { AllocationReport } from "~/components/reports/AllocationReport.js";
import { createWorkbookArrayBuffer } from "~/lib/workbook-export.js";
const ALLOWED_ROLES = new Set(["ADMIN", "MANAGER", "CONTROLLER"]);
// Reject fantasy dates from clients — years outside [2000, 2100] are almost
// certainly malformed input and would generate nonsensical SQL range scans.
const DATE_MIN = new Date("2000-01-01T00:00:00.000Z");
const DATE_MAX = new Date("2100-01-01T00:00:00.000Z");
const queryParamsSchema = z.object({
startDate: z.coerce.date().min(DATE_MIN).max(DATE_MAX).optional(),
endDate: z.coerce.date().min(DATE_MIN).max(DATE_MAX).optional(),
format: z.enum(["pdf", "xlsx"]).default("pdf"),
});
export async function GET(request: Request) {
const session = await auth();
if (!session?.user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const userRole = (session.user as { role?: string }).role;
if (!userRole || !ALLOWED_ROLES.has(userRole)) {
return new NextResponse("Forbidden", { status: 403 });
}
const { searchParams } = new URL(request.url);
const parsed = queryParamsSchema.safeParse({
startDate: searchParams.get("startDate") ?? undefined,
endDate: searchParams.get("endDate") ?? undefined,
format: searchParams.get("format") ?? undefined,
});
if (!parsed.success) {
return new NextResponse("Invalid query parameters", { status: 400 });
}
const startDate = parsed.data.startDate ?? new Date();
const endDate = parsed.data.endDate ?? new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
if (endDate < startDate) {
return new NextResponse("endDate must be >= startDate", { status: 400 });
}
const format = parsed.data.format;
const [demandRequirements, assignments] = await Promise.all([
prisma.demandRequirement.findMany({
where: {
status: { not: "CANCELLED" },
startDate: { lte: endDate },
endDate: { gte: startDate },
},
include: {
project: { select: { id: true, name: true, shortCode: true } },
},
orderBy: [{ project: { name: "asc" } }, { startDate: "asc" }],
take: 1000,
}),
prisma.assignment.findMany({
where: {
status: { not: "CANCELLED" },
startDate: { lte: endDate },
endDate: { gte: startDate },
},
include: {
resource: { select: { id: true, displayName: true, eid: true, lcrCents: true } },
project: { select: { id: true, name: true, shortCode: true } },
},
orderBy: [{ project: { name: "asc" } }, { startDate: "asc" }],
take: 1000,
}),
]);
const allocationView = buildSplitAllocationReadModel({
demandRequirements,
assignments,
});
const assignmentRows = allocationView.assignments.slice(0, 500);
const directory = await getAnonymizationDirectory(prisma);
const rows = assignmentRows.map(
(
a: AllocationLike & {
resource?: { id: string; displayName?: string | null } | null;
project?: { shortCode: string; name: string } | null;
},
) => {
const resource = a.resource ? anonymizeResource(a.resource, directory) : null;
return {
resourceName: resource?.displayName ?? "Unknown",
projectName: a.project ? `${a.project.shortCode}${a.project.name}` : "Unknown project",
role: a.role ?? "",
startDate: new Date(a.startDate).toLocaleDateString("en-GB"),
endDate: new Date(a.endDate).toLocaleDateString("en-GB"),
hoursPerDay: a.hoursPerDay,
dailyCostCents: a.dailyCostCents,
};
},
);
const ts = Date.now();
if (format === "xlsx") {
const workbookRows = [
["Resource", "Project", "Role", "Start Date", "End Date", "Hours/Day", "Daily Cost (ct)"],
...rows.map((row) => [
row.resourceName,
row.projectName,
row.role,
row.startDate,
row.endDate,
row.hoursPerDay,
row.dailyCostCents,
]),
];
const buffer = Buffer.from(await createWorkbookArrayBuffer("Allocations", workbookRows));
return new NextResponse(buffer as unknown as BodyInit, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="allocations-${ts}.xlsx"`,
},
});
}
const title = `Allocation Report ${startDate.toLocaleDateString("en-GB")} ${endDate.toLocaleDateString("en-GB")}`;
const generatedAt = new Date().toLocaleString("en-GB");
const doc = createElement(AllocationReport, { title, generatedAt, rows });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const buffer = await renderToBuffer(doc as any);
return new NextResponse(buffer as unknown as BodyInit, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="allocations-${ts}.pdf"`,
},
});
}