153 lines
5.8 KiB
TypeScript
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",
|
|
},
|
|
});
|
|
}
|