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
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.
145 lines
5.3 KiB
TypeScript
145 lines
5.3 KiB
TypeScript
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"`,
|
||
},
|
||
});
|
||
}
|