Files
pn-new-crm/competing-plans/blessed/L4-ADVANCED.md
Matt 67d7e6e3d5
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00

78 KiB
Raw Permalink Blame History

Layer 4: Advanced Features — Competing Plan (Claude Code)

Scope: Outbound webhooks, scheduled reports, data import/export, custom fields, AI features (berth spec import, recommendation refinement), UX polish (dark mode, mobile responsive, scratchpad, archiving, multi-currency, tags), system monitoring & admin tools, parent company export.

Duration: 7 days across 4 parallel streams

Depends on: L0 (foundation), L1 (CRUD), L2 (business workflows), L3 (operations)


1. Baseline Critique

What's Good

  1. Webhook delivery engine design — HMAC-SHA256 signing, exponential backoff, dead letter queue. Solid pattern.
  2. CSV import wizard UX — 5-step flow (upload → map → preview → configure → execute) is the right approach.
  3. Custom field dynamic validation — Type-switch validator pattern is clean and extensible.
  4. Recommendation engine scoring factors — 7 weighted factors for berth matching is comprehensive.

What Needs Fixing

  1. Wrong route paths (again) — Every UI reference uses src/app/(crm)/admin/... instead of src/app/(dashboard)/[portSlug]/admin/.... Port-scoped routes require the [portSlug] dynamic segment — this is the same error across all baseline layers.

  2. Wrong component paths — Uses src/components/domain/{entity}/ throughout. The project structure is flat: src/components/{entity}/. No domain/ subdirectory.

  3. Dark mode colors don't match design tokens — Baseline invents its own dark mode palette (#0f1729, #1a2340, #243050) instead of using the tokens from 15-DESIGN-TOKENS.md §2.2 (#131a2c, #192239, #1e2844). Design tokens are the source of truth — don't reinvent them.

  4. S3 file migration tool doesn't belong in L4 — This is a one-time NocoDB→PostgreSQL migration operation. It belongs in L6 (Migration), not in the ongoing CRM feature set. Including it in L4 conflates migration tooling with production features.

  5. System monitoring entirely omitted — Master Feature Spec §21 defines: alert monitoring dashboard, admin notifications with configurable thresholds, automated backups with admin UI. Database schema has the tables. API catalog has the endpoints (§28). Baseline ignores all of it.

  6. Client portal not addressed — Master Feature Spec §22 says "Build but deprioritize. Include in V1." The baseline doesn't scope it at all — not even a stub or deferred plan.

  7. Unrealistic 4-day timeline — 5 parallel streams each running 4 days = 20 developer-days of work for a single developer. Phase 2 has one coding agent. Even with overlap, 4 days for this scope is fiction.

  8. No @pdfme for report PDF generation — Baseline mentions "generate PDF" for scheduled reports but never specifies the library. 14-TECHNICAL-DECISIONS.md locks @pdfme as the PDF generation tool. Reports must use it.

  9. Missing audit logging in admin services — Webhook CRUD, custom field CRUD, scheduled report CRUD, system settings changes — none of these write to audit_logs in the baseline.

  10. Webhook secret stored in plaintext — Schema has secret TEXT on webhooks table. Baseline stores HMAC secrets as plaintext. These should be encrypted with AES-256-GCM (same as email credentials) since they're signing secrets per SECURITY-GUIDELINES.md §4.1.

  11. Missing next_run_at calculation — Schema has next_run_at on scheduled_reports but baseline never shows how it's calculated from the cron expression. Need a cron parser library.

  12. exceljs not in tech decisions — Baseline uses exceljs for Excel export but 14-TECHNICAL-DECISIONS.md doesn't list it. Need to either justify adding it or use an alternative.


2. Implementation Plan

Stream A: Webhooks & Scheduled Reports (Days 13)

Day 1: Webhook CRUD + Admin UI

Service: src/lib/services/webhooks.ts

import { z } from 'zod';

/** All webhook-subscribable events, matching real-time event catalog */
export const WEBHOOK_EVENTS = [
  'client.created',
  'client.updated',
  'client.archived',
  'client.merged',
  'interest.created',
  'interest.stageChanged',
  'interest.berthLinked',
  'berth.statusChanged',
  'berth.updated',
  'document.sent',
  'document.signed',
  'document.completed',
  'document.expired',
  'expense.created',
  'expense.updated',
  'invoice.created',
  'invoice.sent',
  'invoice.paid',
  'invoice.overdue',
  'registration.new',
] as const;

export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number];

/**
 * Create a new webhook configuration.
 * @param portId - Port scope
 * @param userId - Creating user for audit
 * @param data - Webhook configuration
 * @returns Created webhook (secret excluded from response)
 * @throws ValidationError if URL invalid or events empty
 */
export async function createWebhook(
  portId: string,
  userId: string,
  data: CreateWebhookInput,
): Promise<WebhookResponse>;

/**
 * List all webhooks for a port.
 * @param portId - Port scope
 * @returns Array of webhooks (secrets masked)
 */
export async function listWebhooks(portId: string): Promise<WebhookResponse[]>;

/**
 * Get webhook detail by ID.
 * @param portId - Port scope
 * @param webhookId - Webhook ID
 * @returns Webhook detail (secret masked)
 * @throws NotFoundError if webhook doesn't exist in port
 */
export async function getWebhook(portId: string, webhookId: string): Promise<WebhookResponse>;

/**
 * Update webhook configuration.
 * @param portId - Port scope
 * @param webhookId - Webhook ID
 * @param userId - Updating user for audit
 * @param data - Fields to update
 * @returns Updated webhook
 * @throws NotFoundError if webhook doesn't exist in port
 */
export async function updateWebhook(
  portId: string,
  webhookId: string,
  userId: string,
  data: UpdateWebhookInput,
): Promise<WebhookResponse>;

/**
 * Delete webhook and all delivery history.
 * @param portId - Port scope
 * @param webhookId - Webhook ID
 * @param userId - Deleting user for audit
 * @throws NotFoundError if webhook doesn't exist in port
 */
export async function deleteWebhook(
  portId: string,
  webhookId: string,
  userId: string,
): Promise<void>;

/**
 * Regenerate the HMAC signing secret for a webhook.
 * @returns New secret (shown once, then masked)
 */
export async function regenerateSecret(
  portId: string,
  webhookId: string,
  userId: string,
): Promise<{ secret: string }>;

/**
 * List delivery log entries for a webhook.
 * @param portId - Port scope
 * @param webhookId - Webhook ID
 * @param pagination - Page, limit, date range filter
 * @returns Paginated delivery records
 */
export async function listDeliveries(
  portId: string,
  webhookId: string,
  pagination: PaginationInput & { from?: Date; to?: Date },
): Promise<PaginatedResponse<WebhookDeliveryResponse>>;

/**
 * Send a test webhook payload simulating a specific event.
 * @param portId - Port scope
 * @param webhookId - Webhook ID
 * @param eventType - Which event to simulate
 * @returns Delivery result (status code, response time)
 */
export async function sendTestWebhook(
  portId: string,
  webhookId: string,
  eventType: WebhookEvent,
): Promise<{ statusCode: number; responseTimeMs: number; deliveryId: string }>;

Secret handling:

  • On creation, generate 32-byte random secret → encrypt with AES-256-GCM using WEBHOOK_SECRET_KEY env var → store ciphertext in webhooks.secret
  • Display plaintext secret once on creation (modal with copy button + "This secret won't be shown again" warning)
  • On API response, mask secret: "wh_sk_...abc" (first 5 + last 3 chars)
  • regenerateSecret() generates new secret, encrypts, replaces old

Zod schemas: src/lib/validators/webhooks.ts

export const createWebhookSchema = z.object({
  name: z.string().min(1).max(100),
  url: z.string().url().max(2048),
  events: z.array(z.enum(WEBHOOK_EVENTS)).min(1).max(WEBHOOK_EVENTS.length),
  is_active: z.boolean().default(true),
});

export const updateWebhookSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  url: z.string().url().max(2048).optional(),
  events: z.array(z.enum(WEBHOOK_EVENTS)).min(1).optional(),
  is_active: z.boolean().optional(),
});

API routes:

Method Path Handler
GET /api/admin/webhooks listWebhooks(portId)
POST /api/admin/webhooks createWebhook(portId, userId, body)
GET /api/admin/webhooks/[id] getWebhook(portId, id)
PATCH /api/admin/webhooks/[id] updateWebhook(portId, id, userId, body)
DELETE /api/admin/webhooks/[id] deleteWebhook(portId, id, userId)
GET /api/admin/webhooks/[id]/deliveries listDeliveries(portId, id, query)
POST /api/admin/webhooks/[id]/test sendTestWebhook(portId, id, body.eventType)

Middleware chain: withAuth → withPermission('webhooks', 'manage') → handler

All routes at src/app/api/admin/webhooks/route.ts and src/app/api/admin/webhooks/[id]/route.ts, src/app/api/admin/webhooks/[id]/deliveries/route.ts, src/app/api/admin/webhooks/[id]/test/route.ts.

