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"`, }, }); }