diff --git a/src/app/(dashboard)/[portSlug]/admin/brochures/page.tsx b/src/app/(dashboard)/[portSlug]/admin/brochures/page.tsx new file mode 100644 index 0000000..03ef6f5 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/brochures/page.tsx @@ -0,0 +1,21 @@ +import { PageHeader } from '@/components/shared/page-header'; +import { BrochuresAdminPanel } from '@/components/admin/brochures-admin-panel'; + +/** + * Per-port admin page for managing brochures (Phase 7 §5.8). + * + * Lists brochures, lets per-port admins upload new versions via direct-to- + * storage presigned URLs (so the 20MB+ file never traverses Next.js's + * body-size limit — see §11.1), and toggle the default flag. + */ +export default function BrochuresAdminPage() { + return ( +
+ + +
+ ); +} diff --git a/src/app/(dashboard)/[portSlug]/admin/email/page.tsx b/src/app/(dashboard)/[portSlug]/admin/email/page.tsx index 4477d4b..8362687 100644 --- a/src/app/(dashboard)/[portSlug]/admin/email/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/email/page.tsx @@ -3,6 +3,7 @@ import { type SettingFieldDef, } from '@/components/admin/shared/settings-form-card'; import { PageHeader } from '@/components/shared/page-header'; +import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card'; const FIELDS: SettingFieldDef[] = [ { @@ -94,6 +95,7 @@ export default function EmailSettingsPage() { description="Optional per-port SMTP credentials. Leave blank to use the global env defaults." fields={FIELDS.slice(5)} /> + ); } diff --git a/src/app/api/v1/admin/brochures/[id]/route.ts b/src/app/api/v1/admin/brochures/[id]/route.ts new file mode 100644 index 0000000..a650d10 --- /dev/null +++ b/src/app/api/v1/admin/brochures/[id]/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { archiveBrochure, getBrochure, updateBrochure } from '@/lib/services/brochures.service'; +import { updateBrochureSchema } from '@/lib/validators/brochures'; + +export const GET = withAuth( + withPermission('admin', 'manage_settings', async (_req, ctx, params) => { + try { + const id = params.id!; + const data = await getBrochure(ctx.portId, id); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const PATCH = withAuth( + withPermission('admin', 'manage_settings', async (req, ctx, params) => { + try { + const id = params.id!; + const input = await parseBody(req, updateBrochureSchema); + const data = await updateBrochure(ctx.portId, id, input); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const DELETE = withAuth( + withPermission('admin', 'manage_settings', async (_req, ctx, params) => { + try { + const id = params.id!; + await archiveBrochure(ctx.portId, id); + return NextResponse.json({ success: true }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/admin/brochures/[id]/versions/route.ts b/src/app/api/v1/admin/brochures/[id]/versions/route.ts new file mode 100644 index 0000000..a58224d --- /dev/null +++ b/src/app/api/v1/admin/brochures/[id]/versions/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { + generateBrochureStorageKey, + registerBrochureVersion, +} from '@/lib/services/brochures.service'; +import { registerBrochureVersionSchema } from '@/lib/validators/brochures'; + +/** + * Two-step upload (per §11.1): + * 1. GET (no body) — server returns a fresh storage key + presigned URL. + * 2. POST (metadata) — after the browser PUTs to the URL, register the + * version row server-side. + * + * Direct-to-storage uploads bypass Next.js's body-size limit; the server + * never holds the 20MB+ payload in memory. + */ +import { getStorageBackend } from '@/lib/storage'; +import { getSalesContentConfig } from '@/lib/services/sales-email-config.service'; + +export const GET = withAuth( + withPermission('admin', 'manage_settings', async (_req, ctx, params) => { + try { + const id = params.id!; + const content = await getSalesContentConfig(ctx.portId); + const storageKey = await generateBrochureStorageKey(ctx.portId, id); + const storage = await getStorageBackend(); + const { url } = await storage.presignUpload(storageKey, { + expirySeconds: 900, + contentType: 'application/pdf', + }); + return NextResponse.json({ + data: { + storageKey, + uploadUrl: url, + method: 'PUT', + maxBytes: content.brochureMaxUploadMb * 1024 * 1024, + }, + }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const POST = withAuth( + withPermission('admin', 'manage_settings', async (req, ctx, params) => { + try { + const id = params.id!; + const input = await parseBody(req, registerBrochureVersionSchema); + const data = await registerBrochureVersion({ + portId: ctx.portId, + brochureId: id, + storageKey: input.storageKey, + fileName: input.fileName, + fileSizeBytes: input.fileSizeBytes, + contentSha256: input.contentSha256, + uploadedBy: ctx.userId, + }); + return NextResponse.json({ data }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/admin/brochures/route.ts b/src/app/api/v1/admin/brochures/route.ts new file mode 100644 index 0000000..53c3562 --- /dev/null +++ b/src/app/api/v1/admin/brochures/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { createBrochure, listBrochures } from '@/lib/services/brochures.service'; +import { createBrochureSchema } from '@/lib/validators/brochures'; + +export const GET = withAuth( + withPermission('admin', 'manage_settings', async (_req, ctx) => { + try { + const data = await listBrochures(ctx.portId, { includeArchived: true }); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const POST = withAuth( + withPermission('admin', 'manage_settings', async (req, ctx) => { + try { + const input = await parseBody(req, createBrochureSchema); + const data = await createBrochure({ + portId: ctx.portId, + label: input.label, + description: input.description ?? null, + isDefault: input.isDefault, + createdBy: ctx.userId, + }); + return NextResponse.json({ data }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/admin/email/sales-config/route.ts b/src/app/api/v1/admin/email/sales-config/route.ts new file mode 100644 index 0000000..45176be --- /dev/null +++ b/src/app/api/v1/admin/email/sales-config/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { + getSalesEmailConfig, + getSalesImapConfig, + getSalesContentConfig, + redactSalesConfigForResponse, + updateSalesEmailConfig, +} from '@/lib/services/sales-email-config.service'; +import { updateSalesEmailConfigSchema } from '@/lib/validators/sales-email-config'; + +/** + * GET /api/v1/admin/email/sales-config + * + * Returns the redacted view of the sales-email config. Per §14.10 + * "Permission escalation: who configures SMTP credentials?", reps cannot + * see the decrypted password — the response only carries `*PassIsSet` + * boolean markers. + */ +export const GET = withAuth( + withPermission('admin', 'manage_settings', async (_req, ctx) => { + try { + const [email, imap, content] = await Promise.all([ + getSalesEmailConfig(ctx.portId), + getSalesImapConfig(ctx.portId), + getSalesContentConfig(ctx.portId), + ]); + const redacted = redactSalesConfigForResponse(email, imap, content); + return NextResponse.json({ data: redacted }); + } catch (error) { + return errorResponse(error); + } + }), +); + +/** + * PATCH /api/v1/admin/email/sales-config + * + * Per-port admin only. Encrypts SMTP/IMAP passwords via AES-256-GCM before + * storage; the API never returns decrypted secrets (mirror enforcement on + * the GET handler). + */ +export const PATCH = withAuth( + withPermission('admin', 'manage_settings', async (req, ctx) => { + try { + const input = await parseBody(req, updateSalesEmailConfigSchema); + await updateSalesEmailConfig(ctx.portId, input, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + // Return the freshly-redacted view so the UI can re-render. + const [email, imap, content] = await Promise.all([ + getSalesEmailConfig(ctx.portId), + getSalesImapConfig(ctx.portId), + getSalesContentConfig(ctx.portId), + ]); + return NextResponse.json({ data: redactSalesConfigForResponse(email, imap, content) }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/document-sends/berth-pdf/route.ts b/src/app/api/v1/document-sends/berth-pdf/route.ts new file mode 100644 index 0000000..5706313 --- /dev/null +++ b/src/app/api/v1/document-sends/berth-pdf/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; + +import { withAuth } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { sendBerthPdf } from '@/lib/services/document-sends.service'; +import { sendBerthPdfSchema } from '@/lib/validators/document-sends'; + +/** + * POST /api/v1/document-sends/berth-pdf + * + * Sends the active per-berth PDF version to a client recipient. The body + * markdown goes through the merge-field expander + sanitizer + * (`renderEmailBody`) before reaching nodemailer (§14.7 critical mitigation: + * body XSS). + */ +export const POST = withAuth(async (req, ctx) => { + try { + const input = await parseBody(req, sendBerthPdfSchema); + const result = await sendBerthPdf({ + portId: ctx.portId, + berthId: input.berthId, + recipient: input.recipient, + customBodyMarkdown: input.customBodyMarkdown, + sentBy: ctx.userId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: result }); + } catch (error) { + return errorResponse(error); + } +}); diff --git a/src/app/api/v1/document-sends/brochure/route.ts b/src/app/api/v1/document-sends/brochure/route.ts new file mode 100644 index 0000000..8e74f57 --- /dev/null +++ b/src/app/api/v1/document-sends/brochure/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; + +import { withAuth } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { sendBrochure } from '@/lib/services/document-sends.service'; +import { sendBrochureSchema } from '@/lib/validators/document-sends'; + +/** + * POST /api/v1/document-sends/brochure + * + * Sends a brochure (default or specified) to a client recipient. Same + * sanitization + audit-row pipeline as the berth-pdf endpoint. + */ +export const POST = withAuth(async (req, ctx) => { + try { + const input = await parseBody(req, sendBrochureSchema); + const result = await sendBrochure({ + portId: ctx.portId, + brochureId: input.brochureId, + recipient: input.recipient, + customBodyMarkdown: input.customBodyMarkdown, + sentBy: ctx.userId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: result }); + } catch (error) { + return errorResponse(error); + } +}); diff --git a/src/app/api/v1/document-sends/preview/route.ts b/src/app/api/v1/document-sends/preview/route.ts new file mode 100644 index 0000000..6b714b3 --- /dev/null +++ b/src/app/api/v1/document-sends/preview/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; + +import { withAuth } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { previewBody } from '@/lib/services/document-sends.service'; +import { previewBodySchema } from '@/lib/validators/document-sends'; + +/** + * POST /api/v1/document-sends/preview + * + * Renders a body for the dry-run UI without actually sending. Returns the + * sanitized HTML, the post-merge markdown, and the list of unresolved + * `{{tokens}}` so the UI can block submit until the rep fills them in + * (§14.7 mitigation). + */ +export const POST = withAuth(async (req, ctx) => { + try { + const input = await parseBody(req, previewBodySchema); + const result = await previewBody( + ctx.portId, + input.documentKind, + input.recipient, + input.customBodyMarkdown ?? null, + { berthId: input.berthId, brochureLabel: input.brochureId }, + ); + return NextResponse.json({ data: result }); + } catch (error) { + return errorResponse(error); + } +}); diff --git a/src/app/api/v1/document-sends/route.ts b/src/app/api/v1/document-sends/route.ts new file mode 100644 index 0000000..5b6f1a4 --- /dev/null +++ b/src/app/api/v1/document-sends/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; + +import { withAuth } from '@/lib/api/helpers'; +import { parseQuery } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { listSends } from '@/lib/services/document-sends.service'; +import { listSendsQuerySchema } from '@/lib/validators/document-sends'; + +export const GET = withAuth(async (req, ctx) => { + try { + const query = parseQuery(req, listSendsQuerySchema); + const data = await listSends({ + portId: ctx.portId, + clientId: query.clientId, + interestId: query.interestId, + berthId: query.berthId, + limit: query.limit, + }); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } +}); diff --git a/src/components/admin/brochures-admin-panel.tsx b/src/components/admin/brochures-admin-panel.tsx new file mode 100644 index 0000000..e6c64c2 --- /dev/null +++ b/src/components/admin/brochures-admin-panel.tsx @@ -0,0 +1,345 @@ +'use client'; + +/** + * Brochures admin panel (Phase 7 §5.8). + * + * Lists every brochure for the port (including archived). Lets a + * `manage_settings` admin: + * - Create new brochures. + * - Upload a new version (direct-to-storage presigned PUT, see §11.1). + * - Mark default / archive. + */ +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Archive, FileText, Loader2, Plus, Star, Upload } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Switch } from '@/components/ui/switch'; +import { apiFetch } from '@/lib/api/client'; + +interface BrochureRow { + id: string; + label: string; + description: string | null; + isDefault: boolean; + archivedAt: string | null; + versionCount: number; + currentVersion: { + id: string; + fileName: string; + fileSizeBytes: number; + uploadedAt: string; + } | null; +} + +interface BrochuresResponse { + data: BrochureRow[]; +} + +interface UploadGrantResponse { + data: { storageKey: string; uploadUrl: string; method: 'PUT'; maxBytes: number }; +} + +export function BrochuresAdminPanel() { + const queryClient = useQueryClient(); + const [createOpen, setCreateOpen] = useState(false); + + const brochuresQuery = useQuery({ + queryKey: ['brochures', 'admin'], + queryFn: () => apiFetch('/api/v1/admin/brochures'), + }); + + const rows = brochuresQuery.data?.data ?? []; + + return ( +
+
+ +
+ + {brochuresQuery.isLoading && ( +
+ Loading… +
+ )} + + {!brochuresQuery.isLoading && rows.length === 0 && ( + + + No brochures yet. Click “New brochure” to add one. + + + )} + +
+ {rows.map((b) => ( + { + void queryClient.invalidateQueries({ queryKey: ['brochures', 'admin'] }); + void queryClient.invalidateQueries({ queryKey: ['brochures', 'list'] }); + }} + /> + ))} +
+ + { + void queryClient.invalidateQueries({ queryKey: ['brochures', 'admin'] }); + }} + /> +
+ ); +} + +function BrochureCard({ brochure, onChange }: { brochure: BrochureRow; onChange: () => void }) { + const [uploading, setUploading] = useState(false); + + const setDefaultMutation = useMutation({ + mutationFn: () => + apiFetch(`/api/v1/admin/brochures/${brochure.id}`, { + method: 'PATCH', + body: { isDefault: true }, + }), + onSuccess: () => { + toast.success('Default brochure updated'); + onChange(); + }, + }); + + const archiveMutation = useMutation({ + mutationFn: () => apiFetch(`/api/v1/admin/brochures/${brochure.id}`, { method: 'DELETE' }), + onSuccess: () => { + toast.success('Brochure archived'); + onChange(); + }, + }); + + async function handleUpload(file: File) { + setUploading(true); + try { + const grant: UploadGrantResponse = await apiFetch( + `/api/v1/admin/brochures/${brochure.id}/versions`, + ); + if (file.size > grant.data.maxBytes) { + throw new Error( + `File is too large. Max is ${(grant.data.maxBytes / 1024 / 1024).toFixed(0)}MB.`, + ); + } + // Direct-to-storage PUT (§11.1). + const putRes = await fetch(grant.data.uploadUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': 'application/pdf' }, + }); + if (!putRes.ok) throw new Error(`Upload failed: ${putRes.status}`); + + const sha = await sha256Hex(file); + await apiFetch(`/api/v1/admin/brochures/${brochure.id}/versions`, { + method: 'POST', + body: { + storageKey: grant.data.storageKey, + fileName: file.name, + fileSizeBytes: file.size, + contentSha256: sha, + }, + }); + toast.success('New version uploaded'); + onChange(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Upload failed'); + } finally { + setUploading(false); + } + } + + return ( + + + + + {brochure.label} + {brochure.isDefault && ( + + default + + )} + {brochure.archivedAt && ( + + archived + + )} + + {brochure.versionCount} versions + + + + {brochure.description && ( +

{brochure.description}

+ )} + {brochure.currentVersion && ( +

+ Latest: {brochure.currentVersion.fileName} ( + {(brochure.currentVersion.fileSizeBytes / 1024 / 1024).toFixed(2)} MB,{' '} + {new Date(brochure.currentVersion.uploadedAt).toLocaleDateString()}) +

+ )} +
+ {!brochure.archivedAt && ( + <> + + {!brochure.isDefault && ( + + )} + + + )} +
+
+
+ ); +} + +function CreateBrochureDialog({ + open, + onOpenChange, + onCreated, +}: { + open: boolean; + onOpenChange: (o: boolean) => void; + onCreated: () => void; +}) { + const [label, setLabel] = useState(''); + const [description, setDescription] = useState(''); + const [isDefault, setIsDefault] = useState(false); + + const createMutation = useMutation({ + mutationFn: () => + apiFetch('/api/v1/admin/brochures', { + method: 'POST', + body: { + label, + description: description || null, + isDefault, + }, + }), + onSuccess: () => { + toast.success('Brochure created. Upload a version next.'); + setLabel(''); + setDescription(''); + setIsDefault(false); + onCreated(); + onOpenChange(false); + }, + }); + + return ( + + + + New brochure + + Create the brochure container, then upload a PDF version on the card that appears. + + +
+
+ + setLabel(e.target.value)} + placeholder="General overview" + /> +
+
+ +