Admin UI:

src/app/(dashboard)/[portSlug]/admin/webhooks/page.tsx:

  • Table: name, URL (truncated), events count, status (active/inactive badge), last delivery status
  • "Create webhook" button → sheet/dialog
  • Row click → detail page

src/app/(dashboard)/[portSlug]/admin/webhooks/[id]/page.tsx:

  • Webhook detail with edit form
  • Event type checkboxes (grouped by entity: Client, Interest, Berth, Document, Financial, System)
  • Delivery log table below: timestamp, event type, status badge (success/failed/pending), response code, response time
  • "Send test" button with event type dropdown
  • "Regenerate secret" button with confirmation dialog
  • "Delete" button with confirmation

Components:

  • src/components/admin/webhook-form.tsx — Create/edit form (React Hook Form + Zod)
  • src/components/admin/webhook-delivery-log.tsx — Paginated delivery table with expandable row for response body
  • src/components/admin/webhook-event-selector.tsx — Grouped checkbox grid for event selection

shadcn components used: Card, Table, Badge, Sheet, Dialog, AlertDialog, Checkbox, Input, Button, Switch (for active toggle), Select (event type for test), Tooltip, ScrollArea

Day 2: Webhook Delivery Engine

Dispatch service: src/lib/services/webhook-dispatch.ts

/**
 * Dispatch a webhook event to all matching active webhooks for a port.
 * Called from event emitters throughout the service layer.
 * @param portId - Port scope
 * @param event - Event type
 * @param payload - Event data (entity ID, changed fields, etc.)
 */
export async function dispatchWebhookEvent(
  portId: string,
  event: WebhookEvent,
  payload: Record<string, unknown>,
): Promise<void> {
  // 1. Query active webhooks for this port where events array contains event
  // 2. For each matching webhook, enqueue a BullMQ job on 'webhooks' queue
  // 3. Job data: { webhookId, event, payload, deliveryId }
  // 4. No blocking — fire and forget via queue
}

BullMQ job processor: src/jobs/webhook-delivery.ts

/**
 * Process a webhook delivery job.
 * Signs payload with HMAC-SHA256, sends POST request, records result.
 */
export async function processWebhookDelivery(job: Job<WebhookDeliveryJobData>): Promise<void> {
  // 1. Fetch webhook config (decrypt secret)
  // 2. Build delivery payload:
  //    {
  //      id: deliveryId (UUID),
  //      event: 'client.created',
  //      timestamp: ISO8601,
  //      port_id: portId,
  //      data: { ...entity data }
  //    }
  // 3. Sign: HMAC-SHA256(secret, JSON.stringify(payload))
  // 4. POST to webhook.url with headers:
  //    X-Webhook-Id: webhookId
  //    X-Webhook-Event: event
  //    X-Webhook-Signature: sha256=<hex signature>
  //    X-Webhook-Delivery: deliveryId
  //    Content-Type: application/json
  //    User-Agent: PortNimara-CRM/1.0
  // 5. Timeout: 10 seconds
  // 6. Record result in webhook_deliveries:
  //    - 2xx → status: 'success', response_status, delivered_at
  //    - non-2xx → status: 'failed', response_status, response_body (first 1KB)
  //    - network error → status: 'failed', response_body: error message
  // 7. On final retry failure → status: 'dead_letter'
  //    → emit system:alert to global room
  //    → create notification for super admin
}

Queue config:

  • Queue: webhooks
  • Concurrency: 5
  • Max attempts: 3
  • Backoff: exponential (30s, 300s, 3000s) — longer than default since external services may be temporarily down
  • Priority: 3 (default)

Integration points — add dispatchWebhookEvent() calls to:

Service Events
src/lib/services/clients.ts client.created, client.updated, client.archived, client.merged
src/lib/services/interests.ts interest.created, interest.stageChanged, interest.berthLinked
src/lib/services/berths.ts berth.statusChanged, berth.updated
src/lib/services/documents.ts document.sent, document.signed, document.completed, document.expired
src/lib/services/expenses.ts expense.created, expense.updated
src/lib/services/invoices.ts invoice.created, invoice.sent, invoice.paid, invoice.overdue
src/lib/services/public-api.ts registration.new

Each dispatch call is non-blocking (enqueues to BullMQ). The service layer does:

// At end of createClient():
await dispatchWebhookEvent(portId, 'client.created', { clientId, clientName, source });

Edge cases:

  • Webhook URL unreachable: recorded as failed, retried per backoff schedule
  • Webhook deleted while jobs pending: job processor checks webhook still exists, skips if deleted
  • Circular webhooks (webhook triggers event that triggers same webhook): impossible because webhook dispatch is outbound-only, doesn't process inbound
  • Payload size: cap at 64KB per delivery payload to prevent memory issues

Day 3: Scheduled Reports Configuration + Generation

Service: src/lib/services/scheduled-reports.ts

export const REPORT_TYPES = [
  'pipeline_summary',
  'expense_summary',
  'berth_occupancy',
  'activity_log',
  'overdue_items',
  'revenue_forecast',
] as const;

export type ReportType = (typeof REPORT_TYPES)[number];

/**
 * Create a scheduled report configuration.
 * @param portId - Port scope
 * @param userId - Creating user for audit
 * @param data - Report config (type, schedule, recipients)
 * @returns Created report with next_run_at calculated
 * @throws ValidationError if cron invalid or recipients empty
 */
export async function createReport(
  portId: string,
  userId: string,
  data: CreateReportInput,
): Promise<ScheduledReportResponse>;

/**
 * List all scheduled reports for a port.
 */
export async function listReports(portId: string): Promise<ScheduledReportResponse[]>;

/**
 * Update report configuration.
 * Recalculates next_run_at if schedule changed.
 */
export async function updateReport(
  portId: string,
  reportId: string,
  userId: string,
  data: UpdateReportInput,
): Promise<ScheduledReportResponse>;

/**
 * Delete report and all recipients.
 */
export async function deleteReport(portId: string, reportId: string, userId: string): Promise<void>;

/**
 * Manually trigger report generation.
 * Enqueues a one-off report generation job.
 * @returns Job ID for progress tracking
 */
export async function triggerReport(
  portId: string,
  reportId: string,
  userId: string,
): Promise<{ jobId: string }>;

/**
 * Calculate next run time from cron expression.
 * Uses `cron-parser` library.
 */
export function calculateNextRun(cronExpression: string): Date;

Cron handling:

  • Use cron-parser npm package (lightweight, no eval, MIT license) to parse cron expressions
  • Pre-built schedule options in UI: daily at 8am, weekly Monday 8am, monthly 1st at 8am, custom cron
  • next_run_at recalculated every time the report runs or schedule changes
  • report-scheduler recurring job (every minute) queries: WHERE is_active = true AND next_run_at <= now()

Zod schemas: src/lib/validators/scheduled-reports.ts

export const createReportSchema = z.object({
  name: z.string().min(1).max(100),
  report_type: z.enum(REPORT_TYPES),
  schedule: z.string().refine(isValidCron, 'Invalid cron expression'),
  recipients: z
    .array(
      z.object({
        email: z.string().email(),
        user_id: z.string().optional(),
      }),
    )
    .min(1)
    .max(20),
  config: z.record(z.unknown()).default({}),
});

function isValidCron(expr: string): boolean {
  try {
    parseCron(expr);
    return true;
  } catch {
    return false;
  }
}

Report generators: src/lib/services/report-generators/

One file per report type:

  • pipeline-summary.ts — Interests by stage, conversion rates, new vs closed this period, top berths by interest count
  • expense-summary.ts — Expenses by category, monthly totals, budget vs actual, top 10 expenses
  • berth-occupancy.ts — Occupied vs available vs maintenance, occupancy %, berth utilization by type
  • activity-log.ts — Actions by user, entity type breakdown, busiest days/hours
  • overdue-items.ts — Overdue invoices, overdue reminders, unsigned documents past deadline
  • revenue-forecast.ts — Pipeline value by stage × probability, expected revenue by month, historical comparison

Each generator follows the pattern:

export interface ReportGenerator {
  /**
   * Gather data and generate report content.
   * @param portId - Port scope
   * @param dateRange - Report period
   * @param config - Report-specific config (from scheduled_reports.config JSONB)
   * @returns Report data suitable for PDF template
   */
  generate(
    portId: string,
    dateRange: DateRange,
    config: Record<string, unknown>,
  ): Promise<ReportData>;
}

PDF generation: Use @pdfme (locked dependency) with report-specific templates:

  • Each report type has a @pdfme template defining layout (cover page with port branding, tables, summary boxes)
  • Template includes: port logo, report title, date range, generation timestamp
  • src/lib/services/report-pdf.ts — takes ReportData + template → PDF buffer

BullMQ job processor: src/jobs/report-generation.ts

  • Queue: reports, concurrency: 1
  • Steps: query data → generate PDF → upload to MinIO ({portSlug}/reports/{reportId}/{timestamp}.pdf) → email PDF to recipients → update last_run_at + calculate next_run_at
  • Emit Socket.io event to port room when complete: { reportId, reportName, downloadUrl }
  • On failure: create system alert notification

