diff --git a/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx b/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx index 19bebc66..7ef3d850 100644 --- a/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx @@ -3,6 +3,7 @@ import { type SettingFieldDef, } from '@/components/admin/shared/settings-form-card'; import { PageHeader } from '@/components/shared/page-header'; +import { PdfLogoUploader } from '@/components/admin/branding/pdf-logo-uploader'; const DEFAULT_EMAIL_HEADER_HTML = ` @@ -87,6 +88,7 @@ export default function BrandingSettingsPage() { description="HTML fragments rendered around every transactional email." fields={FIELDS.slice(3)} /> + ); } diff --git a/src/app/api/v1/admin/branding/logo/route.ts b/src/app/api/v1/admin/branding/logo/route.ts new file mode 100644 index 00000000..320fd4f1 --- /dev/null +++ b/src/app/api/v1/admin/branding/logo/route.ts @@ -0,0 +1,132 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse, ValidationError } from '@/lib/errors'; +import { + clearPortLogo, + getPortLogoFile, + processLogoUpload, + setPortLogo, + type LogoCrop, +} from '@/lib/services/logo.service'; +import { env } from '@/lib/env'; + +const MAX_RAW_BYTES = 5 * 1024 * 1024; + +function parseCrop(value: FormDataEntryValue | null): LogoCrop | undefined { + if (typeof value !== 'string' || value.length === 0) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new ValidationError('Invalid crop JSON'); + } + if (!parsed || typeof parsed !== 'object') { + throw new ValidationError('Invalid crop payload'); + } + const c = parsed as Record; + for (const key of ['x', 'y', 'width', 'height']) { + if (typeof c[key] !== 'number' || !Number.isFinite(c[key])) { + throw new ValidationError(`Crop ${key} must be a finite number`); + } + } + return { + x: c.x as number, + y: c.y as number, + width: c.width as number, + height: c.height as number, + }; +} + +/** + * GET /api/v1/admin/branding/logo + * Returns metadata for the current port logo (or null). + */ +export const GET = withAuth( + withPermission('admin', 'manage_settings', async (_req, ctx) => { + try { + if (!ctx.portId) throw new ValidationError('No active port'); + const file = await getPortLogoFile(ctx.portId); + if (!file) { + return NextResponse.json({ data: null }); + } + const baseUrl = env.APP_URL.replace(/\/+$/, ''); + return NextResponse.json({ + data: { + fileId: file.id, + previewUrl: `${baseUrl}/api/v1/files/${file.id}/preview`, + sizeBytes: file.sizeBytes, + mimeType: file.mimeType, + }, + }); + } catch (error) { + return errorResponse(error); + } + }), +); + +/** + * POST /api/v1/admin/branding/logo + * + * Multipart: `file` (required) + `crop` (optional JSON string `{x, y, width, height}`). + * Runs the sharp normalization pipeline; if accepted, atomically updates the + * `port_logo_file_id` system setting and soft-archives the previous logo. + */ +export const POST = withAuth( + withPermission('admin', 'manage_settings', async (req, ctx) => { + try { + if (!ctx.portId) throw new ValidationError('No active port'); + const formData = await req.formData(); + const fileEntry = formData.get('file'); + if (!(fileEntry instanceof File)) throw new ValidationError('Missing `file` part'); + if (fileEntry.size === 0) throw new ValidationError('Empty file'); + if (fileEntry.size > MAX_RAW_BYTES) { + throw new ValidationError(`File exceeds ${MAX_RAW_BYTES / 1024 / 1024} MB`); + } + const crop = parseCrop(formData.get('crop')); + const buffer = Buffer.from(await fileEntry.arrayBuffer()); + const processed = await processLogoUpload(buffer, crop); + const result = await setPortLogo(ctx.portId, processed, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + const baseUrl = env.APP_URL.replace(/\/+$/, ''); + return NextResponse.json({ + data: { + fileId: result.fileId, + previewUrl: `${baseUrl}/api/v1/files/${result.fileId}/preview`, + warnings: result.warnings, + finalDimensions: processed.finalDimensions, + finalBytes: processed.finalBytes, + originalDimensions: processed.originalDimensions, + originalFormat: processed.originalFormat, + }, + }); + } catch (error) { + return errorResponse(error); + } + }), +); + +/** + * DELETE /api/v1/admin/branding/logo + * Clear the port logo. Subsequent PDF renders fall back to the port-name text header. + */ +export const DELETE = withAuth( + withPermission('admin', 'manage_settings', async (_req, ctx) => { + try { + if (!ctx.portId) throw new ValidationError('No active port'); + await clearPortLogo(ctx.portId, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/admin/branding/logo/sample-pdf/route.tsx b/src/app/api/v1/admin/branding/logo/sample-pdf/route.tsx new file mode 100644 index 00000000..e591d0cc --- /dev/null +++ b/src/app/api/v1/admin/branding/logo/sample-pdf/route.tsx @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server'; +import { eq } from 'drizzle-orm'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { db } from '@/lib/db'; +import { ports } from '@/lib/db/schema/ports'; +import { errorResponse, ValidationError } from '@/lib/errors'; +import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo'; +import { renderPdf } from '@/lib/pdf/render'; +import { BrandingSamplePdf } from '@/lib/pdf/templates/branding-sample'; + +/** + * GET /api/v1/admin/branding/logo/sample-pdf + * + * Renders a one-page PDF that exercises the brand-kit header, footer, and a + * couple of tables/charts. Used by the admin Branding UI's "Test with sample + * PDF" button so the admin can preview their logo in the actual report shell + * before generating real reports. + */ +export const GET = withAuth( + withPermission('admin', 'manage_settings', async (_req, ctx) => { + try { + if (!ctx.portId) throw new ValidationError('No active port'); + const port = await db.query.ports.findFirst({ where: eq(ports.id, ctx.portId) }); + if (!port) throw new ValidationError('Unknown port'); + const logo = await resolvePortLogo(port.id); + const bytes = await renderPdf( + , + ); + return new NextResponse(new Uint8Array(bytes), { + status: 200, + headers: { + 'content-type': 'application/pdf', + 'content-disposition': 'inline; filename="branding-sample.pdf"', + 'cache-control': 'no-store', + }, + }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/admin/branding/pdf-logo-uploader.tsx b/src/components/admin/branding/pdf-logo-uploader.tsx new file mode 100644 index 00000000..33ea0b65 --- /dev/null +++ b/src/components/admin/branding/pdf-logo-uploader.tsx @@ -0,0 +1,340 @@ +'use client'; + +import 'react-image-crop/dist/ReactCrop.css'; + +import { useEffect, useRef, useState } from 'react'; +import ReactCrop, { centerCrop, makeAspectCrop, type Crop, type PixelCrop } from 'react-image-crop'; +import { Loader2, RefreshCw, Trash2, Upload } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { toastError } from '@/lib/api/toast-error'; +import { toast } from 'sonner'; + +const ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml,image/heic,image/heif,image/avif'; + +type AspectMode = 'wide' | 'square' | 'freeform'; + +const ASPECT_RATIOS: Record = { + wide: 3, + square: 1, + freeform: undefined, +}; + +interface CurrentLogo { + fileId: string; + previewUrl: string; + sizeBytes: string | null; + mimeType: string | null; +} + +interface UploadResult { + fileId: string; + previewUrl: string; + warnings: string[]; + finalDimensions: { width: number; height: number }; + finalBytes: number; + originalDimensions: { width: number; height: number }; + originalFormat: string; +} + +const WARNING_LABELS: Record = { + trimmed: 'Auto-trimmed whitespace borders', + resized: 'Downscaled for size', + 'no-alpha': 'No transparent background — will show a white box on dark headers', + 'jpeg-source': 'JPEG source — prefer PNG with alpha for crisp rendering', + 'svg-rasterized': 'SVG rasterized to PNG at 300 DPI', + 'heic-converted': 'HEIC/HEIF converted to PNG', + 'webp-converted': 'WebP converted to PNG', +}; + +function centeredCrop(width: number, height: number, aspect: number): Crop { + return centerCrop(makeAspectCrop({ unit: '%', width: 90 }, aspect, width, height), width, height); +} + +export function PdfLogoUploader() { + const [current, setCurrent] = useState(null); + const [loading, setLoading] = useState(true); + const [working, setWorking] = useState(false); + const [file, setFile] = useState(null); + const [objectUrl, setObjectUrl] = useState(null); + const [crop, setCrop] = useState(); + const [completedCrop, setCompletedCrop] = useState(null); + const [aspectMode, setAspectMode] = useState('wide'); + const [warnings, setWarnings] = useState([]); + const [uploadInfo, setUploadInfo] = useState(null); + const imgRef = useRef(null); + const fileInputRef = useRef(null); + + useEffect(() => { + void refresh(); + }, []); + + useEffect(() => { + return () => { + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [objectUrl]); + + async function refresh() { + setLoading(true); + try { + const res = await fetch('/api/v1/admin/branding/logo'); + if (!res.ok) throw new Error(`GET ${res.status}`); + const body = (await res.json()) as { data: CurrentLogo | null }; + setCurrent(body.data ?? null); + } catch (err) { + toastError(err); + } finally { + setLoading(false); + } + } + + function pick(f: File | null) { + if (!f) return; + if (objectUrl) URL.revokeObjectURL(objectUrl); + const url = URL.createObjectURL(f); + setFile(f); + setObjectUrl(url); + setCrop(undefined); + setCompletedCrop(null); + setWarnings([]); + setUploadInfo(null); + } + + function onImageLoad(e: React.SyntheticEvent) { + const { naturalWidth: w, naturalHeight: h } = e.currentTarget; + const aspect = ASPECT_RATIOS[aspectMode]; + if (aspect) setCrop(centeredCrop(w, h, aspect)); + } + + function changeAspect(mode: AspectMode) { + setAspectMode(mode); + const img = imgRef.current; + const aspect = ASPECT_RATIOS[mode]; + if (img && aspect) { + setCrop(centeredCrop(img.naturalWidth, img.naturalHeight, aspect)); + } else { + setCrop(undefined); + } + } + + async function upload() { + if (!file) return; + setWorking(true); + try { + const fd = new FormData(); + fd.append('file', file); + if (completedCrop && completedCrop.width > 0 && completedCrop.height > 0) { + fd.append( + 'crop', + JSON.stringify({ + x: completedCrop.x, + y: completedCrop.y, + width: completedCrop.width, + height: completedCrop.height, + }), + ); + } + const res = await fetch('/api/v1/admin/branding/logo', { method: 'POST', body: fd }); + const body = (await res.json()) as { data: UploadResult } | { error?: { message?: string } }; + if (!res.ok) { + const message = ('error' in body && body.error?.message) || `Upload failed (${res.status})`; + throw new Error(message); + } + const data = (body as { data: UploadResult }).data; + setUploadInfo(data); + setWarnings(data.warnings ?? []); + toast.success('Logo saved'); + await refresh(); + // Clear the local crop staging so the preview now shows the stored logo. + setFile(null); + if (objectUrl) URL.revokeObjectURL(objectUrl); + setObjectUrl(null); + } catch (err) { + toastError(err); + } finally { + setWorking(false); + } + } + + async function clear() { + if (!confirm('Remove the PDF logo? Future reports will fall back to the port name.')) return; + setWorking(true); + try { + const res = await fetch('/api/v1/admin/branding/logo', { method: 'DELETE' }); + if (!res.ok && res.status !== 204) throw new Error(`DELETE ${res.status}`); + toast.success('Logo removed'); + setCurrent(null); + setUploadInfo(null); + setWarnings([]); + } catch (err) { + toastError(err); + } finally { + setWorking(false); + } + } + + function openSamplePdf() { + window.open('/api/v1/admin/branding/logo/sample-pdf', '_blank'); + } + + return ( + + + PDF logo + + Used as the header logo on every internal PDF the CRM generates (reports, expense sheets, + record exports). Separate from the email/UI logo so PDFs can have a higher-resolution, + alpha-channel version optimized for print. + + + +
+

Use PNG or SVG with a transparent background.

+

Minimum 200×200px; recommended 600×200px (wide) or 400×400px (square).

+

Max 5MB; we'll auto-trim, downscale, and convert to PNG.

+

Avoid JPEGs unless the background is solid white.

+
+ + {loading ? ( +
+ Loading current logo… +
+ ) : current ? ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Current PDF logo on dark header +
+
+
Current logo
+
+ {current.sizeBytes + ? `${(Number(current.sizeBytes) / 1024).toFixed(1)} KB` + : 'size unknown'}{' '} + · {current.mimeType ?? 'image'} +
+
+ + +
+
+
+ ) : ( +
+ No PDF logo configured yet. Reports use the port name as a text header. +
+ )} + +
+ + pick(e.target.files?.[0] ?? null)} + className="block w-full text-sm file:mr-3 file:rounded file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground" + /> +
+ + {objectUrl ? ( +
+
+ Crop: + {(['wide', 'square', 'freeform'] as const).map((m) => ( + + ))} +
+ setCrop(percent)} + onComplete={(c) => setCompletedCrop(c)} + aspect={ASPECT_RATIOS[aspectMode]} + className="max-h-[400px]" + > + {/* eslint-disable-next-line @next/next/no-img-element */} + Logo to crop + +
+ + + +
+
+ ) : null} + + {uploadInfo ? ( +
+
Processed
+
+ From {uploadInfo.originalFormat.toUpperCase()} {uploadInfo.originalDimensions.width}× + {uploadInfo.originalDimensions.height} + {' → '} + PNG {uploadInfo.finalDimensions.width}×{uploadInfo.finalDimensions.height} ( + {(uploadInfo.finalBytes / 1024).toFixed(1)} KB) +
+ {warnings.length > 0 ? ( +
    + {warnings.map((w) => ( +
  • {WARNING_LABELS[w] ?? w}
  • + ))} +
+ ) : null} +
+ ) : null} +
+
+ ); +} diff --git a/src/lib/audit.ts b/src/lib/audit.ts index 2a5b0b88..4a2b2a89 100644 --- a/src/lib/audit.ts +++ b/src/lib/audit.ts @@ -26,6 +26,9 @@ export type AuditAction = | 'view' | 'request_hard_delete_code' | 'hard_delete' + // Branding (port logo upload pipeline). + | 'branding.logo.uploaded' + | 'branding.logo.archived' // System / background events. | 'webhook_delivered' | 'webhook_failed' diff --git a/src/lib/pdf/templates/branding-sample.tsx b/src/lib/pdf/templates/branding-sample.tsx new file mode 100644 index 00000000..1d7ace08 --- /dev/null +++ b/src/lib/pdf/templates/branding-sample.tsx @@ -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 ( + +
+ +
+
+ +
+
+ +
+
+ + 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} + /> +
+
+ ); +} diff --git a/src/lib/services/logo.service.ts b/src/lib/services/logo.service.ts new file mode 100644 index 00000000..00115328 --- /dev/null +++ b/src/lib/services/logo.service.ts @@ -0,0 +1,328 @@ +/** + * Port-logo upload service. + * + * Layer 1 of the design (`docs/superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md`): + * sharp normalization + svgo sanitization + atomic system_settings upsert + + * audit logging. Single entry point for the admin upload endpoint and the + * branding-preview endpoint. Returns processed bytes + the file row, plus a + * collected list of warnings the UI surfaces in the preview swatch. + */ + +import { and, eq } from 'drizzle-orm'; +import sharp from 'sharp'; +import { optimize as svgoOptimize } from 'svgo'; + +import { db } from '@/lib/db'; +import { files } from '@/lib/db/schema/documents'; +import { systemSettings } from '@/lib/db/schema/system'; +import { ports } from '@/lib/db/schema/ports'; +import { createAuditLog, type AuditMeta } from '@/lib/audit'; +import { ValidationError } from '@/lib/errors'; +import { getStorageBackend } from '@/lib/storage'; +import { generateStorageKey } from '@/lib/services/storage'; +import { logger } from '@/lib/logger'; +import { PORT_LOGO_SETTING_KEY } from '@/lib/pdf/brand-kit/logo'; + +const MAX_RAW_BYTES = 5 * 1024 * 1024; +const MAX_FINAL_BYTES = 1 * 1024 * 1024; +const MIN_DIMENSION = 200; +const TARGET_LONG_EDGE = 1200; +const SVG_RASTER_DENSITY = 300; + +const SUPPORTED_INPUT_FORMATS = new Set([ + 'png', + 'jpeg', + 'jpg', + 'webp', + 'svg', + 'heic', + 'heif', + 'avif', +]); + +export type LogoWarning = + | 'trimmed' + | 'resized' + | 'no-alpha' + | 'jpeg-source' + | 'svg-rasterized' + | 'heic-converted' + | 'webp-converted'; + +export interface LogoCrop { + /** Crop coordinates in raw-image pixel units. */ + x: number; + y: number; + width: number; + height: number; +} + +export interface ProcessLogoResult { + pngBuffer: Buffer; + warnings: LogoWarning[]; + originalFormat: string; + originalDimensions: { width: number; height: number }; + finalDimensions: { width: number; height: number }; + finalBytes: number; +} + +function sanitizeSvg(rawSvg: string): string { + // preset-default strips + + `; + const buf = Buffer.from(svg, 'utf8'); + await expect(processLogoUpload(buf)).rejects.toThrow(/disallowed nodes/i); + }); + + it('rejects SVG with external href', async () => { + const svg = ` + + `; + const buf = Buffer.from(svg, 'utf8'); + await expect(processLogoUpload(buf)).rejects.toThrow(/disallowed nodes/i); + }); + + it('flags JPEG sources with no alpha', async () => { + const buf = await sharp({ + create: { width: 1200, height: 1200, channels: 3, background: { r: 200, g: 50, b: 50 } }, + }) + .jpeg({ quality: 80 }) + .toBuffer(); + const result = await processLogoUpload(buf); + expect(result.warnings).toContain('jpeg-source'); + expect(result.warnings).toContain('no-alpha'); + }); +}); diff --git a/tests/unit/pdf-brand-kit.test.tsx b/tests/unit/pdf-brand-kit.test.tsx index 8c864267..6777ee50 100644 --- a/tests/unit/pdf-brand-kit.test.tsx +++ b/tests/unit/pdf-brand-kit.test.tsx @@ -67,14 +67,10 @@ describe('pdf brand kit', () => {
- columns={[ - { header: 'Name', render: (r: { name: string }) => r.name }, - { - header: 'Score', - align: 'right', - render: (r: { score: number }) => String(r.score), - }, + { header: 'Name', render: (r) => r.name }, + { header: 'Score', align: 'right', render: (r) => String(r.score) }, ]} rows={[ { name: 'Alpha', score: 1 },