Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/audit/pdfRender.ts
T

153 lines
5.8 KiB
TypeScript

/**
* 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<AuditExportRow>;
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<Response> {
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",
},
});
}