API routes:

Method Path Handler
GET /api/admin/reports listReports(portId)
POST /api/admin/reports createReport(portId, userId, body)
GET /api/admin/reports/[id] getReport(portId, id)
PATCH /api/admin/reports/[id] updateReport(portId, id, userId, body)
DELETE /api/admin/reports/[id] deleteReport(portId, id, userId)
POST /api/admin/reports/[id]/run triggerReport(portId, id, userId)

Route files at src/app/api/admin/reports/route.ts and src/app/api/admin/reports/[id]/route.ts, src/app/api/admin/reports/[id]/run/route.ts.

Middleware: withAuth → withPermission('reports', 'manage') → handler

Admin UI:

src/app/(dashboard)/[portSlug]/admin/reports/page.tsx:

  • Table: name, type, schedule (human-readable), next run, last run, status (active/inactive), recipients count
  • Create button → sheet with form
  • Row actions: edit, run now, delete

src/components/admin/report-form.tsx:

  • Report name, type dropdown, schedule selector (preset + custom cron input), recipient email list (autocomplete CRM users + manual email entry)
  • Config section that changes based on report type (e.g., expense summary can filter by category)

shadcn components: Card, Table, Badge, Sheet, Select, Input, Button, Calendar (date range for manual run)


Stream B: Data Import/Export (Days 24)

Day 2 (of stream): CSV/Excel Upload + Column Mapping

Service: src/lib/services/data-import.ts

export const IMPORTABLE_ENTITIES = ['clients', 'interests', 'berths', 'expenses'] as const;
export type ImportableEntity = (typeof IMPORTABLE_ENTITIES)[number];

/**
 * Parse uploaded file and detect columns.
 * @param portId - Port scope
 * @param userId - Uploading user
 * @param file - Uploaded file buffer
 * @param entityType - Target entity type
 * @returns Import session with detected columns and auto-mappings
 * @throws ValidationError if file format invalid or too large
 */
export async function uploadForImport(
  portId: string,
  userId: string,
  file: { buffer: Buffer; mimetype: string; originalname: string },
  entityType: ImportableEntity,
): Promise<ImportSession>;

/**
 * Preview import with user-confirmed column mappings.
 * Validates all rows and detects duplicates.
 * @param portId - Port scope
 * @param sessionId - Import session ID
 * @param mappings - Column-to-field mappings
 * @returns Preview with valid/error/duplicate counts and sample data
 */
export async function previewImport(
  portId: string,
  sessionId: string,
  mappings: ColumnMapping[],
): Promise<ImportPreview>;

/**
 * Execute import as BullMQ job.
 * @param portId - Port scope
 * @param userId - Executing user
 * @param sessionId - Import session ID
 * @param options - Duplicate handling strategy
 * @returns Job ID for progress tracking
 */
export async function executeImport(
  portId: string,
  userId: string,
  sessionId: string,
  options: { skipDuplicates: boolean; updateDuplicates: boolean },
): Promise<{ jobId: string }>;

/**
 * List past import operations for a port.
 */
export async function listImportHistory(
  portId: string,
  pagination: PaginationInput,
): Promise<PaginatedResponse<ImportHistoryEntry>>;

File parsing:

  • CSV: use papaparse (already available, lightweight)
  • Excel: use xlsx (SheetJS) for read-only parsing — lighter than exceljs for import. xlsx is a read/write library that doesn't require native deps.
  • Auto-detect format from MIME type: text/csv → papaparse, application/vnd.openxmlformats* → xlsx
  • Extract headers from first row
  • Auto-map headers to entity fields using fuzzy matching:
    // "First Name" → first_name, "E-mail" → email, "Phone Number" → phone
    function autoMapColumn(header: string, entityFields: string[]): string | null {
      const normalized = header.toLowerCase().replace(/[^a-z0-9]/g, '_');
      return entityFields.find((f) => f === normalized || levenshtein(f, normalized) <= 2) ?? null;
    }
    

Import session storage: Store in Redis with 1-hour TTL (parsed data, mappings, preview results). Key: import:{sessionId}. Avoids re-parsing on each step.

Zod schemas: src/lib/validators/data-import.ts

export const uploadImportSchema = z.object({
  entityType: z.enum(IMPORTABLE_ENTITIES),
});

export const previewImportSchema = z.object({
  sessionId: z.string().uuid(),
  mappings: z.array(
    z.object({
      sourceColumn: z.string(),
      targetField: z.string(),
      transform: z.enum(['none', 'trim', 'lowercase', 'uppercase', 'date_parse']).default('none'),
    }),
  ),
});

export const executeImportSchema = z.object({
  sessionId: z.string().uuid(),
  skipDuplicates: z.boolean().default(true),
  updateDuplicates: z.boolean().default(false),
});

API routes:

Method Path Handler
POST /api/import/upload uploadForImport(portId, userId, file, body.entityType)
POST /api/import/preview previewImport(portId, body.sessionId, body.mappings)
POST /api/import/execute executeImport(portId, userId, body.sessionId, body)
GET /api/import/history listImportHistory(portId, query)

Route files at src/app/api/import/upload/route.ts, src/app/api/import/preview/route.ts, src/app/api/import/execute/route.ts, src/app/api/import/history/route.ts.

Middleware: withAuth → withPermission('import', 'manage') → handler

BullMQ job: src/jobs/import-process.ts

  • Queue: import, concurrency: 1
  • Priority: 5 (lowest)
  • Max attempts: 1 (user retries manually)
  • Progress updates via Socket.io: { sessionId, processed, total, errors }
  • Each row inserted in transaction → if row fails, skip and record error → continue
  • On completion: store import summary in import_history table (or a dedicated metadata table in Redis, exported to audit_logs)
  • Audit log: one entry per import with metadata { source: 'csv_import', filename, rowCount, successCount, errorCount }

Duplicate detection during import:

  • Clients: exact email match → flag as duplicate
  • Berths: exact mooring_number match → flag as duplicate
  • Interests: client_id + berth_id match → flag as duplicate
  • Expenses: no dedup (each row is unique)

Day 3 (of stream): Entity Export + Import History UI

Export service: src/lib/services/data-export.ts

export const EXPORTABLE_ENTITIES = [
  'clients',
  'interests',
  'berths',
  'expenses',
  'invoices',
] as const;

/**
 * Export entity list with current filters and selected columns.
 * @param portId - Port scope
 * @param userId - Exporting user (for audit)
 * @param entityType - Which entity to export
 * @param options - Filters, columns, format, optional entity IDs for selection
 * @returns Job ID for async export
 */
export async function exportEntities(
  portId: string,
  userId: string,
  entityType: ExportableEntity,
  options: ExportOptions,
): Promise<{ jobId: string }>;

BullMQ job: src/jobs/export-process.ts

  • Queue: export, concurrency: 2
  • Priority: 4
  • Steps:
    1. Query entities with filters + port scope
    2. Include custom field values as additional columns (if custom fields exist for entity type)
    3. Generate file:
      • CSV: papaparse unparse
      • Excel: xlsx (SheetJS) write — creates workbook with headers, auto-width columns, styled header row
    4. Upload to MinIO: {portSlug}/exports/{entityType}-{timestamp}.{ext}
    5. Generate presigned URL (15-min expiry)
    6. Emit Socket.io event to user:{userId}: { jobId, downloadUrl, filename }
    7. Create notification with download link
    8. Audit log: { action: 'export', entityType, rowCount, format, userId }

API route:

Method Path Handler
POST /api/export/[entityType] exportEntities(portId, userId, entityType, body)

Route at src/app/api/export/[entityType]/route.ts.

Middleware: withAuth → withPermission(entityType, 'export') → handler

Zod schema: src/lib/validators/data-export.ts

export const exportSchema = z.object({
  filters: z.record(z.unknown()).default({}),
  columns: z.array(z.string()).min(1),
  format: z.enum(['csv', 'xlsx']),
  entityIds: z.array(z.string().uuid()).optional(), // if set, export only these
});

Day 4 (of stream): Import Wizard UI + Export Integration

Import wizard page: src/app/(dashboard)/[portSlug]/admin/import/page.tsx

