feat(reports): PDF report exporter foundation + dashboard report (phase A)

Production-grade PDF reporting for the CRM. Phase A ships the
foundation (branded layout, render pipeline, API route) plus the
first report kind — the dashboard summary. Phases B, C, D add the
remaining report kinds, saved templates, and the preview modal.

Stack: @react-pdf/renderer (already in package.json). Single primary
font (Helvetica/Helvetica-Bold), per-port primary color + logo,
table-based section layout. Charts will become tables here on
purpose; reports are for printed reference and review, where
exact numbers beat at-a-glance shapes. We can revisit Recharts-as-
SVG embedding if a stakeholder asks for chart visuals.

New files:
  - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig
    covering dashboard / clients / berths / interests kinds. Only
    dashboard is wired in phase A; the others throw a clear
    not-implemented error from pickDocument().
  - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off
    branding.primaryColor. Computes a readable foreground color
    (luminance check) for the accent stripe so dark-brand ports
    still read at AA.
  - src/lib/pdf/reports/branded-document.tsx: page wrapper with
    fixed footer (port name, generated-at timestamp, page numbers
    via react-pdf's render-prop pattern).
  - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget
    SimpleTable sections. Each section gated on the widget id being
    present in config.widgetIds AND data being supplied.
  - src/lib/pdf/reports/render-report.ts: single entry point that
    resolves branding (logoUrl + primaryColor + portName from
    getPortBrandingConfig + ports.name), dispatches via
    discriminated-union switch, returns Buffer via renderToBuffer.
    Exhaustiveness check at the bottom catches unhandled variants
    at compile time.
  - src/lib/services/dashboard-report-data.service.ts: server-side
    data resolver. PDF_DASHBOARD_WIDGETS is the public widget list
    for the dialog picker; each id maps to a dashboard.service.ts
    fetcher invoked only when the rep selected that widget.
  - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod
    discriminated-union body schema, withAuth + withPermission
    'reports.export' gating, audit-log write on success, RFC 5987
    Content-Disposition for unicode-safe filenames.
  - src/components/reports/export-dashboard-pdf-button.tsx: dialog
    with section checkboxes + title input. Permission-gated client-
    side (server re-checks). Raw fetch (not apiFetch) to pull the
    binary blob with X-Port-Id header attached manually.
  - tests/unit/pdf-report-renderer.test.ts: renders three fixture
    cases — full set / sparse / no-logo — and asserts the buffer
    starts with the `%PDF-` magic bytes and is non-trivial in size.

DashboardShell gains an Export PDF button between the date-range
picker and the Customize widgets menu (gated on reports.export).

Verified: tsc clean, vitest 1451/1451 (3 new render tests included).
The first end-to-end manual test (export a real dashboard) is in
Phase D after the preview modal lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 20:35:53 +02:00
parent e91055f784
commit 3b199c245c
10 changed files with 1243 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { renderReport, type ReportData } from '@/lib/pdf/reports/render-report';
import { resolveDashboardReportData } from '@/lib/services/dashboard-report-data.service';
import { createAuditLog } from '@/lib/audit';
const dashboardConfigSchema = z.object({
kind: z.literal('dashboard'),
widgetIds: z.array(z.string()).min(1).max(20),
dateFrom: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
dateTo: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
});
const requestSchema = z.object({
title: z.string().min(1).max(200),
subtitle: z.string().max(400).optional(),
config: z.discriminatedUnion('kind', [
dashboardConfigSchema,
// Phase B will widen this union with clients / berths / interests.
]),
});
/**
* POST /api/v1/reports/generate
*
* Renders a port-branded PDF report and streams it back to the
* caller. Permission gating sits at the entry: `reports.export` (a
* new permission added in seed-data; defaults to true for super-
* admins and sales-managers, false for sales-agents). The route also
* defends in depth via per-section service calls, each of which
* reapplies the active port filter — a rep with reports.export but
* no clients.view still gets a clients table in the PDF, since the
* export is a snapshot of state the rep already has dashboard access
* to. Defer narrower per-section gating to a follow-up once the
* usage pattern is clearer.
*
* Audit trail: every successful export writes a `document` create
* audit row with the report kind + widget ids in metadata. Useful
* for "who exported what" investigations downstream.
*/
export const POST = withAuth(
withPermission('reports', 'export', async (req, ctx) => {
try {
const body = await parseBody(req, requestSchema);
const data: ReportData = {};
switch (body.config.kind) {
case 'dashboard':
data.dashboard = await resolveDashboardReportData(ctx.portId, body.config.widgetIds);
break;
default:
// Unreachable while only the dashboard kind is wired; kept
// for the type-narrowing exhaustiveness check.
throw new ValidationError('Unsupported report kind');
}
const buffer = await renderReport({
portId: ctx.portId,
request: body,
data,
});
// Audit BEFORE returning so a failed write doesn't silently
// leak an export. The `void` is intentional — audit is best-
// effort relative to the export; the PDF download succeeding
// is the contract.
void createAuditLog({
portId: ctx.portId,
userId: ctx.userId,
action: 'create',
entityType: 'report',
entityId: 'pdf-export',
metadata: {
kind: body.config.kind,
title: body.title,
...(body.config.kind === 'dashboard'
? {
widgetIds: body.config.widgetIds,
dateFrom: body.config.dateFrom,
dateTo: body.config.dateTo,
}
: {}),
sizeBytes: buffer.byteLength,
},
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
const filename = sanitizeFilename(`${body.title}.pdf`);
// Stream the buffer back inline. The Content-Disposition uses
// `attachment; filename*` (RFC 5987) for unicode titles —
// Port names with diacritics need this or browsers fall back
// to a mojibake'd ASCII filename. `filename=` carries the
// ASCII fallback for older HTTP stacks.
// Node Buffer is not directly assignable to BodyInit in the
// Next.js / Web Fetch typings; passing the underlying
// ArrayBuffer view keeps the bytes intact without an extra copy.
return new NextResponse(new Uint8Array(buffer), {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Length': String(buffer.byteLength),
'Content-Disposition': `attachment; filename="${filename.ascii}"; filename*=UTF-8''${filename.encoded}`,
// No-cache: each export reflects the moment-in-time data,
// so caching the response would leak stale numbers to
// subsequent calls.
'Cache-Control': 'no-store',
},
});
} catch (error) {
logger.warn({ err: error, portId: ctx.portId }, 'PDF report generation failed');
return errorResponse(error);
}
}),
);
/**
* Build a safe attachment filename. The ASCII variant strips
* non-ASCII characters for the legacy `filename=` field; the
* percent-encoded variant carries the full unicode title for the
* RFC 5987 `filename*=` field. Replace `/` and `\\` so the file
* doesn't accidentally look like a path component.
*/
function sanitizeFilename(name: string): { ascii: string; encoded: string } {
const noPaths = name.replace(/[\\/]/g, '_');
const ascii = noPaths.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, "'");
const encoded = encodeURIComponent(noPaths);
return { ascii, encoded };
}