feat(branding): port logo upload pipeline for internal PDFs

Phase 1 / commit 2 of 14 — adds the admin-facing logo upload that the
brand-kit Header pulls in for every internal-only PDF.

Server pipeline (src/lib/services/logo.service.ts):
  - magic-byte format check via sharp metadata
  - rejects animated/multi-frame inputs
  - SVGs sanitized via svgo preset-default + post-pass regex check
    (rejects <script>, on*=, javascript:, external href, <foreignObject>),
    then rasterized to PNG at 300 DPI
  - HEIC/HEIF/AVIF/WEBP all auto-converted to PNG by sharp
  - optional crop coords applied server-side (bounds-checked first)
  - auto-trim near-white borders
  - resize so longest edge <= 1200px, sRGB, palette-PNG
  - rejects undersized output (< 200px any side) or > 1MB
  - atomic system_settings upsert; soft-archives prior file row + storage object

API:
  GET    /api/v1/admin/branding/logo            current logo metadata
  POST   /api/v1/admin/branding/logo            multipart upload + crop
  DELETE /api/v1/admin/branding/logo            clear; future PDFs fall back
                                                 to port-name text header
  GET    /api/v1/admin/branding/logo/sample-pdf renders branding-sample.tsx
                                                 with the current logo so
                                                 admins can spot-check
                                                 letterboxing in real shell

UI:
  src/components/admin/branding/pdf-logo-uploader.tsx
    - react-image-crop with Wide 3:1 / Square 1:1 / Freeform aspect toggle
    - file picker accepts PNG/JPEG/WEBP/SVG/HEIC/HEIF/AVIF (up to 5 MB)
    - dark-band preview swatch shows how the logo lands in the header
    - post-upload warnings panel surfaces every server-side normalization
      (resized, trimmed, JPEG no-alpha warning, SVG rasterized, etc.)
    - "Test with sample PDF" button streams a real PDF for spot-check
    - "Remove" tears down the file + storage object + setting
  Wired into the existing /admin/branding settings page beneath the
  Identity and Email-branding cards.

Audit:
  Two new AuditAction enum values added: branding.logo.uploaded and
  branding.logo.archived. Captured per upload + per archived prior logo.

Tests:
  tests/unit/logo-service.test.ts (11 tests): sharp pipeline happy path,
  undersized rejection, empty/oversized rejection, non-image rejection,
  out-of-bounds crop rejection, in-bounds crop, SVG rasterization, SVG
  with embedded script rejection, SVG with external href rejection,
  JPEG-with-no-alpha warning collection.

1308/1308 vitest green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 20:51:49 +02:00
parent 73184c51e0
commit 6517e014a6
9 changed files with 1043 additions and 7 deletions

View File

@@ -0,0 +1,87 @@
import {
BarChart,
DataTable,
DocumentShell,
KeyValueGrid,
PieChart,
Section,
} from '@/lib/pdf/brand-kit';
export interface BrandingSamplePdfProps {
portName: string;
logoBuffer: Buffer | null;
}
const SAMPLE_BARS = [
{ label: 'Mon', value: 14 },
{ label: 'Tue', value: 22 },
{ label: 'Wed', value: 18 },
{ label: 'Thu', value: 27 },
{ label: 'Fri', value: 31 },
{ label: 'Sat', value: 9 },
{ label: 'Sun', value: 5 },
];
const SAMPLE_PIE = [
{ label: 'Available', value: 42 },
{ label: 'Under Offer', value: 12 },
{ label: 'Sold', value: 38 },
];
interface SampleRow {
date: string;
action: string;
who: string;
}
const SAMPLE_ROWS: SampleRow[] = [
{ date: '2026-05-12', action: 'created client', who: 'Sarah K.' },
{ date: '2026-05-12', action: 'sent EOI', who: 'Matt P.' },
{ date: '2026-05-11', action: 'updated berth A12', who: 'Sarah K.' },
{ date: '2026-05-11', action: 'archived interest', who: 'James R.' },
];
/**
* One-page sample PDF used by the admin Branding UI's "Test with sample PDF"
* button. Exercises every brand-kit primitive a real report would touch so an
* admin can sanity-check their logo placement, font/color rendering, header
* letterboxing, and footer page numbering at a glance.
*/
export function BrandingSamplePdf({ portName, logoBuffer }: BrandingSamplePdfProps) {
return (
<DocumentShell
portName={portName}
docTitle="Branding Sample"
docMeta="Generated to verify your port logo and design tokens"
logoBuffer={logoBuffer}
pdfTitle="Branding Sample"
>
<Section title="Summary" subtitle="A glance at how the brand kit renders in real reports.">
<KeyValueGrid
rows={[
{ label: 'Port', value: portName },
{ label: 'Logo source', value: logoBuffer ? 'Configured' : 'Fallback (text)' },
{ label: 'Page size', value: 'A4' },
{ label: 'Color scheme', value: 'Slate header · Blue accent' },
]}
/>
</Section>
<Section title="Bar chart" subtitle="Sample weekly activity counts.">
<BarChart data={SAMPLE_BARS} showValues />
</Section>
<Section title="Pie chart" subtitle="Sample berth status mix.">
<PieChart data={SAMPLE_PIE} innerRadiusRatio={0.5} />
</Section>
<Section title="Table" subtitle="Recent sample events.">
<DataTable<SampleRow>
columns={[
{ header: 'Date', flex: 1, render: (r) => r.date },
{ header: 'Action', flex: 2, render: (r) => r.action },
{ header: 'Who', flex: 1, render: (r) => r.who },
]}
rows={SAMPLE_ROWS}
/>
</Section>
</DocumentShell>
);
}