5-step wizard using shadcn Tabs or a custom stepper:

  1. Select & Upload — Entity type dropdown + file drag-and-drop zone (shadcn's dropzone pattern)
  2. Map Columns — Two-column layout: source columns (left) ↔ target fields (right, dropdown). Auto-mapped columns pre-selected with green check. Unmapped columns highlighted yellow.
  3. Preview — Summary cards: ✓ Valid rows, ⚠ Errors, 🔄 Duplicates. Expandable error table with row number, field, error message. Sample of first 5 transformed rows.
  4. Configure — Duplicate handling: skip / update existing. Confirm button with total count.
  5. Progress & Results — Progress bar (updated via Socket.io). On completion: success count, error count, download error report CSV.

Components:

  • src/components/admin/import-wizard.tsx — Multi-step wizard container
  • src/components/admin/column-mapper.tsx — Drag-to-map or dropdown column mapping
  • src/components/admin/import-preview.tsx — Preview table with validation results
  • src/components/admin/import-history-table.tsx — Past imports with stats

Export integration on list pages:

  • Add export dropdown button to all entity list page toolbars (clients, interests, berths, expenses, invoices)
  • Dropdown: "Export as CSV" / "Export as Excel"
  • Uses current active filters and visible columns as defaults
  • Option to export selected rows only (if bulk selection active) or all matching
  • Shows toast notification "Export started" → updates to "Export ready — Download" with link

TanStack Query keys:

// Import
['import', 'session', sessionId][('import', 'preview', sessionId)][('import', 'history', portId)][ // Import session data // Preview results // Import history list
  // Export
  ('export', 'job', jobId)
]; // Export job status

State management:

  • Import wizard state: local React state (useState) for step tracking, mappings, preview data
  • No Zustand needed — wizard is self-contained, state doesn't persist across navigation

shadcn components: Card, Table, Badge, Button, Select, Progress, Alert, Tabs (for stepper), DropdownMenu (export format), Tooltip


Stream C: Custom Fields & Tags (Days 35)

Day 3 (of stream): Custom Field Definitions

Service: src/lib/services/custom-fields.ts

export const CUSTOM_FIELD_TYPES = ['text', 'number', 'date', 'boolean', 'select'] as const;
export const CUSTOM_FIELD_ENTITIES = ['client', 'interest', 'berth'] as const;

/**
 * List custom field definitions for a port, optionally filtered by entity type.
 * @param portId - Port scope
 * @param entityType - Optional entity type filter
 * @returns Definitions sorted by sort_order
 */
export async function listDefinitions(
  portId: string,
  entityType?: CustomFieldEntity,
): Promise<CustomFieldDefinition[]>;

/**
 * Create a custom field definition.
 * @throws ConflictError if field_name already exists for port+entity_type
 */
export async function createDefinition(
  portId: string,
  userId: string,
  data: CreateFieldInput,
): Promise<CustomFieldDefinition>;

/**
 * Update a custom field definition.
 * Cannot change field_type after creation (would break existing values).
 * @throws ValidationError if attempting to change field_type
 */
export async function updateDefinition(
  portId: string,
  fieldId: string,
  userId: string,
  data: UpdateFieldInput,
): Promise<CustomFieldDefinition>;

/**
 * Delete a custom field definition and all associated values.
 * @returns Count of deleted values for confirmation messaging
 */
export async function deleteDefinition(
  portId: string,
  fieldId: string,
  userId: string,
): Promise<{ deletedValueCount: number }>;

/**
 * Get all custom field values for a specific entity instance.
 * @param entityId - The entity (client/interest/berth) UUID
 * @param portId - Port scope (for definition lookup)
 * @returns Array of { definition, value } pairs
 */
export async function getValues(entityId: string, portId: string): Promise<CustomFieldWithValue[]>;

/**
 * Set (upsert) custom field values for an entity.
 * Validates each value against its field type.
 * @throws ValidationError if value doesn't match field type
 */
export async function setValues(
  entityId: string,
  portId: string,
  userId: string,
  values: { fieldId: string; value: unknown }[],
): Promise<void>;

Value validation:

function validateCustomFieldValue(
  definition: CustomFieldDefinition,
  value: unknown,
): string | null {
  if (value === null || value === undefined) {
    return definition.is_required ? 'This field is required' : null;
  }
  switch (definition.field_type) {
    case 'text':
      if (typeof value !== 'string') return 'Must be text';
      if (value.length > 1000) return 'Maximum 1000 characters';
      return null;
    case 'number':
      if (typeof value !== 'number' || isNaN(value)) return 'Must be a number';
      return null;
    case 'date':
      if (typeof value !== 'string' || isNaN(Date.parse(value))) return 'Must be a valid date';
      return null;
    case 'boolean':
      if (typeof value !== 'boolean') return 'Must be true or false';
      return null;
    case 'select':
      if (!definition.select_options?.includes(value as string)) {
        return `Must be one of: ${definition.select_options?.join(', ')}`;
      }
      return null;
    default:
      return 'Unknown field type';
  }
}

Drizzle queries:

// Get values with definitions in one query (join)
const valuesWithDefs = await db
  .select()
  .from(customFieldValues)
  .innerJoin(customFieldDefinitions, eq(customFieldValues.fieldId, customFieldDefinitions.id))
  .where(and(eq(customFieldValues.entityId, entityId), eq(customFieldDefinitions.portId, portId)))
  .orderBy(customFieldDefinitions.sortOrder);

// Upsert value
await db
  .insert(customFieldValues)
  .values({ fieldId, entityId, value: JSON.stringify(value) })
  .onConflictDoUpdate({
    target: [customFieldValues.fieldId, customFieldValues.entityId],
    set: { value: JSON.stringify(value), updatedAt: new Date() },
  });

Zod schemas: src/lib/validators/custom-fields.ts

export const createFieldSchema = z
  .object({
    entity_type: z.enum(CUSTOM_FIELD_ENTITIES),
    field_name: z
      .string()
      .min(1)
      .max(50)
      .regex(/^[a-z_][a-z0-9_]*$/, 'Must be snake_case'),
    field_label: z.string().min(1).max(100),
    field_type: z.enum(CUSTOM_FIELD_TYPES),
    select_options: z.array(z.string().min(1).max(100)).min(1).max(50).optional(),
    is_required: z.boolean().default(false),
    sort_order: z.number().int().min(0).default(0),
  })
  .refine(
    (data) =>
      data.field_type !== 'select' || (data.select_options && data.select_options.length > 0),
    { message: 'Select fields must have at least one option', path: ['select_options'] },
  );

export const updateFieldSchema = z.object({
  field_label: z.string().min(1).max(100).optional(),
  select_options: z.array(z.string().min(1).max(100)).optional(),
  is_required: z.boolean().optional(),
  sort_order: z.number().int().min(0).optional(),
  // field_type intentionally omitted — cannot be changed
});

export const setValuesSchema = z.object({
  values: z.array(
    z.object({
      fieldId: z.string().uuid(),
      value: z.unknown(), // validated dynamically against field type
    }),
  ),
});

API routes:

Method Path Handler
GET /api/admin/custom-fields listDefinitions(portId, query.entityType)
POST /api/admin/custom-fields createDefinition(portId, userId, body)
PATCH /api/admin/custom-fields/[id] updateDefinition(portId, id, userId, body)
DELETE /api/admin/custom-fields/[id] deleteDefinition(portId, id, userId)

Custom field values are read/written through the entity's own API:

  • GET /api/v1/clients/[id] → includes customFields array in response
  • PATCH /api/v1/clients/[id] → accepts customFields in body → calls setValues()

Route files at src/app/api/admin/custom-fields/route.ts and src/app/api/admin/custom-fields/[id]/route.ts.

Middleware: withAuth → withPermission('custom_fields', 'manage') → handler

Admin UI: src/app/(dashboard)/[portSlug]/admin/custom-fields/page.tsx

  • Grouped by entity type (Tabs: Clients | Interests | Berths)
  • Table within each tab: field name, label, type, required badge, sort order, actions
  • Create button → dialog with form
  • Edit inline or via dialog
  • Delete with confirmation showing "This will delete X values across Y records"
  • Drag-to-reorder (sort_order)

Components:

  • src/components/admin/custom-field-form.tsx — Create/edit field definition form
  • src/components/admin/custom-field-list.tsx — Sortable list with drag handles

Day 4 (of stream): Custom Field Rendering + Tags Polish

Dynamic custom field renderer: src/components/shared/custom-fields-section.tsx

interface CustomFieldsSectionProps {
  entityType: 'client' | 'interest' | 'berth';
  entityId: string;
  portId: string;
  readOnly?: boolean;
}
  • Fetches definitions for entity type + current values for entity ID
  • Renders form controls based on field_type:
    • text → shadcn Input
    • number → shadcn Input with type="number"
    • date → shadcn DatePicker
    • boolean → shadcn Switch
    • select → shadcn Select with options from select_options
  • Required fields show asterisk and validation
  • Auto-saves on blur (debounced 500ms) — same pattern as inline editing in L1
  • Collapsible section titled "Custom Fields" on entity detail pages
  • Empty state: "No custom fields configured. Admins can add fields in Settings."

Integration with entity detail pages:

  • src/components/clients/client-detail.tsx → add <CustomFieldsSection entityType="client" entityId={id} />
  • src/components/interests/interest-detail.tsx → add <CustomFieldsSection entityType="interest" entityId={id} />
  • src/components/berths/berth-detail.tsx → add <CustomFieldsSection entityType="berth" entityId={id} />

Custom fields in exports:

  • Update data-export.ts job processor to:
    1. Fetch custom field definitions for entity type
    2. For each exported row, join custom field values
    3. Append custom field columns after standard columns
    4. Column headers use field_label

Custom fields in saved views:

  • Update saved view column_config to support custom:{fieldId} column identifiers
  • Column selector on list pages shows custom fields in a separate "Custom Fields" group
  • Sort/filter by custom fields: basic support (text/number sort, exact match filter)

Tags polish: src/lib/services/tags.ts (updates to L1 implementation)

/**
 * Update tag color.
 * @param portId - Port scope
 * @param tagId - Tag ID
 * @param color - New hex color
 */
export async function updateTagColor(
  portId: string,
  tagId: string,
  userId: string,
  color: string,
): Promise<Tag>;

/**
 * Rename a tag. Name must be unique within port.
 * @throws ConflictError if new name already exists
 */
export async function renameTag(
  portId: string,
  tagId: string,
  userId: string,
  name: string,
): Promise<Tag>;

/**
 * Delete tag and remove all entity associations.
 * @returns Count of affected entities
 */
export async function deleteTag(
  portId: string,
  tagId: string,
  userId: string,
): Promise<{ affectedCount: number }>;

/**
 * Merge two tags into one. All entities tagged with sourceTag get re-tagged with targetTag.
 * Source tag is deleted after merge.
 * @returns Count of re-tagged entities
 */
export async function mergeTags(
  portId: string,
  sourceTagId: string,
  targetTagId: string,
  userId: string,
): Promise<{ mergedCount: number }>;

Tag UI enhancements:

  • src/components/shared/tag-badge.tsx — Already exists from L1. Add: color dot/background based on tag color
  • src/components/shared/tag-color-picker.tsx — 12 preset colors + custom hex input (shadcn Popover with color swatches)
  • src/components/admin/tag-manager.tsx — Admin page for tag management: rename, change color, delete, merge
  • Delete confirmation: "This tag is used on X clients, Y interests, Z berths. Deleting will remove it from all."
  • Merge dialog: select source and target tags, show counts, confirm

Tag admin page: src/app/(dashboard)/[portSlug]/admin/tags/page.tsx

TanStack Query keys:

['custom-fields', 'definitions', portId, entityType][('custom-fields', 'values', entityId)][
  ('tags', portId)
]; // All tags for port

Stream D: UX Polish, System Monitoring & Admin (Days 47)

Day 4 (of stream): Dark Mode

Implementation using 15-DESIGN-TOKENS.md §2.2 (exact token values):

src/app/globals.css — extend existing CSS custom properties:

/* Dark mode token overrides — values from 15-DESIGN-TOKENS.md §2.2 */
[data-theme='dark'] {
  /* Backgrounds */
  --background: #131a2c;
  --background-secondary: #192239;
  --background-tertiary: #1e2844;
  --background-brand: #3a7bc8;
  --background-brand-dark: #101625;

  /* Text */
  --text-primary: #e8ece9;
  --text-secondary: #9ea1af;
  --text-tertiary: #71768a;
  --text-on-brand: #ffffff;
  --text-link: #6196d3;

  /* Borders */
  --border: #2d3c66;
  --border-strong: #474e66;
  --border-focus: #6196d3;

  /* Interactive */
  --primary: #4a8ad4;
  --primary-hover: #6196d3;
  --primary-active: #3a7bc8;

  /* Status — brightened for dark bg readability */
  --success: #4caf50;
  --success-bg: #1b3d1e;
  --warning: #ffca28;
  --warning-bg: #3d3417;
  --error: #ef5350;
  --error-bg: #3d1a1a;
  --info: #6196d3;
  --info-bg: #1a2d3d;

  /* Sidebar — already dark in light mode, minimal change */
  --sidebar-bg: #101625;
  --sidebar-hover: #131a2c;
}

Theme store: src/lib/stores/theme-store.ts

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface ThemeStore {
  theme: 'light' | 'dark' | 'system';
  setTheme: (theme: 'light' | 'dark' | 'system') => void;
  resolvedTheme: () => 'light' | 'dark';
}

export const useThemeStore = create<ThemeStore>()(
  persist(
    (set, get) => ({
      theme: 'system',
      setTheme: (theme) => {
        set({ theme });
        applyTheme(theme);
      },
      resolvedTheme: () => {
        const { theme } = get();
        if (theme === 'system') {
          return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
        }
        return theme;
      },
    }),
    { name: 'theme-preference' },
  ),
);

function applyTheme(theme: 'light' | 'dark' | 'system') {
  const resolved =
    theme === 'system'
      ? window.matchMedia('(prefers-color-scheme: dark)').matches
        ? 'dark'
        : 'light'
      : theme;
  document.documentElement.setAttribute('data-theme', resolved);
}
  • Also persist to user_profiles.preferences JSONB on the server (PATCH on change, so it follows the user across devices)
  • On initial page load: read from localStorage first (instant, no flash), then sync with server preference
  • System preference listener: matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ...) to update when OS theme changes

