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

1991 lines
78 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
5. **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.
6. **Wrong component paths** — Uses `src/components/domain/{entity}/` throughout. The project structure is flat: `src/components/{entity}/`. No `domain/` subdirectory.
7. **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.
8. **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.
9. **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.
10. **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.
11. **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.
12. **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.
13. **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.
14. **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.
15. **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.
16. **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`
```typescript
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`
```typescript
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`
```typescript
/**
* 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`
```typescript
/**
* 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:
```typescript
// 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`
```typescript
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`
```typescript
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:
```typescript
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`
```typescript
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:
```typescript
// "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`
```typescript
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`
```typescript
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`
```typescript
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:**
```typescript
// 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`
```typescript
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:**
```typescript
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:**
```typescript
// 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`
```typescript
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`
```typescript
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)
```typescript
/**
* 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:**
```typescript
['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:
```css
/* 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`
```typescript
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`
```typescript
/**
* 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`
```typescript
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:
```typescript
scratchpadOpen: boolean;
toggleScratchpad: () => void;
```
**TanStack Query keys:**
```typescript
['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`
```typescript
/**
* 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`
```typescript
/**
* 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`
```typescript
/**
* 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`
```typescript
/**
* 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
```typescript
// 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)
```typescript
// 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:
```html
<!-- 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)
13. Admin can create a scheduled report with type, cron schedule, and email recipients
14. Six report types available: pipeline summary, expense summary, berth occupancy, activity log, overdue items, revenue forecast
15. Report `next_run_at` is correctly calculated from cron expression and updates after each run
16. Report-scheduler recurring job (every minute) picks up due reports and enqueues generation
17. Generated reports are PDF files using @pdfme templates with port branding
18. Generated PDF is uploaded to MinIO and emailed to all recipients
19. Admin can manually trigger a report and download the result
20. Admin can update report schedule, recipients, and active status
21. Admin can delete a scheduled report
22. All report CRUD operations write to audit_logs
### Data Import (AC-L4-23 through AC-L4-30)
23. Import wizard accepts CSV and Excel files for clients, interests, berths, expenses
24. Column auto-mapping suggests target fields using fuzzy header matching
25. User can manually adjust column mappings using dropdown selectors
26. Preview step shows valid, error, and duplicate row counts with expandable error details
27. Duplicate detection identifies: exact email match (clients), mooring_number match (berths)
28. User can choose to skip or update duplicates
29. Import executes as BullMQ job with progress updates via Socket.io
30. Import history page shows past imports with success/error/total counts
### Data Export (AC-L4-31 through AC-L4-35)
31. Export button available on all entity list pages (clients, interests, berths, expenses, invoices)
32. Export supports CSV and Excel formats
33. Export respects current filters and visible columns as defaults
34. Export includes custom field values as additional columns when custom fields exist
35. Export job completes and notifies user with download link via Socket.io
### Custom Fields (AC-L4-36 through AC-L4-44)
36. Admin can create custom field definitions for clients, interests, and berths
37. Field types supported: text, number, date, boolean, select (with configurable options)
38. Field names must be unique per port + entity type
39. Field type cannot be changed after creation
40. Custom fields render dynamically on entity detail pages with appropriate form controls
41. Custom field values auto-save on blur with debounced upsert
42. Required custom fields show validation error when empty
43. Custom fields appear in entity exports as additional columns
44. Custom fields available in saved view column configuration
### Tags Polish (AC-L4-45 through AC-L4-48)
45. Admin can rename tags; name must remain unique within port
46. Admin can change tag color using 12 presets or custom hex input
47. Admin can delete a tag; confirmation shows count of affected entities
48. Admin can merge two tags; all entity associations transfer to target tag
### Dark Mode (AC-L4-49 through AC-L4-53)
49. Theme toggle (light/dark/system) in user dropdown menu
50. Dark mode uses exact color values from `15-DESIGN-TOKENS.md` §2.2
51. Theme preference persisted to localStorage (instant) and server (synced)
52. System preference option follows OS dark/light mode
53. All components render correctly in dark mode with no hardcoded colors
### Mobile Responsive (AC-L4-54 through AC-L4-58)
54. Sidebar collapses to hamburger menu on screens < 768px
55. Data tables scroll horizontally or switch to card layout on screens < 640px
56. Forms switch to single-column layout on screens < 768px
57. All touch targets are minimum 44×44px
58. Pipeline board scrolls horizontally with snap on mobile
### Scratchpad (AC-L4-59 through AC-L4-63)
59. Scratchpad panel slides out from sidebar footer icon
60. User can create, edit, and delete quick notes
61. Notes auto-save on blur with 500ms debounce
62. User can link a note to a client; note content moves to client notes
63. Keyboard shortcut Alt+N creates a new scratchpad note
### Archiving (AC-L4-64 through AC-L4-67)
64. Archive/restore buttons on all entity detail pages
65. Archived entities hidden from default list views
66. "Show archived" toggle on list pages includes archived records with visual indicator
67. Bulk archive available via bulk operations toolbar
### Multi-Currency (AC-L4-68 through AC-L4-72)
68. Exchange rates refresh every 6 hours from Frankfurter API
69. If API unreachable, last cached rate is used (with stale warning in admin)
70. ECD rate is fixed at 2.70 per USD (pegged currency)
71. Berth detail page shows price in USD, EUR, GBP, and ECD
72. Admin can manually override exchange rates
### System Monitoring (AC-L4-73 through AC-L4-79)
73. System health endpoint returns status of PostgreSQL, Redis, MinIO, Documenso
74. Job dashboard shows all BullMQ queues with waiting/active/failed counts
75. Admin can view, retry, and delete individual jobs
76. System alerts surface dead letter jobs and backup failures
77. Admin can acknowledge alerts
78. Manual backup trigger creates pg_dump and stores in MinIO
79. Backup list shows available backups with download links
### Parent Company Export (AC-L4-80 through AC-L4-82)
80. Admin can generate a parent company report for a date range
81. Report PDF includes: expenses with receipts, revenue, occupancy, pipeline summary, EUR subtotal + 5% fee
82. Report uploaded to MinIO with presigned download URL
### AI Features (AC-L4-83 through AC-L4-87)
83. Berth spec import accepts PDF or Excel files for AI parsing
84. Parsed berths show confidence scores; user can review and correct before confirming
85. Confirmed berths are created using standard berth creation flow (with validation + audit)
86. Manual recommendations can be added to interests alongside AI recommendations
87. 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.