/** * Phase 10 Task #67 — Inline PDF helper for the admin/tenant audit-export * routes. * * Wraps `serializeAuditEventsToPdf` from `@toolctrl/exports/audit-pdf` * with the same row + integrity shape the admin route already computes * for the CSV path. Wired into the export route via `?format=pdf` * (the default remains CSV). * * See docs/improvement-plan/10-compliance-polish/compliance-audit-export.md. */ import { AUDIT_PDF_CONTENT_TYPE, AUDIT_PDF_HARD_ROW_LIMIT, serializeAuditEventsToPdf, summarizeAuditPdfFilters, type AuditPdfEvent, type AuditPdfIntegrity, type AuditPdfLabelOverrides, type AuditPdfReportInput, } from "@toolctrl/exports/audit-pdf"; import type { TFunction } from "i18next"; import type { AuditExportFilters, AuditExportRow, AuditIntegritySummary } from "~/routes/admin/audit-export.helpers.server"; export { AUDIT_PDF_CONTENT_TYPE, AUDIT_PDF_HARD_ROW_LIMIT }; /** * Map an in-memory AuditExportRow (from the existing CSV pipeline) to * the AuditPdfEvent shape consumed by @toolctrl/exports/audit-pdf. * * `metadata` is opaque JSON in Prisma — we keep it as-is; the renderer * never inlines metadata into the report body (privacy / page-density * concern). */ export function toAuditPdfEvent(row: AuditExportRow): AuditPdfEvent { return { id: row.id, createdAt: row.createdAt, tenantId: row.tenantId, actorType: row.actorType, actorId: row.actorId, actorEmail: row.actorEmail, actorIp: row.actorIp, actorUserAgent: row.actorUserAgent, action: row.action, targetType: row.targetType, targetId: row.targetId, requestId: row.requestId, sourceModule: row.sourceModule, outcome: row.outcome, dataSensitivity: row.dataSensitivity, regulatoryScope: row.regulatoryScope, metadata: row.metadata, previousHash: row.previousHash, eventHash: row.eventHash, }; } /** Map our AuditIntegritySummary onto AuditPdfIntegrity (1:1 shape). */ export function toAuditPdfIntegrity(integrity: AuditIntegritySummary): AuditPdfIntegrity { return { ok: integrity.ok, inspected: integrity.inspected, firstBreakId: integrity.firstBreakId, reason: integrity.reason, }; } export type AuditPdfRenderRequest = { tenantId: string | null; filters: AuditExportFilters; integrity: AuditIntegritySummary; rows: ReadonlyArray; generatedBy?: string; generatedAt?: Date; labels?: AuditPdfLabelOverrides; }; export function buildAuditPdfLabels(t: TFunction): AuditPdfLabelOverrides { return { title: t("settings:auditExport.pdf.title"), tenant: t("settings:auditExport.pdf.tenant"), platformTenant: t("settings:auditExport.pdf.platformTenant"), range: t("settings:auditExport.pdf.range"), openRange: t("settings:auditExport.pdf.openRange"), filters: t("settings:auditExport.pdf.filters"), noFilters: t("settings:auditExport.pdf.noFilters"), generatedBy: t("settings:auditExport.pdf.generatedBy"), generatedAt: t("settings:auditExport.pdf.generatedAt"), hashChainIntegrity: t("settings:auditExport.pdf.hashChainIntegrity"), events: (count) => t("settings:auditExport.pdf.events", { count }), integrityOk: (inspected) => t("settings:auditExport.pdf.integrity.ok", { inspected }), integrityBroken: ({ inspected, firstBreakId, reason }) => t("settings:auditExport.pdf.integrity.broken", { inspected, firstBreakId, reason }), columns: { createdAt: t("settings:auditExport.pdf.columns.createdAt"), actorType: t("settings:auditExport.pdf.columns.actorType"), actorId: t("settings:auditExport.pdf.columns.actorId"), action: t("settings:auditExport.pdf.columns.action"), targetType: t("settings:auditExport.pdf.columns.targetType"), outcome: t("settings:auditExport.pdf.columns.outcome"), regulatoryScope: t("settings:auditExport.pdf.columns.regulatoryScope"), eventHash: t("settings:auditExport.pdf.columns.eventHash"), }, }; } export function buildAuditPdfReportInput(req: AuditPdfRenderRequest): AuditPdfReportInput { return { tenantId: req.tenantId, from: req.filters.from?.toISOString() ?? null, to: req.filters.to?.toISOString() ?? null, filterSummary: summarizeAuditPdfFilters({ regulatoryScope: req.filters.regulatoryScope, actorType: req.filters.actorType, outcome: req.filters.outcome, }), integrity: toAuditPdfIntegrity(req.integrity), events: req.rows.map(toAuditPdfEvent), ...(req.generatedBy ? { generatedBy: req.generatedBy } : {}), ...(req.generatedAt ? { generatedAt: req.generatedAt } : {}), ...(req.labels ? { labels: req.labels } : {}), }; } /** * Render the audit-export response as a PDF stream. Returns a * Web-Response with the appropriate Content-Disposition + X-Audit-Chain-Status * headers so the route can return it directly. */ export async function renderAuditExportPdfResponse( req: AuditPdfRenderRequest, options: { filenamePrefix: string } ): Promise { const input = buildAuditPdfReportInput(req); const bytes = await serializeAuditEventsToPdf(input); const filenameSlug = req.tenantId ?? "platform"; const fromSlug = req.filters.from ? req.filters.from.toISOString().slice(0, 10) : "all"; const toSlug = req.filters.to ? req.filters.to.toISOString().slice(0, 10) : "all"; // Convert Uint8Array (Node Buffer slice) to ArrayBuffer for Response body // typing — node's Buffer is fine but Response wants a BodyInit. Slicing // via .buffer keeps the bytes intact. const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; return new Response(body, { headers: { "Content-Type": AUDIT_PDF_CONTENT_TYPE, "Content-Disposition": `attachment; filename="${options.filenamePrefix}-${filenameSlug}-${fromSlug}_${toSlug}.pdf"`, "X-Audit-Chain-Status": req.integrity.ok ? "ok" : "broken", }, }); }