Theme toggle: Added to user dropdown menu in topbar header:

  • Icon: Sun/Moon/Monitor (Lucide: Sun, Moon, Monitor)
  • Three-option toggle: Light / Dark / System
  • Also available in user settings page

Audit all components for hardcoded colors:

  • All shadcn components already use CSS variables → work automatically
  • Custom components from L1-L3 should use semantic tokens (text-primary, bg-secondary, etc.)
  • Check and fix: chart colors, status badges, pipeline stage colors, sidebar active states
  • Charts (Recharts): use CSS variable values programmatically via getComputedStyle()

Day 5 (of stream): Mobile Responsive + Scratchpad

Mobile responsive audit:

Priority breakpoints: sm (640px), md (768px), lg (1024px)

Key responsive fixes:

  1. Sidebar — Already collapsible from L0. On < md: auto-collapse to icon-only, hamburger button in topbar. Overlay mode on mobile (Sheet component).
  2. Data tables — On < lg: horizontal scroll with ScrollArea. On < sm: switch to card layout using DataTableMobileView component.
  3. Forms — On < md: single-column layout. Multi-column grids collapse to stack.
  4. Pipeline board — On < md: horizontal scroll with snap-to-card. Touch-friendly drag.
  5. Dashboard widgets — On < md: single-column stack. Widgets full-width.
  6. Dialogs/modals — On < sm: full-screen via Dialog with className="sm:max-w-[425px] max-sm:h-full max-sm:max-h-full max-sm:rounded-none".
  7. Touch targets — Minimum 44×44px on all interactive elements. Audit buttons, links, checkboxes.

Components:

  • src/components/shared/responsive-table.tsx — Wrapper that switches between table and card layout based on breakpoint
  • src/components/shared/mobile-sheet-sidebar.tsx — Sheet-based sidebar overlay for mobile

Scratchpad:

Service: src/lib/services/scratchpad.ts

/**
 * List current user's scratchpad notes.
 * Only returns unlinked notes (linked notes have moved to client notes).
 * Ordered by updated_at DESC.
 */
export async function listNotes(userId: string): Promise<ScratchpadNote[]>;

/**
 * Create a scratchpad note.
 * @param userId - Note owner
 * @param content - Note text content
 */
export async function createNote(userId: string, content: string): Promise<ScratchpadNote>;

/**
 * Update note content.
 * @throws NotFoundError if note doesn't belong to user
 */
export async function updateNote(
  userId: string,
  noteId: string,
  content: string,
): Promise<ScratchpadNote>;

/**
 * Delete a scratchpad note.
 * @throws NotFoundError if note doesn't belong to user
 */
export async function deleteNote(userId: string, noteId: string): Promise<void>;

/**
 * Link a scratchpad note to a client record.
 * Creates a client_notes entry with the scratchpad content, then marks the scratchpad note as linked.
 * @param userId - Note owner
 * @param noteId - Scratchpad note ID
 * @param clientId - Target client
 * @param portId - Port scope (for client validation)
 * @returns The created client note
 */
export async function linkToClient(
  userId: string,
  noteId: string,
  clientId: string,
  portId: string,
): Promise<ClientNote>;

Note: Scratchpad notes are user-scoped (no port_id in schema — scratchpad_notes table has user_id only). This is correct per the spec: personal notes that can later be linked to a port-scoped client.

API routes:

Method Path Handler
GET /api/scratchpad listNotes(userId)
POST /api/scratchpad createNote(userId, body.content)
PATCH /api/scratchpad/[id] updateNote(userId, id, body.content)
DELETE /api/scratchpad/[id] deleteNote(userId, id)
POST /api/scratchpad/[id]/link linkToClient(userId, id, body.clientId, portId)

Route files at src/app/api/scratchpad/route.ts and src/app/api/scratchpad/[id]/route.ts, src/app/api/scratchpad/[id]/link/route.ts.

Middleware: withAuth → handler (no permission check — scratchpad is user-scoped, every authenticated user can use it)

Zod schemas: src/lib/validators/scratchpad.ts

export const createNoteSchema = z.object({
  content: z.string().min(1).max(5000),
});

export const updateNoteSchema = z.object({
  content: z.string().min(1).max(5000),
});

export const linkNoteSchema = z.object({
  clientId: z.string().uuid(),
});

