/** * Brochures + brochure-versions service (Phase 7 — see plan §3.3 / §4.7). * * Brochures are port-wide marketing PDFs (the sample `Port-Nimara-Brochure-March-2025` * is 10.26 MB). Each `brochures` row groups a logical brochure (e.g. * "Investor Pack"); each `brochure_versions` row is an immutable upload tied * to that brochure. The default brochure is the one the send-out flow picks * when the rep doesn't pick explicitly (§14.7). * * Storage goes through `getStorageBackend()` (Phase 6a) — never minio * directly. The version row's `storageKey` follows the §4.7a convention. */ import { and, asc, desc, eq, isNull } from 'drizzle-orm'; import { db } from '@/lib/db'; import { brochures, brochureVersions, ports } from '@/lib/db/schema'; import type { Brochure, BrochureVersion } from '@/lib/db/schema'; import { CodedError, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; import { getStorageBackend } from '@/lib/storage'; import { buildStoragePath } from '@/lib/minio'; import { logger } from '@/lib/logger'; // ─── Types ─────────────────────────────────────────────────────────────────── export interface BrochureWithCurrentVersion extends Brochure { currentVersion: BrochureVersion | null; versionCount: number; } // ─── Internal helpers ──────────────────────────────────────────────────────── async function loadPortSlug(portId: string): Promise { const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); if (!port) throw new NotFoundError('Port'); return port.slug; } // ─── List ──────────────────────────────────────────────────────────────────── /** * List all brochures for a port. By default returns only non-archived rows; * pass `{ includeArchived: true }` for the admin manage page. */ export async function listBrochures( portId: string, opts: { includeArchived?: boolean } = {}, ): Promise { const baseRows = await db.query.brochures.findMany({ where: opts.includeArchived ? eq(brochures.portId, portId) : and(eq(brochures.portId, portId), isNull(brochures.archivedAt)), orderBy: [desc(brochures.isDefault), asc(brochures.label)], }); if (baseRows.length === 0) return []; const ids = baseRows.map((r) => r.id); // One round-trip fetches every version for the page, ordered newest-first // so the per-row `currentVersion` lookup below is just `[0]`. const allVersions = await db.query.brochureVersions.findMany({ where: (bv, { inArray }) => inArray(bv.brochureId, ids), orderBy: [desc(brochureVersions.uploadedAt)], }); return baseRows.map((row) => { const versionsForRow = allVersions.filter((v) => v.brochureId === row.id); versionsForRow.sort( (a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime(), ); return { ...row, currentVersion: versionsForRow[0] ?? null, versionCount: versionsForRow.length, }; }); } export async function getBrochure( portId: string, brochureId: string, ): Promise { const row = await db.query.brochures.findFirst({ where: and(eq(brochures.id, brochureId), eq(brochures.portId, portId)), }); if (!row) throw new NotFoundError('Brochure'); const versions = await db.query.brochureVersions.findMany({ where: eq(brochureVersions.brochureId, brochureId), orderBy: [desc(brochureVersions.uploadedAt)], }); return { ...row, currentVersion: versions[0] ?? null, versionCount: versions.length }; } /** * Resolve the brochure that the send-out flow should default to. Returns the * default brochure when one exists and is non-archived; falls back to the * most recently created non-archived brochure with a version; null when * the port has no usable brochures (the send UI hides the button — §14.7). */ export async function getDefaultBrochure( portId: string, ): Promise { const all = await listBrochures(portId, { includeArchived: false }); const usable = all.filter((b) => b.currentVersion !== null); if (usable.length === 0) return null; const flaggedDefault = usable.find((b) => b.isDefault); if (flaggedDefault) return flaggedDefault; return usable[0]!; } // ─── Mutations ─────────────────────────────────────────────────────────────── export interface CreateBrochureInput { portId: string; label: string; description?: string | null; isDefault?: boolean; createdBy: string; } export async function createBrochure(input: CreateBrochureInput): Promise { if (!input.label.trim()) throw new ValidationError('Brochure label is required'); // If this is being created as default, clear any existing default first // so we maintain the invariant: at most one default per port. return db.transaction(async (tx) => { if (input.isDefault) { await tx .update(brochures) .set({ isDefault: false }) .where(and(eq(brochures.portId, input.portId), eq(brochures.isDefault, true))); } const [row] = await tx .insert(brochures) .values({ portId: input.portId, label: input.label.trim(), description: input.description ?? null, isDefault: input.isDefault ?? false, createdBy: input.createdBy, }) .returning(); if (!row) throw new CodedError('INSERT_RETURNING_EMPTY', { internalMessage: 'Failed to create brochure', }); return row; }); } export interface UpdateBrochureInput { label?: string; description?: string | null; isDefault?: boolean; } export async function updateBrochure( portId: string, brochureId: string, patch: UpdateBrochureInput, ): Promise { const existing = await db.query.brochures.findFirst({ where: and(eq(brochures.id, brochureId), eq(brochures.portId, portId)), }); if (!existing) throw new NotFoundError('Brochure'); return db.transaction(async (tx) => { if (patch.isDefault === true) { await tx .update(brochures) .set({ isDefault: false }) .where(and(eq(brochures.portId, portId), eq(brochures.isDefault, true))); } const updates: Partial = {}; if (patch.label !== undefined) updates.label = patch.label.trim(); if (patch.description !== undefined) updates.description = patch.description; if (patch.isDefault !== undefined) updates.isDefault = patch.isDefault; const [row] = await tx .update(brochures) .set(updates) .where(and(eq(brochures.id, brochureId), eq(brochures.portId, portId))) .returning(); if (!row) throw new CodedError('INSERT_RETURNING_EMPTY', { internalMessage: 'Failed to update brochure', }); return row; }); } export async function archiveBrochure(portId: string, brochureId: string): Promise { const existing = await db.query.brochures.findFirst({ where: and(eq(brochures.id, brochureId), eq(brochures.portId, portId)), }); if (!existing) throw new NotFoundError('Brochure'); await db .update(brochures) .set({ archivedAt: new Date(), isDefault: false }) .where(and(eq(brochures.id, brochureId), eq(brochures.portId, portId))); } // ─── Versions ──────────────────────────────────────────────────────────────── export interface RegisterBrochureVersionInput { portId: string; brochureId: string; storageKey: string; fileName: string; fileSizeBytes: number; contentSha256: string; uploadedBy: string; } /** * After a presigned upload completes, the browser POSTs the metadata back * here. We HEAD the storage key to verify the file exists at the claimed * size + content-type (per §11.1 "Server-side validation"), then write the * version row + bump version number. */ export async function registerBrochureVersion( input: RegisterBrochureVersionInput, ): Promise { const brochure = await db.query.brochures.findFirst({ where: and(eq(brochures.id, input.brochureId), eq(brochures.portId, input.portId)), }); if (!brochure) throw new NotFoundError('Brochure'); if (brochure.archivedAt) { throw new ForbiddenError('Cannot upload a version to an archived brochure'); } const storage = await getStorageBackend(); const head = await storage.head(input.storageKey); if (!head) throw new ValidationError('Uploaded object not found in storage'); if (head.sizeBytes !== input.fileSizeBytes) { logger.warn( { expected: input.fileSizeBytes, actual: head.sizeBytes, key: input.storageKey }, 'Brochure upload size mismatch', ); throw new ValidationError('Uploaded object size does not match metadata'); } // Magic-byte check (§14.6 critical) - the presign path doesn't see the // bytes until upload completes. Read the first 5 bytes; abort + delete // on mismatch so a malicious uploader can't smuggle a non-PDF that the // CRM would later email as `application/pdf`. const stream = await storage.get(input.storageKey); const chunks: Buffer[] = []; let total = 0; for await (const chunk of stream as AsyncIterable) { const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; chunks.push(buf); total += buf.length; if (total >= 5) break; } if (typeof (stream as { destroy?: () => void }).destroy === 'function') { (stream as unknown as { destroy: () => void }).destroy(); } const probe = Buffer.concat(chunks).subarray(0, 5); if (probe.length < 5 || probe.toString('utf8', 0, 5) !== '%PDF-') { await storage.delete(input.storageKey).catch(() => undefined); throw new ValidationError( 'Uploaded file failed PDF magic-byte check (does not start with %PDF-).', ); } // Determine the next version number for this brochure. const existing = await db.query.brochureVersions.findMany({ where: eq(brochureVersions.brochureId, input.brochureId), orderBy: [desc(brochureVersions.versionNumber)], limit: 1, }); const nextVersion = (existing[0]?.versionNumber ?? 0) + 1; const [row] = await db .insert(brochureVersions) .values({ brochureId: input.brochureId, versionNumber: nextVersion, storageKey: input.storageKey, fileName: input.fileName, fileSizeBytes: input.fileSizeBytes, contentSha256: input.contentSha256, uploadedBy: input.uploadedBy, }) .returning(); if (!row) throw new CodedError('INSERT_RETURNING_EMPTY', { internalMessage: 'Failed to record brochure version', }); return row; } /** * Generate a storage key the client should PUT to. Caller hands the returned * key + URL to the browser; after upload the browser calls * `registerBrochureVersion` with the same key. */ export async function generateBrochureStorageKey( portId: string, brochureId: string, ): Promise { const portSlug = await loadPortSlug(portId); const fileId = crypto.randomUUID(); return buildStoragePath(portSlug, 'brochures', brochureId, fileId, 'pdf'); }