import { type NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { errorResponse, requireRole, ApiError } from "@/lib/api"; import { readFileBytes } from "@/lib/files"; import { renderProjectTravelers, type OperationCardData, type PartCoverData, } from "@/lib/pdf"; /** * Admin-only. Bulk traveler PDF for an entire project — one megafile * containing every part's cover + drawings + op cards, prefixed with a * project-level summary sheet. * * Query params: * ?scope=all (default) every part with at least one operation * ?scope=incomplete only parts that still have pending / partial / * in_progress / qc_failed ops (i.e. what's left to run) * * Parts with zero operations are always skipped — they'd render an empty * card anyway. Drawings are inlined best-effort (missing files are logged * and the PDF still renders). */ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { try { await requireRole("admin"); const { id } = await ctx.params; const scope = (new URL(req.url).searchParams.get("scope") ?? "all").toLowerCase(); if (scope !== "all" && scope !== "incomplete") { throw new ApiError(400, "bad_scope", "scope must be 'all' or 'incomplete'"); } const project = await prisma.project.findUnique({ where: { id }, select: { id: true, code: true, name: true, customerCode: true, dueDate: true }, }); if (!project) throw new ApiError(404, "not_found", "Project not found"); const parts = await prisma.part.findMany({ where: { assembly: { projectId: id }, ...(scope === "incomplete" ? { operations: { some: { status: { in: ["pending", "partial", "in_progress", "qc_failed"] }, }, }, } : {}), }, orderBy: [{ assembly: { code: "asc" } }, { code: "asc" }], include: { assembly: { include: { project: { select: { code: true, name: true } }, drawingFile: { select: { path: true, originalName: true } }, }, }, stepFile: { select: { originalName: true, sizeBytes: true, sha256: true } }, drawingFile: { select: { originalName: true, sizeBytes: true, sha256: true, path: true }, }, cutFile: { select: { originalName: true, sizeBytes: true, sha256: true } }, operations: { orderBy: { sequence: "asc" }, include: { machine: { select: { name: true, kind: true } } }, }, }, }); const eligible = parts.filter((p) => p.operations.length > 0); if (eligible.length === 0) { throw new ApiError( 400, "no_parts", scope === "incomplete" ? "No parts with outstanding operations in this project" : "No parts with operations in this project", ); } const bundles = await Promise.all( eligible.map(async (part) => { const projectHead = { code: part.assembly.project.code, name: part.assembly.project.name }; const assembly = { code: part.assembly.code, name: part.assembly.name, qty: part.assembly.qty, }; const partHeader = { code: part.code, name: part.name, material: part.material, qty: part.qty, }; const drawingPdfBytes = await tryReadPdf(part.drawingFile?.path ?? null); const assemblyDrawingPdfBytes = await tryReadPdf(part.assembly.drawingFile?.path ?? null); const cover: PartCoverData = { project: projectHead, assembly, part: { ...partHeader, notes: part.notes }, files: [ { label: "STEP / 3D", file: part.stepFile }, { label: "Drawing PDF", file: part.drawingFile }, { label: "Cut file", file: part.cutFile }, ], operations: part.operations.map((op) => ({ sequence: op.sequence, name: op.name, kind: op.kind, machineName: op.machine?.name ?? null, qcRequired: op.qcRequired, qrToken: op.qrToken, unitsCompleted: op.unitsCompleted, status: op.status, })), }; const cards: OperationCardData[] = part.operations.map((op) => ({ project: projectHead, assembly, part: partHeader, operation: { id: op.id, sequence: op.sequence, name: op.name, kind: op.kind, qrToken: op.qrToken, machineName: op.machine?.name ?? null, machineKind: op.machine?.kind ?? null, settings: op.settings, materialNotes: op.materialNotes, instructions: op.instructions, qcRequired: op.qcRequired, plannedMinutes: op.plannedMinutes, plannedUnits: op.plannedUnits, unitsCompleted: op.unitsCompleted, status: op.status, }, })); return { cover, cards, drawingPdfBytes, assemblyDrawingPdfBytes }; }), ); const pdf = await renderProjectTravelers({ project: { code: project.code, name: project.name, customerCode: project.customerCode, dueDate: project.dueDate, }, bundles, }); const suffix = scope === "incomplete" ? "-incomplete" : ""; const safeName = `${project.code}${suffix}-travelers.pdf`.replace(/[^A-Za-z0-9._-]/g, "_"); return new NextResponse(pdf as unknown as BodyInit, { status: 200, headers: { "Content-Type": "application/pdf", "Content-Disposition": `inline; filename="${safeName}"`, "Cache-Control": "private, no-store", }, }); } catch (err) { return errorResponse(err); } } async function tryReadPdf(path: string | null): Promise { if (!path) return null; try { const buf = await readFileBytes(path); return new Uint8Array(buf); } catch (err) { console.warn("[projects/travelers.pdf] could not read drawing file", { path, err }); return null; } }