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>
78 KiB
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
- Webhook delivery engine design — HMAC-SHA256 signing, exponential backoff, dead letter queue. Solid pattern.
- CSV import wizard UX — 5-step flow (upload → map → preview → configure → execute) is the right approach.
- Custom field dynamic validation — Type-switch validator pattern is clean and extensible.
- Recommendation engine scoring factors — 7 weighted factors for berth matching is comprehensive.
What Needs Fixing
-
Wrong route paths (again) — Every UI reference uses
src/app/(crm)/admin/...instead ofsrc/app/(dashboard)/[portSlug]/admin/.... Port-scoped routes require the[portSlug]dynamic segment — this is the same error across all baseline layers. -
Wrong component paths — Uses
src/components/domain/{entity}/throughout. The project structure is flat:src/components/{entity}/. Nodomain/subdirectory. -
Dark mode colors don't match design tokens — Baseline invents its own dark mode palette (
#0f1729,#1a2340,#243050) instead of using the tokens from15-DESIGN-TOKENS.md§2.2 (#131a2c,#192239,#1e2844). Design tokens are the source of truth — don't reinvent them. -
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.
-
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.
-
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.
-
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.
-
No @pdfme for report PDF generation — Baseline mentions "generate PDF" for scheduled reports but never specifies the library.
14-TECHNICAL-DECISIONS.mdlocks@pdfmeas the PDF generation tool. Reports must use it. -
Missing audit logging in admin services — Webhook CRUD, custom field CRUD, scheduled report CRUD, system settings changes — none of these write to
audit_logsin the baseline. -
Webhook secret stored in plaintext — Schema has
secret TEXTonwebhookstable. Baseline stores HMAC secrets as plaintext. These should be encrypted with AES-256-GCM (same as email credentials) since they're signing secrets perSECURITY-GUIDELINES.md§4.1. -
Missing
next_run_atcalculation — Schema hasnext_run_atonscheduled_reportsbut baseline never shows how it's calculated from the cron expression. Need a cron parser library. -
exceljs not in tech decisions — Baseline uses
exceljsfor Excel export but14-TECHNICAL-DECISIONS.mddoesn't list it. Need to either justify adding it or use an alternative.
2. Implementation Plan
Stream A: Webhooks & Scheduled Reports (Days 1–3)
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_KEYenv var → store ciphertext inwebhooks.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 bodysrc/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-parsernpm 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_atrecalculated every time the report runs or schedule changesreport-schedulerrecurring 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 countexpense-summary.ts— Expenses by category, monthly totals, budget vs actual, top 10 expensesberth-occupancy.ts— Occupied vs available vs maintenance, occupancy %, berth utilization by typeactivity-log.ts— Actions by user, entity type breakdown, busiest days/hoursoverdue-items.ts— Overdue invoices, overdue reminders, unsigned documents past deadlinerevenue-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
@pdfmetemplate 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— takesReportData+ 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 → updatelast_run_at+ calculatenext_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 2–4)
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 thanexceljsfor import.xlsxis 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_historytable (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:
- Query entities with filters + port scope
- Include custom field values as additional columns (if custom fields exist for entity type)
- Generate file:
- CSV:
papaparseunparse - Excel:
xlsx(SheetJS) write — creates workbook with headers, auto-width columns, styled header row
- CSV:
- Upload to MinIO:
{portSlug}/exports/{entityType}-{timestamp}.{ext} - Generate presigned URL (15-min expiry)
- Emit Socket.io event to
user:{userId}:{ jobId, downloadUrl, filename } - Create notification with download link
- 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:
- Select & Upload — Entity type dropdown + file drag-and-drop zone (shadcn's dropzone pattern)
- Map Columns — Two-column layout: source columns (left) ↔ target fields (right, dropdown). Auto-mapped columns pre-selected with green check. Unmapped columns highlighted yellow.
- Preview — Summary cards: ✓ Valid rows, ⚠ Errors, 🔄 Duplicates. Expandable error table with row number, field, error message. Sample of first 5 transformed rows.
- Configure — Duplicate handling: skip / update existing. Confirm button with total count.
- 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 containersrc/components/admin/column-mapper.tsx— Drag-to-map or dropdown column mappingsrc/components/admin/import-preview.tsx— Preview table with validation resultssrc/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 3–5)
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]→ includescustomFieldsarray in responsePATCH /api/v1/clients/[id]→ acceptscustomFieldsin body → callssetValues()
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 formsrc/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 Inputnumber→ shadcn Input withtype="number"date→ shadcn DatePickerboolean→ shadcn Switchselect→ shadcn Select with options fromselect_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.tsjob processor to:- Fetch custom field definitions for entity type
- For each exported row, join custom field values
- Append custom field columns after standard columns
- Column headers use
field_label
Custom fields in saved views:
- Update saved view
column_configto supportcustom:{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 colorsrc/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 4–7)
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.preferencesJSONB 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:
- Sidebar — Already collapsible from L0. On
< md: auto-collapse to icon-only, hamburger button in topbar. Overlay mode on mobile (Sheet component). - Data tables — On
< lg: horizontal scroll withScrollArea. On< sm: switch to card layout usingDataTableMobileViewcomponent. - Forms — On
< md: single-column layout. Multi-column grids collapse to stack. - Pipeline board — On
< md: horizontal scroll with snap-to-card. Touch-friendly drag. - Dashboard widgets — On
< md: single-column stack. Widgets full-width. - Dialogs/modals — On
< sm: full-screen viaDialogwithclassName="sm:max-w-[425px] max-sm:h-full max-sm:max-h-full max-sm:rounded-none". - 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 breakpointsrc/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:
-
Archive/restore buttons — On every entity detail page (clients, interests, berths, expenses, invoices). Archive = set
archived_at = now(). Restore = setarchived_at = null. -
List page filtering — All list endpoints already support
?includeArchived=true. Default: exclude archived. Add "Show archived" toggle on list page toolbar. -
Visual indicator — Archived records show a yellow "Archived" badge in lists and detail views.
-
Bulk archive — Uses existing bulk operations from L3.
POST /api/bulk/status-changewith actionarchive. -
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_ratestable (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 servicesrc/components/admin/queue-overview.tsx— Queue stats cardssrc/components/admin/queue-detail-table.tsx— Jobs table with actionssrc/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:
- Cover page — Port logo, report title, date range, generation date
- Executive summary — Revenue total, expense total, net, berth occupancy %, pipeline count
- Expense detail — Table grouped by category, with receipt thumbnails (from MinIO)
- Revenue breakdown — Invoices paid in period, by client, by berth
- Berth occupancy — Available/occupied/maintenance counts, occupancy percentage
- Pipeline summary — Interests by stage, new inquiries in period
- 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
aiqueue (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_recommendationswithsource = '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):
- Port basics — Name, slug, logo upload, timezone, default currency
- Initial berths — Upload berth spec (reuses AI berth import) OR skip
- Add users — Invite team members with role assignment
- 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)
- Admin can create a webhook with name, URL, and selected events; signing secret is shown once on creation
- Admin can update webhook name, URL, events, and active/inactive status
- Admin can delete a webhook; all delivery history is cascade-deleted
- Admin can regenerate a webhook signing secret
- When a subscribed event fires, a delivery is enqueued to BullMQ within 100ms (non-blocking)
- Webhook delivery includes HMAC-SHA256 signature in
X-Webhook-Signatureheader - Webhook delivery retries 3 times with exponential backoff (30s, 300s, 3000s)
- Failed delivery after all retries creates a system alert notification for super admin
- Delivery log shows: timestamp, event type, status (success/failed/dead_letter), response code, response time
- Test webhook sends a simulated event payload and records the delivery result
- Webhook signing secrets are encrypted at rest with AES-256-GCM
- All webhook CRUD operations write to audit_logs
Scheduled Reports (AC-L4-13 through AC-L4-22)
- Admin can create a scheduled report with type, cron schedule, and email recipients
- Six report types available: pipeline summary, expense summary, berth occupancy, activity log, overdue items, revenue forecast
- Report
next_run_atis correctly calculated from cron expression and updates after each run - Report-scheduler recurring job (every minute) picks up due reports and enqueues generation
- Generated reports are PDF files using @pdfme templates with port branding
- Generated PDF is uploaded to MinIO and emailed to all recipients
- Admin can manually trigger a report and download the result
- Admin can update report schedule, recipients, and active status
- Admin can delete a scheduled report
- All report CRUD operations write to audit_logs
Data Import (AC-L4-23 through AC-L4-30)
- Import wizard accepts CSV and Excel files for clients, interests, berths, expenses
- Column auto-mapping suggests target fields using fuzzy header matching
- User can manually adjust column mappings using dropdown selectors
- Preview step shows valid, error, and duplicate row counts with expandable error details
- Duplicate detection identifies: exact email match (clients), mooring_number match (berths)
- User can choose to skip or update duplicates
- Import executes as BullMQ job with progress updates via Socket.io
- Import history page shows past imports with success/error/total counts
Data Export (AC-L4-31 through AC-L4-35)
- Export button available on all entity list pages (clients, interests, berths, expenses, invoices)
- Export supports CSV and Excel formats
- Export respects current filters and visible columns as defaults
- Export includes custom field values as additional columns when custom fields exist
- Export job completes and notifies user with download link via Socket.io
Custom Fields (AC-L4-36 through AC-L4-44)
- Admin can create custom field definitions for clients, interests, and berths
- Field types supported: text, number, date, boolean, select (with configurable options)
- Field names must be unique per port + entity type
- Field type cannot be changed after creation
- Custom fields render dynamically on entity detail pages with appropriate form controls
- Custom field values auto-save on blur with debounced upsert
- Required custom fields show validation error when empty
- Custom fields appear in entity exports as additional columns
- Custom fields available in saved view column configuration
Tags Polish (AC-L4-45 through AC-L4-48)
- Admin can rename tags; name must remain unique within port
- Admin can change tag color using 12 presets or custom hex input
- Admin can delete a tag; confirmation shows count of affected entities
- Admin can merge two tags; all entity associations transfer to target tag
Dark Mode (AC-L4-49 through AC-L4-53)
- Theme toggle (light/dark/system) in user dropdown menu
- Dark mode uses exact color values from
15-DESIGN-TOKENS.md§2.2 - Theme preference persisted to localStorage (instant) and server (synced)
- System preference option follows OS dark/light mode
- All components render correctly in dark mode with no hardcoded colors
Mobile Responsive (AC-L4-54 through AC-L4-58)
- Sidebar collapses to hamburger menu on screens < 768px
- Data tables scroll horizontally or switch to card layout on screens < 640px
- Forms switch to single-column layout on screens < 768px
- All touch targets are minimum 44×44px
- Pipeline board scrolls horizontally with snap on mobile
Scratchpad (AC-L4-59 through AC-L4-63)
- Scratchpad panel slides out from sidebar footer icon
- User can create, edit, and delete quick notes
- Notes auto-save on blur with 500ms debounce
- User can link a note to a client; note content moves to client notes
- Keyboard shortcut Alt+N creates a new scratchpad note
Archiving (AC-L4-64 through AC-L4-67)
- Archive/restore buttons on all entity detail pages
- Archived entities hidden from default list views
- "Show archived" toggle on list pages includes archived records with visual indicator
- Bulk archive available via bulk operations toolbar
Multi-Currency (AC-L4-68 through AC-L4-72)
- Exchange rates refresh every 6 hours from Frankfurter API
- If API unreachable, last cached rate is used (with stale warning in admin)
- ECD rate is fixed at 2.70 per USD (pegged currency)
- Berth detail page shows price in USD, EUR, GBP, and ECD
- Admin can manually override exchange rates
System Monitoring (AC-L4-73 through AC-L4-79)
- System health endpoint returns status of PostgreSQL, Redis, MinIO, Documenso
- Job dashboard shows all BullMQ queues with waiting/active/failed counts
- Admin can view, retry, and delete individual jobs
- System alerts surface dead letter jobs and backup failures
- Admin can acknowledge alerts
- Manual backup trigger creates pg_dump and stores in MinIO
- Backup list shows available backups with download links
Parent Company Export (AC-L4-80 through AC-L4-82)
- Admin can generate a parent company report for a date range
- Report PDF includes: expenses with receipts, revenue, occupancy, pipeline summary, EUR subtotal + 5% fee
- Report uploaded to MinIO with presigned download URL
AI Features (AC-L4-83 through AC-L4-87)
- Berth spec import accepts PDF or Excel files for AI parsing
- Parsed berths show confidence scores; user can review and correct before confirming
- Confirmed berths are created using standard berth creation flow (with validation + audit)
- Manual recommendations can be added to interests alongside AI recommendations
- 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
reportsqueue (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 containssessionId - 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_trgmonclients.full_namebefore invoking any AI call. - PDF parsing can return partial rows; review UI must allow delete and correction, not only confirm.