UI:

src/components/shared/scratchpad-panel.tsx:

  • Slide-out Sheet from sidebar (trigger: notepad icon in sidebar footer + keyboard shortcut Alt+N)
  • Note cards: editable text area, timestamp, character count
  • "Link to client" button on each note → opens client search dialog → select client → confirms → note moves to client notes
  • Create new note: text area at top with "Add" button
  • Auto-save on blur (debounced 500ms)
  • Empty state: "Jot down quick notes during calls. Link them to clients later."

Zustand slice: Add to existing UI store:

scratchpadOpen: boolean;
toggleScratchpad: () => void;

TanStack Query keys:

['scratchpad', userId]; // User's notes

shadcn components: Sheet, Textarea, Button, ScrollArea, Dialog (client search for linking)

Day 6 (of stream): Archiving, Multi-Currency, System Monitoring

Archiving consistency:

All entities already have archived_at columns from L0 schema. Ensure consistent behavior:

  1. Archive/restore buttons — On every entity detail page (clients, interests, berths, expenses, invoices). Archive = set archived_at = now(). Restore = set archived_at = null.

  2. List page filtering — All list endpoints already support ?includeArchived=true. Default: exclude archived. Add "Show archived" toggle on list page toolbar.

  3. Visual indicator — Archived records show a yellow "Archived" badge in lists and detail views.

  4. Bulk archive — Uses existing bulk operations from L3. POST /api/bulk/status-change with action archive.

  5. Cascade rules:

    • Archiving a client does NOT archive their interests (interests may be shared with other entities)
    • Archiving an interest does NOT affect the linked berth
    • Archiving a berth triggers berth status rules engine (BR-001) if applicable

Multi-currency price book:

Service: src/lib/services/currency.ts

/**
 * Get current exchange rates from cache.
 * Rates are refreshed every 6 hours by maintenance job.
 */
export async function getRates(): Promise<CurrencyRate[]>;

/**
 * Convert amount between currencies using cached rates.
 * @throws NotFoundError if rate pair not cached
 */
export async function convert(amount: number, from: string, to: string): Promise<number>;

/**
 * Force refresh rates from Frankfurter API.
 * Updates currency_rates table.
 * Falls back to last cached rate if API is unreachable.
 */
export async function refreshRates(): Promise<{ updated: number; source: string }>;

/**
 * Manually override a rate pair.
 * Sets source='manual' to distinguish from API rates.
 */
export async function overrideRate(
  baseCurrency: string,
  targetCurrency: string,
  rate: number,
  userId: string,
): Promise<CurrencyRate>;

Currency pairs: USD (primary), EUR, GBP, ECD (local Anguilla currency). Only need 3 pairs: USD→EUR, USD→GBP, USD→ECD.

Frankfurter API integration:

  • Endpoint: https://api.frankfurter.app/latest?from=USD&to=EUR,GBP
  • ECD: fixed rate (1 USD = 2.70 ECD — Eastern Caribbean Dollar is pegged to USD)
  • Cache in Redis (6h TTL) AND currency_rates table (persistent)
  • If API unreachable: use last cached rate, log warning

Berth detail page enhancement:

  • Show price in all configured currencies below the primary USD amount:
    Annual Rate: $45,000 USD
    €41,400 EUR | £36,000 GBP | $121,500 ECD
    
  • src/components/berths/berth-price-display.tsx — Multi-currency price component

API routes:

Method Path Handler
GET /api/currency/rates getRates()
POST /api/currency/rates/refresh refreshRates()
POST /api/currency/convert convert(body.amount, body.from, body.to)
PATCH /api/currency/rates/[pair] overrideRate(pair.base, pair.target, body.rate, userId)

Middleware: GET is withAuth, POST/PATCH is withAuth → withPermission('currency', 'manage')

System monitoring:

Service: src/lib/services/system-monitoring.ts

/**
 * Get BullMQ dashboard data: queue names, counts (waiting, active, completed, failed, delayed).
 */
export async function getJobDashboard(): Promise<QueueStatus[]>;

/**
 * Get jobs in a specific queue with pagination.
 */
export async function getQueueJobs(
  queueName: string,
  status: 'waiting' | 'active' | 'completed' | 'failed' | 'delayed',
  pagination: PaginationInput,
): Promise<PaginatedResponse<JobEntry>>;

/**
 * Retry a failed job.
 */
export async function retryJob(queueName: string, jobId: string, userId: string): Promise<void>;

/**
 * Delete a job.
 */
export async function deleteJob(queueName: string, jobId: string, userId: string): Promise<void>;

/**
 * System health check: ping PostgreSQL, Redis, MinIO, Documenso.
 */
export async function healthCheck(): Promise<HealthStatus>;

/**
 * List active system alerts (dead letter jobs, failed backups, etc.).
 */
export async function listAlerts(portId?: string): Promise<SystemAlert[]>;

/**
 * Acknowledge a system alert.
 */
export async function acknowledgeAlert(alertId: string, userId: string): Promise<void>;

API routes (from catalog §28):

Method Path Handler
GET /api/admin/jobs getJobDashboard()
GET /api/admin/jobs/[queueName] getQueueJobs(queueName, query)
POST /api/admin/jobs/[queueName]/[jobId]/retry retryJob(queueName, jobId, userId)
DELETE /api/admin/jobs/[queueName]/[jobId] deleteJob(queueName, jobId, userId)
GET /api/admin/alerts listAlerts(portId)
PATCH /api/admin/alerts/[id]/acknowledge acknowledgeAlert(id, userId)

Also from catalog §23:

Method Path Handler
GET /api/admin/health healthCheck()
POST /api/admin/backup Trigger manual pg_dump
GET /api/admin/backups List available backups from MinIO
GET /api/admin/backups/[id]/download Presigned URL for backup download
POST /api/admin/backups/[id]/restore Restore from backup (super_admin only)

Middleware: withAuth → withPermission('system', 'manage') → handler (super_admin or director only)

System monitoring UI:

src/app/(dashboard)/[portSlug]/admin/monitoring/page.tsx:

  • Health panel — Service status indicators: PostgreSQL ✓, Redis ✓, MinIO ✓, Documenso ✓ (green/red dots with last check time)
  • Queue overview — Cards per queue: name, waiting count, active count, failed count. Click to expand.
  • Queue detail — Table of jobs with: ID, status, created, processed, data preview. Actions: retry, delete.
  • Alerts panel — Active system alerts with acknowledge button. Shows: alert type, message, severity, timestamp.

src/app/(dashboard)/[portSlug]/admin/backups/page.tsx:

  • Table of available backups: filename, size, created_at
  • "Create backup" button → triggers manual pg_dump
  • Download button per backup (presigned URL)
  • Restore button (super_admin only) with multi-step confirmation

Components:

  • src/components/admin/service-health-card.tsx — Green/red status indicator per service
  • src/components/admin/queue-overview.tsx — Queue stats cards
  • src/components/admin/queue-detail-table.tsx — Jobs table with actions
  • src/components/admin/system-alerts-list.tsx — Alert cards with acknowledge

Note: We use bull-board's data API directly rather than embedding its UI. This gives us full control over styling (matching our design system) while leveraging bull-board's queue introspection capabilities.

Day 7 (of stream): Parent Company Export + AI Features + Final Polish

Parent company export:

Service: src/lib/services/parent-company-export.ts

/**
 * Generate the parent company audit report as a downloadable PDF.
 * @param portId - Port scope
 * @param userId - Requesting user (for audit)
 * @param dateRange - Report period
 * @returns Download URL for generated PDF
 */
export async function generateParentCompanyReport(
  portId: string,
  userId: string,
  dateRange: { from: Date; to: Date },
): Promise<{ downloadUrl: string; filename: string }>;

Report sections:

  1. Cover page — Port logo, report title, date range, generation date
  2. Executive summary — Revenue total, expense total, net, berth occupancy %, pipeline count
  3. Expense detail — Table grouped by category, with receipt thumbnails (from MinIO)
  4. Revenue breakdown — Invoices paid in period, by client, by berth
  5. Berth occupancy — Available/occupied/maintenance counts, occupancy percentage
  6. Pipeline summary — Interests by stage, new inquiries in period
  7. Fee calculation — EUR subtotal + 5% management fee

PDF generated via @pdfme with a dedicated report template. Uploaded to MinIO, presigned URL returned.

API route: POST /api/admin/parent-company-export{ from: Date, to: Date }

UI: Button in admin section → date range picker (shadcn Calendar with range mode) → "Generate Report" → progress → download link.

AI features (berth spec import):

Service: src/lib/services/berth-import.ts

/**
 * Upload a berth specification document for AI-assisted parsing.
 * @param portId - Port scope
 * @param userId - Uploading user
 * @param file - PDF or Excel file with berth specs
 * @returns Job ID for tracking parse progress
 */
export async function uploadBerthSpec(
  portId: string,
  userId: string,
  file: { buffer: Buffer; mimetype: string; originalname: string },
): Promise<{ jobId: string }>;

