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:
140
src/app/api/v1/reports/generate/route.ts
Normal file
140
src/app/api/v1/reports/generate/route.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user