/**
 * Get parsed berth data from AI processing (for user review).
 * @returns Parsed berths with confidence scores per field
 */
export async function getParsedBerths(jobId: string): Promise<ParsedBerthResult>;

/**
 * Confirm and create berths from parsed spec data.
 * @param portId - Port scope
 * @param userId - Confirming user
 * @param jobId - Parse job ID
 * @param berths - User-reviewed/corrected berth data
 */
export async function confirmBerthImport(
  portId: string,
  userId: string,
  jobId: string,
  berths: CreateBerthInput[],
): Promise<{ createdCount: number }>;
  • BullMQ job on ai queue (concurrency: 2, priority: 5)
  • For PDF: send pages to OpenAI Vision API (gpt-4o) for table extraction
  • For Excel: parse directly with xlsx, less AI needed
  • AI prompt extracts: mooring_number, berth_type, max_loa, max_beam, max_draft, annual_rate, status
  • Each parsed field gets a confidence score (high/medium/low) based on AI response
  • User reviews parsed results in a confirmation table, can edit before confirming
  • On confirm: creates berths using existing createBerth() service (so all validation, audit logging, etc. applies)

Recommendation engine refinement (enhancement to L2 implementation):

  • Add "Add manual recommendation" button to interest detail → search berth → add with notes
  • Manual recommendations stored in berth_recommendations with source = 'manual'
  • Manual recommendations display at top of recommendation list with "Manual" badge
  • "Re-generate" button: re-runs recommendation scoring with current vessel specs

Onboarding wizard (simplified for V1):

src/components/admin/onboarding-wizard.tsx:

  • Only triggered when super_admin creates a new port
  • 4 steps (simplified from baseline's 5):
    1. Port basics — Name, slug, logo upload, timezone, default currency
    2. Initial berths — Upload berth spec (reuses AI berth import) OR skip
    3. Add users — Invite team members with role assignment
    4. Review & activate — Summary, activate port
  • Not triggered for first-time setup (that's a deployment concern, not an app feature)

3. Code-Ready Details

Middleware Chain

All L4 admin routes:

withAuth → extractPort → withPermission(resource, action) → handler

Specific permission requirements:

Resource Action Allowed Roles
webhooks manage super_admin, director
reports manage super_admin, director
import manage super_admin, director, sales_manager
custom_fields manage super_admin, director
system manage super_admin, director
currency manage super_admin, director

Export permission uses the entity's own export action: | clients | export | super_admin, director, sales_manager | | expenses | export | super_admin, director |

Scratchpad has no permission check — all authenticated users.

TanStack Query Key Map

// Webhooks
['webhooks', portId][('webhooks', portId, webhookId)][ // List // Detail
  ('webhooks', portId, webhookId, 'deliveries')
][ // Delivery log
  // Reports
  ('reports', portId)
][('reports', portId, reportId)][ // List // Detail
  // Import/Export
  ('import', 'session', sessionId)
][('import', 'preview', sessionId)][('import', 'history', portId)][('export', 'job', jobId)][
  // Custom Fields
  ('custom-fields', 'definitions', portId, entityType)
][('custom-fields', 'values', entityId)][
  // Tags
  ('tags', portId)
][
  // Scratchpad
  ('scratchpad', userId)
][
  // Currency
  ('currency', 'rates')
][
  // System
  ('system', 'health')
][('system', 'jobs')][('system', 'jobs', queueName)][('system', 'alerts')][('system', 'backups')];

Zustand Slices (additions)

// Theme (new store)
useThemeStore: { theme, setTheme, resolvedTheme }

// UI store additions
scratchpadOpen: boolean;
toggleScratchpad: () => void;

Socket.io Events (L4)

Event Room Payload Trigger
export:completed user:{userId} { jobId, downloadUrl, filename, entityType } Export file ready
import:progress user:{userId} { sessionId, processed, total, errors } Import row processed
import:completed user:{userId} { sessionId, successCount, errorCount } Import finished
report:generated port:{portId} { reportId, reportName, downloadUrl } Scheduled report ready
system:alert global { alertType, message, severity } System alert (dead letter, backup failure)

BullMQ Queues (L4 additions)

Queue Job Types Concurrency Max Attempts Backoff
webhooks Webhook delivery 5 3 Exponential: 30s, 300s, 3000s
reports Report generation 1 3 Exponential: 1s, 10s, 100s
import CSV/Excel import 1 1 None (manual retry)
export CSV/Excel export 2 3 Exponential: 1s, 10s, 100s
ai Berth spec parsing 2 2 Fixed: 30s

Environment Variables (L4 additions)

WEBHOOK_SECRET_KEY=<32-byte AES key, hex>     # For encrypting webhook signing secrets

No other new env vars needed — OpenAI key already exists for receipt OCR (L2).

shadcn Components Used in L4

Already installed (from L0): Button, Input, Label, Select, Textarea, Checkbox, Switch, Dialog, Sheet, DropdownMenu, Tabs, Table, Card, Badge, Tooltip, Popover, Calendar, Form, Toast (Sonner), Skeleton, ScrollArea, AlertDialog, Progress, Separator

No new shadcn components needed for L4.

CSS/Tailwind Patterns

Dark mode: All components use CSS custom properties. Theme toggle applies data-theme="dark" to <html>. No Tailwind dark: prefix needed — the CSS variable approach handles it automatically.

Mobile responsive: Use Tailwind responsive prefixes (sm:, md:, lg:) with mobile-first approach:

<!-- Example: form grid that stacks on mobile -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">...</div>

4. Acceptance Criteria

Webhooks (AC-L4-01 through AC-L4-12)

  1. Admin can create a webhook with name, URL, and selected events; signing secret is shown once on creation
  2. Admin can update webhook name, URL, events, and active/inactive status
  3. Admin can delete a webhook; all delivery history is cascade-deleted
  4. Admin can regenerate a webhook signing secret
  5. When a subscribed event fires, a delivery is enqueued to BullMQ within 100ms (non-blocking)
  6. Webhook delivery includes HMAC-SHA256 signature in X-Webhook-Signature header
  7. Webhook delivery retries 3 times with exponential backoff (30s, 300s, 3000s)
  8. Failed delivery after all retries creates a system alert notification for super admin
  9. Delivery log shows: timestamp, event type, status (success/failed/dead_letter), response code, response time
  10. Test webhook sends a simulated event payload and records the delivery result
  11. Webhook signing secrets are encrypted at rest with AES-256-GCM
  12. All webhook CRUD operations write to audit_logs

Scheduled Reports (AC-L4-13 through AC-L4-22)

  1. Admin can create a scheduled report with type, cron schedule, and email recipients
  2. Six report types available: pipeline summary, expense summary, berth occupancy, activity log, overdue items, revenue forecast
  3. Report next_run_at is correctly calculated from cron expression and updates after each run
  4. Report-scheduler recurring job (every minute) picks up due reports and enqueues generation
  5. Generated reports are PDF files using @pdfme templates with port branding
  6. Generated PDF is uploaded to MinIO and emailed to all recipients
  7. Admin can manually trigger a report and download the result
  8. Admin can update report schedule, recipients, and active status
  9. Admin can delete a scheduled report
  10. All report CRUD operations write to audit_logs

Data Import (AC-L4-23 through AC-L4-30)

  1. Import wizard accepts CSV and Excel files for clients, interests, berths, expenses
  2. Column auto-mapping suggests target fields using fuzzy header matching
  3. User can manually adjust column mappings using dropdown selectors
  4. Preview step shows valid, error, and duplicate row counts with expandable error details
  5. Duplicate detection identifies: exact email match (clients), mooring_number match (berths)
  6. User can choose to skip or update duplicates
  7. Import executes as BullMQ job with progress updates via Socket.io
  8. Import history page shows past imports with success/error/total counts

Data Export (AC-L4-31 through AC-L4-35)

  1. Export button available on all entity list pages (clients, interests, berths, expenses, invoices)
  2. Export supports CSV and Excel formats
  3. Export respects current filters and visible columns as defaults
  4. Export includes custom field values as additional columns when custom fields exist
  5. Export job completes and notifies user with download link via Socket.io

Custom Fields (AC-L4-36 through AC-L4-44)

  1. Admin can create custom field definitions for clients, interests, and berths
  2. Field types supported: text, number, date, boolean, select (with configurable options)
  3. Field names must be unique per port + entity type
  4. Field type cannot be changed after creation
  5. Custom fields render dynamically on entity detail pages with appropriate form controls
  6. Custom field values auto-save on blur with debounced upsert
  7. Required custom fields show validation error when empty
  8. Custom fields appear in entity exports as additional columns
  9. Custom fields available in saved view column configuration

Tags Polish (AC-L4-45 through AC-L4-48)

  1. Admin can rename tags; name must remain unique within port
  2. Admin can change tag color using 12 presets or custom hex input
  3. Admin can delete a tag; confirmation shows count of affected entities
  4. Admin can merge two tags; all entity associations transfer to target tag

Dark Mode (AC-L4-49 through AC-L4-53)

  1. Theme toggle (light/dark/system) in user dropdown menu
  2. Dark mode uses exact color values from 15-DESIGN-TOKENS.md §2.2
  3. Theme preference persisted to localStorage (instant) and server (synced)
  4. System preference option follows OS dark/light mode
  5. All components render correctly in dark mode with no hardcoded colors

Mobile Responsive (AC-L4-54 through AC-L4-58)

  1. Sidebar collapses to hamburger menu on screens < 768px
  2. Data tables scroll horizontally or switch to card layout on screens < 640px
  3. Forms switch to single-column layout on screens < 768px
  4. All touch targets are minimum 44×44px
  5. Pipeline board scrolls horizontally with snap on mobile

Scratchpad (AC-L4-59 through AC-L4-63)

  1. Scratchpad panel slides out from sidebar footer icon
  2. User can create, edit, and delete quick notes
  3. Notes auto-save on blur with 500ms debounce
  4. User can link a note to a client; note content moves to client notes
  5. Keyboard shortcut Alt+N creates a new scratchpad note

Archiving (AC-L4-64 through AC-L4-67)

  1. Archive/restore buttons on all entity detail pages
  2. Archived entities hidden from default list views
  3. "Show archived" toggle on list pages includes archived records with visual indicator
  4. Bulk archive available via bulk operations toolbar

Multi-Currency (AC-L4-68 through AC-L4-72)

  1. Exchange rates refresh every 6 hours from Frankfurter API
  2. If API unreachable, last cached rate is used (with stale warning in admin)
  3. ECD rate is fixed at 2.70 per USD (pegged currency)
  4. Berth detail page shows price in USD, EUR, GBP, and ECD
  5. Admin can manually override exchange rates

System Monitoring (AC-L4-73 through AC-L4-79)

  1. System health endpoint returns status of PostgreSQL, Redis, MinIO, Documenso
  2. Job dashboard shows all BullMQ queues with waiting/active/failed counts
  3. Admin can view, retry, and delete individual jobs
  4. System alerts surface dead letter jobs and backup failures
  5. Admin can acknowledge alerts
  6. Manual backup trigger creates pg_dump and stores in MinIO
  7. Backup list shows available backups with download links

Parent Company Export (AC-L4-80 through AC-L4-82)

  1. Admin can generate a parent company report for a date range
  2. Report PDF includes: expenses with receipts, revenue, occupancy, pipeline summary, EUR subtotal + 5% fee
  3. Report uploaded to MinIO with presigned download URL

AI Features (AC-L4-83 through AC-L4-87)

  1. Berth spec import accepts PDF or Excel files for AI parsing
  2. Parsed berths show confidence scores; user can review and correct before confirming
  3. Confirmed berths are created using standard berth creation flow (with validation + audit)
  4. Manual recommendations can be added to interests alongside AI recommendations
  5. Recommendation re-generation updates scores based on current vessel specs

5. Self-Review Checklist

Security

  • Webhook signing secrets encrypted at rest (AES-256-GCM) — never stored or logged in plaintext
  • Webhook delivery payloads don't include sensitive PII (phone, email of clients) — use entity IDs only
  • Import file uploads validated: MIME type allowlist, max size, filename sanitization
  • Import session data in Redis has TTL (1 hour) — no stale data accumulation
  • Export files stored in MinIO with presigned URLs (15-min expiry) — no permanent public URLs
  • System monitoring endpoints restricted to super_admin/director roles
  • Backup restore restricted to super_admin only
  • Custom field values validated against field type before storage
  • No raw SQL in any L4 service — all queries via Drizzle ORM
  • Dark mode doesn't leak light-mode-only colors in any component

Data Integrity

  • All admin CRUD operations (webhooks, reports, custom fields, tags) write to audit_logs
  • Import operations create audit log entries with metadata (filename, row count, entity type)
  • Export operations create audit log entries (who exported what, when)
  • Webhook deletion cascades to webhook_deliveries (per schema ON DELETE CASCADE)
  • Custom field deletion cascades to custom_field_values (per schema ON DELETE CASCADE)
  • Tag merge re-assigns all entity associations before deleting source tag
  • Tag deletion cascades via junction tables (client_tags, interest_tags, berth_tags ON DELETE CASCADE)
  • Currency rate overrides preserve the source: 'manual' flag for audit trail
  • Parent company export includes only data within the specified date range

Port Scoping

  • Webhook CRUD scoped to portId from session context
  • Scheduled report CRUD scoped to portId
  • Import operations scoped to portId (all created records get correct port_id)
  • Custom field definitions scoped to portId
  • Tags scoped to portId (uniqueness constraint is port_id + name)
  • Currency rates are global (not port-scoped) — correct per schema
  • Scratchpad notes are user-scoped (not port-scoped) — correct per schema
  • System monitoring accessible to admin roles regardless of port context

UX

  • Dark mode toggle responds instantly (localStorage first, server sync async)
  • No flash of wrong theme on page load (theme applied in <head> script)
  • Mobile sidebar overlay closes when navigating to a page
  • Import wizard preserves state across steps (back/forward navigation)
  • Export progress notification persists until user dismisses or downloads
  • Scratchpad panel doesn't interfere with sidebar navigation
  • Custom field section collapses cleanly when no fields defined

Performance

  • Webhook dispatch is non-blocking (enqueue to BullMQ, don't await delivery)
  • Report generation runs on reports queue (concurrency: 1) to avoid memory spikes from PDF generation
  • Import uses streaming parse for large files (papaparse supports streaming)
  • Export uses cursor-based pagination for large entity sets (don't load all into memory)
  • Custom field values loaded in single JOIN query with definitions (not N+1)
  • Currency rates cached in Redis (6h TTL) — API call only when cache misses or forced refresh
  • System health check has 5-second timeout per service (don't block on unresponsive service)

Testing

  • Webhook delivery engine: test HMAC signature generation, retry logic, dead letter handling
  • Import: test CSV parsing, Excel parsing, column auto-mapping, duplicate detection, error rows
  • Export: test CSV and Excel output format, custom field inclusion, filter application
  • Custom fields: test type validation for all 5 types, required field enforcement, upsert behavior
  • Tags: test merge (source tag deleted, associations transferred), delete cascade
  • Dark mode: visual regression test (Playwright screenshot comparison, if feasible)
  • Currency conversion: test with known rates, test manual override, test API fallback
  • Scratchpad link-to-client: verify client note created, scratchpad note marked as linked

Integration

  • Webhook dispatch calls added to all 18 event sources across service layer
  • Custom field section renders on client, interest, and berth detail pages
  • Export includes custom fields for all exportable entity types
  • Saved views support custom field columns
  • Dark mode renders correctly on all pages from L0-L3
  • Mobile responsive works on all pages from L0-L3
  • Archiving behavior consistent across all entity types

Codex Addenda — Merged from Competing Plan Review

1. Webhook Event Name Translation (Dot-Style)

Internal socket events use camelCase (e.g., interest:stageChanged), but outbound webhook events must use dot-style names per BR-111/BR-112. Build an explicit translation map:

Internal Event Outbound Webhook Event
interest:stageChanged interest.stage_changed
client:created client.created
registration:new registration.new
document:signed document.signed
invoice:paid invoice.paid

Implement this as src/modules/webhooks/event-map.ts.

2. Report/Import History via Audit Logs (No New Tables)

Report run history and import history should be stored as audit_logs entries:

  • Report runs: entity_type='scheduled_report', action='run', metadata contains file path, recipients, and result
  • Import sessions: entity_type='import', metadata contains sessionId
  • Export history: entity_type='export'

Do not add report_runs or import_history tables unless Matt explicitly blesses a schema change.

3. TipTap-to-@pdfme Serializer Risk

The TipTap-to-@pdfme conversion is the hardest implementation detail in L4. The editor must stay within a constrained formatting subset or the serializer will become a rewrite sink. Define the supported node list upfront and reject unsupported nodes at validation time, not at generation time.

4. Custom Field Immutability

Custom field fieldType cannot change after records exist with values for that field. Only fieldLabel, isRequired, sortOrder, and selectOptions can be modified post-creation. Deleting a custom field definition cascade-deletes all values and requires explicit confirmation.

5. Webhook Secret Auto-Generation

If a webhook secret is blank on create, the server generates one and shows it once in the UI response. After that, the secret is masked in all GET responses.

6. Port-Scoped Webhook Firing

Port-scoped webhooks fire for the data port even when the actor is a super admin working cross-port. The webhook context is the affected port, not the actor's session port.

7. Form Expiry UX

Expired public forms return a dedicated expired-state page, not a generic 404, per BR-130.

8. AI Job Safety

  • AI (OpenAI) job outputs are stored in BullMQ job data plus MinIO temp artifacts; do not add permanent AI-result tables in V1.
  • File matching suggestions should use existing pg_trgm on clients.full_name before invoking any AI call.
  • PDF parsing can return partial rows; review UI must allow delete and correction, not only confirm.