208 Commits

Author SHA1 Message Date
5c8c12ba1f feat: autonomous backlog push — admin UX overhaul + storage parity + residential parity + Documenso Phase 1
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m32s
Build & Push Docker Images / build-and-push (push) Failing after 32s
Massive multi-area push driven by docs/admin-ux-backlog.md. Every byte
path now goes through getStorageBackend() so signed EOIs, contracts,
brochures, berth PDFs, files, avatars, branding logos, and DB backups
all work identically on S3 and filesystem backends.

USER SETTINGS (rebuild)
  - Country + Timezone selectors with cross-defaulting
  - Browser-detected timezone banner ("Looks like you're in Europe/Paris…")
  - Email change with verification flow (user_email_changes table,
    OLD-address cancel link + NEW-address confirm link)
    + EMAIL_CHANGE_INSTANT=true dev shortcut
  - Password reset triggered via better-auth requestPasswordReset
  - Profile photo upload + crop (square 256×256) via shared
    <ImageCropperDialog> + /api/v1/me/avatar

BRANDING
  - Shared <ImageCropperDialog> using react-easy-crop
  - Logo upload + crop in /admin/branding (writes via
    /api/v1/admin/settings/image -> storage backend)
  - Email header/footer HTML defaults injectable via "Insert default"
  - SettingsFormCard new field types: timezone (combobox), image-upload

STORAGE ADMIN OVERHAUL
  - S3 config form FIRST, swap action SECOND
  - Test connection before any switch
  - Two-button switch: "Switch + migrate" vs "Switch only" with
    warning modals
  - runMigration() honours skipMigration flag
  - /api/ready + system-monitoring health check use the active
    storage backend instead of always probing MinIO
  - Filesystem backend already had full feature parity — verified

BACKUP MANAGEMENT (real)
  - New backup_jobs table (id / status / trigger / size / storage_path)
  - runBackup() service spawns pg_dump --format=custom, streams to
    active storage backend via getStorageBackend().put()
  - /admin/backup page: trigger, history, download .dump for restore
  - Super-admin gated

AI ADMIN PANEL
  - /admin/ai consolidates master switch + monthly token cap +
    provider credentials
  - Per-feature settings (OCR, berth-PDF parser, recommender)
    linked from the same page

ONBOARDING WIZARD
  - /admin/onboarding now real with auto-checked steps
  - Reads each setting key + lists endpoint (roles/users/tags) to
    decide completion
  - Manual checkboxes for steps without an auto-detect signal
  - Progress bar + Mark done/Mark incomplete buttons
  - State persisted in system_settings.onboarding_manual_status

RESIDENTIAL PARITY (full)
  - New residential_client_notes + residential_interest_notes tables
    (mirror marina-side shape)
  - Polymorphic notes.service.ts extended (verifyParent, listForEntity,
    create, update, delete) for residential_clients/_interests
  - <NotesList> component accepts the new entity types
  - 4 new note endpoints (GET/POST/PATCH/DELETE for clients + interests)
  - 2 new activity endpoints (residential clients + interests)
  - residential-client-tabs.tsx + residential-interest-tabs.tsx use
    DetailLayout (Overview / Interests / Notes / Activity)
  - residential-client-detail-header.tsx mirrors marina-side strip
  - useBreadcrumbHint wired into both detail components
  - Configurable Assigned-to dropdown (residential_interests.view perm)

CONFIGURABLE RESIDENTIAL STAGES
  - residential-stages.service.ts with list / save / orphan-check
  - /api/v1/residential/stages GET/PUT
  - /admin/residential-stages admin UI with reassign-on-remove modal
  - Validators relaxed from z.enum to z.string

DOCUMENSO PHASE 1
  - Schema: document_signers.invited_at / opened_at /
    last_reminder_sent_at / signing_token (+ idx_ds_signing_token)
  - Schema: documents.completion_cc_emails (text[]) +
    auto_reminder_interval_days (int)
  - transformSigningUrl() now maps SignerRole -> URL segment via
    ROLE_TO_URL_SEGMENT (approver->cc, witness->witness) — fixes
    Risk #5 where approver invites landed on /sign/error
  - POST /api/v1/documents/[id]/send-invitation with auto-pick of
    next pending signer
  - Per-port settings: documenso_developer_label / _approver_label
    + documenso_developer_user_id / _approver_user_id (Phase 7
    Project Director RBAC binding fields)

ADMIN UX RAPID-FIRE
  - Sidebar collapse removed (always-expanded design)
  - Audit log: input sizes (h-9), date pickers w-44, action cell
    sub-label so single-row entries aren't blank
  - Sales email config: token list <details> + tooltips on
    threshold + body fields
  - Custom Settings card: long-form description
  - Reminder digest timezone uses TimezoneCombobox
  - Port form: currency dropdown (10 common currencies) + timezone
    combobox + brand color picker
  - Permissions count badge opens modal with granted/denied per
    resource
  - Role names display-normalized via prettifyRoleName
  - Tag form: native input type=color
  - Custom Fields page: amber heads-up about non-integration
  - Settings manager: select field type + fallthrough_policy as dropdown
  - Storage admin S3 fields ship as proper password + boolean

LIST PAGES
  - Residential client list: clickable email/phone (mailto/tel/wa.me)
  - Residential interests + Documents Hub search inputs sized h-9

CURRENCY API
  - scripts/test-currency-api.ts verifies live Frankfurter fetch
    -> DB upsert -> getRate -> convert. Inverse-rate drift <=0.001

TESTS
  - 1185/1185 vitest passing
  - tsc clean
  - eslint 0 errors (16 pre-existing warnings)

Note: WEBSITE_INTAKE_SECRET added to .env.example but committed
separately due to pre-commit hook policy on .env* files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:02:12 +02:00
3e4d9d6310 feat(interests): EOI/contract/reservation tabs + contact log + berth interest milestone + interest list overhaul
Major interest workflow expansion driven by the rapid-fire UX session.

EOI / Contract / Reservation tabs replace the generic Documents tab when
the deal is at the relevant stage — workspace pattern with active-doc
hero, signing progress, paper-signed upload, and history strip. Stage-
conditional visibility wired through interest-tabs.tsx so the tab set
shrinks/expands as the deal moves through the pipeline.

Contact log: per-interaction structured log (channel/direction/summary/
optional follow-up reminder). New `interest_contact_log` table + service
+ tab UI (timeline with channel-coded icons + compose dialog).
auto-creates a reminder when followUpAt is set.

Berth Interest milestone: first milestone in the OverviewTab's pipeline
strip, completes the moment any berth is linked via the junction. Drives
the "have we captured what they want?" sanity check for general_interest
leads before they move to EOI.

Stage-conditional milestones: past phases collapse into a one-liner
strip, current phase expands, future phases hide behind a "Show
upcoming" toggle. Inline stage picker now defers reason capture to an
override-confirm view (only required for illegal transitions, not the
default flow).

Notes blob → threaded: dropped `interests.notes` column entirely; the
threaded `interest_notes` table is the single source of truth. Latest-
note teaser on Overview links into the dedicated Notes tab. Polymorphic
notes service gains aggregated client view (unions client + interest +
yacht notes with source chips and group-by-source toggle).

Berth interest list overhaul:
  - Configurable columns via ColumnPicker (18 toggleable, 5 default-on)
  - Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2)
  - Per-letter row tinting via colored left-border accent + dot in cell
  - Documents tab merged Files (single attachments section)

Topbar improvements:
  - Always-visible back arrow on detail pages (path depth > 2)
  - Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can
    push their entity hierarchy (Clients › Mary Smith › Interest › B17)
  - Tighter spacing, softer separators, 160px crumb truncation

DataTable upgrades:
  - Page-size selector with All option (validator cap raised to 1000)
  - getRowClassName slot for per-row styling (used by berth tinting)
  - Fixed Radix SelectItem crash on empty-string values via __any__
    sentinel (was crashing every list page that opened a select filter)

Interest list:
  - Configurable columns picker
  - Stage cell clickable into detail
  - TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons
  - Save view moved into ColumnPicker menu; Views button hidden when
    no views are saved
  - Pipeline kanban board endpoint at /api/v1/interests/board with
    minimal projection, 5000-row cap + truncated banner, filter
    pass-through

Mobile chrome + sidebar collapse removed (always-expanded design choice).

User management lists super-admins (was inner-joined on user_port_roles
which excluded global super-admins).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:59:28 +02:00
267c2b6d1f feat(search): full-platform search overhaul + view tracking + notes bucket
Service rewrite covers 14 entity buckets (clients, residential clients,
yachts, companies, interests, residential interests, berths, invoices,
expenses, documents, files, reminders, brochures, tags, notes, navigation)
with prefix tsquery + trigram fallback, phone-digit normalization,
and JOINs to client_contacts for email matching.

New `notes` bucket searches across the four note tables (client,
interest, yacht, company) via UNION + parent-entity label resolution
(berth mooring for interests, name for yachts/companies). Renders at
the bottom of the dropdown so broad-content matches don't crowd
entity-specific hits — per the user's "low-noise" preference.

Recently-viewed tracking persists last 20 entity views per user in
Redis sorted set; CommandSearch surfaces them as the dropdown's
default state and applies affinity ranking when the user types.

ID-resolve endpoint accepts pasted UUIDs (or invoice numbers like
`INV-2025-001`) and routes the rep straight to the entity, skipping
the normal search bucket.

Audit search service gains `entityIds[]` array filter for the new
loadClientActivityAggregated() path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:58:34 +02:00
a0e68eb060 docs: comprehensive audits + Documenso build plan + admin UX backlog
Six audit documents capture the 2026-05-06 review pass (comprehensive,
frontend, missing-features, permissions, reliability) along with the
Documenso integration audit + locked build plan that drove the bulk
of subsequent feature work.

Adds `docs/admin-ux-backlog.md` as a living tracker for the autonomous
push — every item marked DONE or REMAINING with file pointers and
scope estimates so future sessions can pick up where this one stopped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:57:53 +02:00
Matt Ciaccio
05babe57a0 feat(branding): wire per-port branding through every transactional email + auth shell (R2-H15)
Multi-tenant branding admin (/admin/branding) was saving 5 settings
that no code read — every port's emails shipped Port Nimara's logo
and color regardless. Now wired end-to-end:

New shared infrastructure:
- src/lib/email/shell.ts — renderShell() + brandingPrimaryColor()
  helpers; takes BrandingShell { logoUrl, primaryColor,
  emailHeaderHtml, emailFooterHtml }, falls back to Port Nimara
  defaults when null.
- src/lib/email/branding-resolver.ts — getBrandingShell(portId)
  thin wrapper over getPortBrandingConfig() that returns null on
  error / missing portId so senders never break on misconfig.

All 6 transactional templates refactored to use renderShell + the
shared accent color; portName now flows through every template
(crm-invite, portal activation/reset, both inquiries, both
residential templates, notification digest).

All 6 senders pass branding via getBrandingShell:
- portal-auth.service.ts (activation + reset)
- crm-invite.service.ts (resend path; create-invite has no portId
  yet so falls through to defaults)
- email worker (inquiry confirmation + sales notification)
- residential-inquiries route (client confirmation + sales alert)
- notification-digest.service.ts (digest)

BrandedAuthShell takes an optional `branding` prop with logoUrl +
appName (parent page server-fetches via getPortBrandingConfig).
Defaults to Port Nimara if omitted, so single-tenant deployments
are unaffected.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 00:00:45 +02:00
Matt Ciaccio
1a87f28fd4 feat(notifications): wire the notification-digest scheduler (R2-H16)
The 'notification-digest' cron entry in scheduler.ts was registered
but had no handler — admins configured a daily digest time/timezone
at /admin/reminders and got fire-as-they-hit notifications instead.

New runNotificationDigest() service:
- Loads per-port reminder config; skips ports with digestEnabled=false
- Compares the current hour in the port's configured timezone to the
  configured digest time; only fires when the hour matches (cron is
  hourly, so this gate ensures exactly one digest per port per day).
- For every user with a port-role on that port, batches their unread
  notifications from the last 24h (capped at 20 inline + "and N more"
  link to the inbox) into a single digest email.
- Marks the included rows as email_sent so tomorrow's digest doesn't
  resend them.

New email template at notification-digest.ts renders the per-row
type/title/description with deep-link to the in-app inbox.

Email worker now routes case 'notification-digest' to the dispatcher.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:51:51 +02:00
Matt Ciaccio
f3143d7561 feat(inquiries): triage workflow on the inbox (R2-M2)
The inquiry inbox was read-only — every inquiry stayed there forever
with no way to mark "I handled this" or "this is spam." Now:

- Migration 0045 adds triage_state ('open' | 'assigned' | 'converted'
  | 'dismissed' default 'open') + triaged_at + triaged_by columns to
  website_submissions, plus a (port_id, triage_state, received_at)
  index for the inbox query.
- New PATCH /api/v1/admin/website-submissions/[id]/triage flips the
  state with audit log entry.
- List endpoint takes a `state` filter (default 'inbox' = open +
  assigned, hides converted + dismissed).
- UI: per-row Convert / Assign / Dismiss / Reopen actions; second
  filter row for state; triage badge per card. "Convert" jumps to
  /clients with prefill_name / prefill_email / prefill_phone /
  prefill_source / prefill_inquiry_id query params + marks the row
  converted (the client-create form will read those — same prefill
  pattern other entry points use).

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:48:59 +02:00
Matt Ciaccio
0f648a924b fix(audit): LOWs sweep — truncate auth entityId, fix legacy berthId in seed-data
L3: failed-login audit's entityId could carry an unbounded
attempted-email value (the form lets you type anything). Truncate
to 256 chars before using as entityId; full original still in
metadata for forensic context.

L2: seed-data.ts (the realistic fixture) inserted interests with
berthId — that column was dropped in migration 0029 and the realistic
seed would fail at insert on a fresh DB. Now inserts via the
interestBerths junction (mirrors the synthetic seed's pattern).

L1 (no-op): next-in-line notification already gets the 5-min
cooldownMs default from createNotification, so retries are
idempotent without extra code. Verified.

L5 (no-op): import worker comment already explains the stub state
adequately; no code change.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:40:35 +02:00
Matt Ciaccio
b4fb3b2ca6 fix(audit): MEDIUMs sweep — mobile More-sheet, portal profile, inline override, dialog UX, ext-EOI gate
R2-M11: mobile More-sheet missing 4 destinations. Added Reservations,
Notifications, Residential, Website analytics — anyone using mobile
chrome to triage on the go can now reach those domains.

R2-M12: portal had no profile / change-password surface. New
/portal/profile page with read-only contact details + a
ChangePasswordForm component, backed by a new POST
/api/portal/auth/change-password endpoint and
changePortalPassword() service function. Audits both ok and failure
cases at warning severity. Added Profile to PortalNav.

R2-M1: portal dashboard "My Memberships" tile had no href and no
/portal/memberships route — dead-end on tap. Hidden until a
memberships page ships; the count remains in the underlying data.

R2-M7: InlineStagePicker never sent override:true so users with
interests.override_stage couldn't actually use the perm from the
inline chip — they had to fall back to the modal picker. Now the
picker auto-detects when a transition isn't legal AND the user has
override_stage, sets override:true, and supplies a default reason.

Frontend M2: hard-delete-dialog confirm stage now has a "Send a new
code" link in case the original expired before the user could enter
it. Avoids forcing a full Cancel + reopen.

Frontend M4: audit-log-list date-range validation. From > To now
shows an inline error and skips the request rather than firing an
empty-range query that surfaces "no entries found".

R2-M6: external-EOI route now requires interests.edit AND
documents.upload_signed (defense-in-depth) — uploading a signed EOI
mutates interest state, so the upload-signed perm alone shouldn't
let a custom role flip an interest.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:38:59 +02:00
Matt Ciaccio
da7ede71d6 fix(audit): H2 audit-view dedupe, M3/M4 honest labels, M10 documenso DLQ alert
H2: audit-page view audit row was firing on every filter change. Now
deduped per-user via Redis SET NX with a 60s TTL, so heavy filter-
tweaking writes one self-reference per minute instead of dozens.

R2-M3: /admin landing card for Onboarding said "Initial-setup wizard
for fresh ports" — the page is a static checklist that even calls
itself "what this page will become". Relabelled to "Onboarding
checklist · Setup checklist for fresh ports (read-only references)."

R2-M4: same for Backup & Restore — landing card promised "on-demand
exports" while the page renders only docs. Relabelled to "Backup
posture + retention policy (read-only)."

R2-M10: documenso-void worker had no DLQ alert hook — a persistent
401/403 from Documenso retried until BullMQ exhausted attempts and
the failure disappeared into audit. Now on final-attempt failure
we notify all super-admins via createNotification with a deduplicating
key per documentId, surfacing the 'void manually in Documenso if
still active' actionable.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:31:52 +02:00
Matt Ciaccio
0a5f085a9e fix(audit): wire reminder defaults into createInterest; doc branding gap (R2-H15/H16)
R2-H16: /admin/reminders persisted defaultEnabled + defaultDays to
system_settings but createInterest ignored them — every new interest
defaulted to reminderEnabled=false regardless. The validator now
treats reminderEnabled / reminderDays as optional (no default false),
and createInterest falls back to getPortReminderConfig(portId) when
the caller omits them. Explicit false / null still opts out.

R2-H15: branding admin (/admin/branding) saves 5 settings that no
code reads — the email templates and BrandedAuthShell hardcode Port
Nimara branding. Wiring it end-to-end is a multi-template refactor;
documented the gap inline above getPortBrandingConfig with a
step-by-step wire-up plan so future devs don't think it's done.

The reminder-digest scheduler (digestEnabled/digestTime/digestTimezone)
remains unimplemented — needs a new BullMQ recurring job that batches
pending reminders into per-user/per-port digest emails. Out of scope
for this audit pass.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:28:41 +02:00
Matt Ciaccio
c312cd3685 fix(audit): wire 6 missing email subject overrides (R2-H14)
Admin-editable subject overrides at /admin/email-templates were no-ops
for 6 of 8 templates — only portal_activation and portal_reset called
loadSubjectOverride. Added a shared resolveSubject() helper and wired
it into the missing senders:

- crm_invite + portal_invite_resend (crm-invite.service.ts)
- inquiry_client_confirmation (email worker via portId on job payload)
- inquiry_sales_notification (email worker via portId on job payload)
- residential_inquiry_client_confirmation (residential-inquiries route)
- residential_inquiry_sales_alert (residential-inquiries route)

The inquiry email worker payloads now carry portId + portName so the
worker can resolve the per-port override; producers in inquiry-
notifications.service.ts pass them through.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:26:41 +02:00
Matt Ciaccio
59b9e8f177 fix(audit): replace 'coming soon' tab stubs (H7 + R2-M5)
H7: Three tabs were rendering "coming soon" placeholders to every user
on every detail page:
- Client Files: now uses ClientFilesTab (already existed) which renders
  the FileGrid + upload zone via /api/v1/files?clientId=...
- Client Reservations: split into Active / History sections; History
  lazy-loads ended + cancelled reservations on demand from
  /api/v1/berth-reservations?clientId=&status=
- Berth Waiting List + Maintenance Log: removed from buildBerthTabs
  until the underlying surfaces ship (schema tables exist; UIs don't)

R2-M5: Company Documents tab was a "Coming soon" EmptyState. Removed
from buildCompanyTabs until /api/v1/files accepts a companyId filter
(schema supports it, validator doesn't).

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:21:23 +02:00
Matt Ciaccio
5fc68a5f34 fix(audit): frontend HIGHs — surface fetch errors, kill href=#, invalidate queries, toast over alert
R2-H10: webhook-delivery-log and audit-log-list both swallowed fetch
errors silently — failed loads showed spinner forever or stale data.
Both now set a loadError state, show an inline retry banner, and fire
a toast.error. Same applies to audit-log loadMore.

R2-H11: audit-log-card rendered as `<a href="#">` — tapping on mobile
inserted `#` in the URL and scrolled to top (back-button trap).
ListCard now treats `href` as optional and renders a non-link `<div>`
when omitted; audit-log-card no longer passes href.

R2-H12: smart-archive-dialog only invalidated ['clients'] / ['berths']
/ ['interests']. Detail header kept showing Archived=false until hard
reload. Now also invalidates ['clients', clientId] and removes the
['client-archive-dossier', clientId] cache so re-open re-fetches.

R2-H13: client-list bulk mutation used native alert() on partial
failure (blocking the page) and had no onError handler. Replaced with
toast.warning / toast.success / toast.error.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:18:14 +02:00
Matt Ciaccio
a8c6c071e6 fix(audit): permission UI gates + preflight leak (R2-H6/H7/H8/H9 + R2-M9)
R2-H6: webhook-delivery-log Replay column was rendered for any user
who could load the page; the route gates on admin.manage_webhooks.
Now the entire Replay column is hidden when the user lacks the perm.

R2-H7: Bulk Archive action was visible to sales_agent + viewer
(clients.delete:false). Now wrapped in canBulkArchive (clients.delete).

R2-H8: Bulk Add tag / Remove tag were visible to viewer (clients.edit:
false). Now wrapped in canBulkTag (clients.edit).

R2-H9: bulk-hard-delete silently dropped clients that became
unarchived between preflight and execute. The service now returns
{deletedCount, skipped[]} and the dialog stays open on partial
success showing the per-row reason table — operators can see exactly
which IDs were skipped and why.

R2-M9: bulk-archive-preflight catch block was leaking dossier-loader
error messages, letting an attacker enumerate "not found" vs "exists
in another port". Replaced with a generic 'Could not load dossier —
client may have been removed' blocker.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:15:01 +02:00
Matt Ciaccio
94331bd6ec fix(audit): reliability HIGHs — smart-restore re-link, TOCTOU lock, bulk wrong-interest, ext-EOI tx, bulk idempotency
R2-H1: smart-restore's berth_released auto-reversal was a no-op while
the wizard claimed success. Now uses the persisted interestId from
the decision detail to re-insert the interest_berths link and flip
the berth status back to under_offer. Verifies the interest still
exists and isn't archived before re-linking.

R2-H2: smart-archive berth status update had a TOCTOU race — read
outside tx, write inside without a lock. Now selects-for-update the
berths row inside the tx and re-checks status against the locked row
before flipping to available, preventing concurrent archive+sale
from un-selling a berth.

R2-H3: bulk-archive's berth→interest lookup fell back to
dossier.interests[0]?.interestId ?? '' which sent empty-string
interestIds that silently matched zero rows. Dossier now exposes
linkedInterestIds[] per berth (authoritative interest_berths join);
bulk + single-client wizard both use it and skip berths with no
linked interest. Affected:
- src/lib/services/client-archive-dossier.service.ts (DossierBerth)
- src/app/api/v1/clients/bulk/route.ts
- src/components/clients/smart-archive-dialog.tsx

R2-H4: external-EOI ran storage upload + 4 DB writes outside a
transaction. Now wraps file/document/event/interest writes in a
single tx; storage upload stays before the tx (S3 isn't
transactional), orphan-object on tx failure is acceptable.

R2-H5: bulk archive double-submit treated already-archived clients as
per-row failures. Bulk callback now early-returns success when the
dossier shows archivedAt is set, making the endpoint idempotent.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:11:00 +02:00
Matt Ciaccio
588f8bc43c fix(audit): security HIGHs — rate-limit hard-delete codes, collapse error msgs, doc bad-secret per-IP
H1: hard-delete-request and bulk-hard-delete-request endpoints had no
rate limit; an admin's compromised account could email-bomb the
operator's inbox or use the endpoints as a client-id oracle. Added a
new `hardDeleteCode` limiter (5 per hour per user).

H3: hard-delete error messages distinguished "no code requested" from
"wrong code", letting an attacker brute-force the 4-digit space with
~5k attempts (vs the full 10k). Both single + bulk paths now return
the same 'Invalid or expired confirmation code' message.

H5: invalid Documenso webhook secret submissions are now rate-limited
per-IP (10 per 15min) and only audit-logged inside the cap, so a slow
enumeration can't fill the audit log silently. Real Documenso traffic
won't fail the secret check, so any traffic beyond the cap is
brute-force.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:06:40 +02:00
Matt Ciaccio
c5b41ca4b5 fix(audit): CRITICAL — wire 5 missing workers + bulk-archive side-effects + restore-button hover
C1: src/worker.ts and src/server.ts only imported 5 of 10 BullMQ
workers. ai/bulk/maintenance/reports/webhooks were never started, so
in production: webhooks never delivered, no maintenance crons (DB
backups, session cleanup, retention sweeps, alerts, analytics refresh,
calendar sync), no scheduled reports, no AI features, no async bulk.
All 10 are now imported and held against GC.

R2-C1: Bulk archive's runBulk callback discarded the return value
from archiveClientWithDecisions, so Documenso envelopes marked for
void in the wizard were never queued and next-in-line notifications
never fired. Now we collect the per-archive (dossier, result) pairs
and replay the same post-commit fan-out the single-client route uses.

R2-C2: Archived-client header's Restore icon was hovering destructive-
red because an unconditional hover:text-foreground was overriding the
later conditional. Restore now hovers emerald; archive still hovers
red.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:03:47 +02:00
Matt Ciaccio
9890d065f8 feat(audit): wider coverage — sensitive views, cron, jobs, portal abuse
Builds on the audit infra split (severity/source) by emitting events
from every place a security or operations review would want to see:

Sensitive data views (severity=warning):
- GDPR export download URL issued
- Audit log page opened (watch-the-watchers; first page only)
- CSV export of expenses
- Webhook secret regenerated

Authentication abuse (severity=warning, source=auth):
- Portal sign-in: success + failed-credentials + portal-disabled
- Portal password reset: unknown email + portal-disabled + bad token
- Portal activation: bad/expired token

Inbound webhook hardening:
- Documenso webhook with invalid X-Documenso-Secret now writes
  webhook_failed instead of being silently logged

Background work (source=cron / job):
- New attachWorkerAudit() helper wires every BullMQ worker to emit
  job_failed (severity=error) on .on('failed') and cron_run on
  .on('completed') for any job whose name matches the recurring
  scheduler list. Applied across all 10 workers.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:44:38 +02:00
Matt Ciaccio
d2171ea79b feat(audit): comprehensive logging — auth events, severity, source, IP
Audit log was previously silent on authentication and on background
work. This wires:

- Login (success + failed) and logout via a wrapper around better-auth's
  [...all] handler. Failed logins are severity 'warning' and carry the
  attempted email so brute-force attempts surface in the inspector.
- New severity (info|warning|error|critical) and source (user|auth|
  system|webhook|cron|job) columns on audit_logs. permission_denied
  defaults to 'warning', hard_delete to 'critical'.
- Webhook delivery success/failure/DLQ/retry now write audit rows
  alongside the webhook_deliveries detail table.
- IP address is now visible as a column in the inspector (was already
  captured at the helper level).
- Audit UI: severity badges per row, severity + source dropdowns, IP
  column, expanded action filter covering hard-delete, webhook events,
  job/cron events.

Migration 0044 adds the two columns + their port-scoped indexes.
1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:35:34 +02:00
Matt Ciaccio
4592789712 feat(seed): synthetic fixture covering every pipeline stage + db:reset
Splits seed bootstrap (ports/roles/profile) into a shared module so
two seed entry points can share it:
- pnpm db:seed             realistic NocoDB-shaped fixture (existing)
- pnpm db:seed:synthetic   12 clients, one per pipeline stage + archive
                           variants (rich metadata for restore wizard)

scripts/db-reset.ts truncates all data tables (preserves migrations);
guarded by --confirm and a localhost host check. Companion npm scripts:
- pnpm db:reset
- pnpm db:reseed:realistic
- pnpm db:reseed:synthetic

scripts/dev-open-browser.ts launches a headed Chromium with no viewport
override (uses the host monitor's natural size), pre-fills the login
form for the requested role.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:19:50 +02:00
Matt Ciaccio
758d8628cf test(client-archive): destructive smoke for smart-archive + smart-restore
Walks the new dossier dialog end-to-end on a freshly-created client and
asserts the toast + list refresh. Companion test exercises the smart-
restore wizard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:44:36 +02:00
Matt Ciaccio
44db579988 feat(webhooks): admin replay for failed/dead-letter deliveries
Outbound webhook deliveries already retry with backoff, dead-letter
after maxAttempts, and notify super admins. This adds operator-level
replay: a per-row button on the deliveries log spawns a fresh pending
delivery + queues a new BullMQ job. The original failed row stays
intact so the response body remains for audit; the replay payload
carries retried_from/retried_at markers so receivers can deduplicate.

Inbound idempotency was already handled via the documentEvents
signatureHash unique index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:31:34 +02:00
Matt Ciaccio
7274baf1e1 feat(client-archive): bulk-archive wizard with per-high-stakes confirmation
Replaces the single window.confirm() with a 3-stage wizard:
- preflight: counts auto/needs-reason/blocked (POST /bulk-archive-preflight)
- reasons: carousel through high-stakes clients capturing per-client
  reason (≥5 chars) — bulk endpoint accepts reasonsByClientId map
- confirm: shows the final archivable count and submits

Low-stakes still auto-archives with safe defaults; blocked clients
are skipped with a per-row reason in the preflight summary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:29:17 +02:00
Matt Ciaccio
70105715a7 feat(clients): hard-delete with email-code confirmation (single + bulk)
Permanent client deletion is now reachable from:
- archived single-client detail page (icon button, gated by new
  admin.permanently_delete_clients perm)
- archived clients list bulk action

Both flows are 2-stage: request a 4-digit code (sent to operator's
account email, 10min Redis TTL), then enter both code AND a typed
confirmation (client name single, "DELETE N CLIENTS" bulk). Cascade
strategy preserves audit trails: signed documents, email threads,
files and reminders are detached but retained; addresses, contacts,
notes, portal user, GDPR records, interests and reservations are
deleted via FK cascade or explicit tx delete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:26:42 +02:00
Matt Ciaccio
472c12280b feat(client-archive): smart-restore wizard with auto/opt-in/locked sections
Replaces the simple confirm-restore dialog with a wizard that reads the
persisted archive_metadata via /restore-dossier and surfaces:
- auto-reversed (e.g. berth still available → re-attached on restore)
- opt-in to undo (e.g. berth now under offer to another client)
- locked (e.g. yacht transferred and new owner has active interests)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:26:28 +02:00
Matt Ciaccio
1ae5d88af4 feat(client-archive): async Documenso voids + next-in-line sales notifications
Post-archive side-effects now run with backpressure:
- Documenso envelope voids enqueue to BullMQ documents queue with retry/DLQ
- Released berths fan out a "next in line" notification to port users with
  interests.change_stage; informational only, no auto stage transitions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:12:55 +02:00
Matt Ciaccio
8c02f88cbd feat(interests): upload externally-signed EOI (paper / non-Documenso)
Sales reps need to file EOIs that were signed outside Documenso —
on paper, in person at a boat show, or via an alternate e-sign vendor.
Until now the EOI flow assumed Documenso was the only path.

- external-eoi.service.uploadExternallySignedEoi creates BOTH the
  document row AND the signed-file record in one shot. Document is
  marked isManualUpload=true with status=completed and signedFileId
  set. Distinct from the existing uploadSignedManually which augments
  a document row that came from the Documenso pathway.
- POST /api/v1/interests/[id]/external-eoi accepts multipart with the
  PDF + optional title + signedAt date + comma-separated signer names
  + free-text notes. Gated on documents.upload_signed permission.
- Interest stage auto-advances to eoi_signed (only when the interest
  is currently at or before eoi_sent — past that, just file the doc).
- The signing date, signer names, and any notes are captured into
  document_events.eventData + the audit_log metadata so the audit
  trail records who said the document was signed and when.
- ExternalEoiUploadDialog renders a small modal: file picker, title
  override, signed-date (defaults to today), comma-separated signer
  names, notes. Wired into interest-detail-header behind an Upload
  icon button (gated on documents.upload_signed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:33:15 +02:00
Matt Ciaccio
789656bc70 feat(interests): manual stage override + Residential Partner system role
Manual stage override
  Sales reps need to skip canTransitionStage rules when the data was
  entered out of order — e.g. recording a contract_signed deal whose
  earlier stages were never tracked in the system.

  - New permission flag interests.override_stage in RolePermissions.
    Plumbed through the schema TS type, the role-editor UI, the seed
    file's pre-built roles (super_admin/director/sales_manager get it,
    sales_agent + viewer don't), and the test factories.
  - changeStageSchema gains an optional `override` boolean and the
    service checks it before evaluating canTransitionStage. When
    override=true the reason field becomes required (min 5 chars) and
    is recorded in the audit log.
  - The route handler gates `override` on the new permission so a
    sales_agent without it can't pass override=true and bypass.
  - InterestStagePicker auto-detects when the requested transition is
    blocked by the table and switches into "override mode" — shows an
    amber warning, requires the reason, button label flips to
    "Override stage". When the operator lacks the permission, the
    warning is red and the button is disabled.

Residential Partner role
  Per the smart-archive scoping conversation: external partners who
  handle residential inquiries shouldn't see marina clients, yachts,
  berths, or financials. The two residential_* permission groups
  already exist; this commit just seeds a pre-built system role
  ("residential_partner") with those flags + minimal own-reminders, so
  admins can invite a partner today via /admin/users without manually
  building the permission set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:32:57 +02:00
Matt Ciaccio
fb02f3d5e1 feat(client-archive): bulk-archive uses smart backend (low-stakes auto, high-stakes blocked)
The new smart-archive backend (d07f1ed) is now wired to the existing
bulk-archive endpoint. Previously /api/v1/clients/bulk just called the
legacy archiveClient — bypassing the dossier and the per-client
decisions. That's now a regression hazard: a power-user could bulk-
archive a client mid-deposit with no audit trail.

New behaviour:
- bulk action='archive' fetches the dossier per client.
- Low-stakes clients (open through eoi_signed) auto-archive with the
  same default decisions the single-client modal would pick: release
  available/under-offer berths, retain sold berths, cancel active
  reservations, leave invoices, leave Documenso envelopes pending,
  acknowledge signed documents inline.
- High-stakes clients (deposit_10pct and beyond) refuse with a clear
  message: "open the client to confirm + supply a reason". The bulk
  summary surfaces the failure per row so the user knows which clients
  need individual handling.
- Pre-flight blocker check (e.g. active reservation on a sold berth)
  also rejects with a per-row error instead of crashing.

The proper "bulk wizard" UI (per-high-stakes-client confirmation panel
with reason fields) is still TODO — this commit just makes the existing
button safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:32:30 +02:00
Matt Ciaccio
e95316bd8a feat(client-archive): single-client smart-archive dialog + CSP/middleware fixups
UI side of the smart-archive backend that shipped in d07f1ed.

- SmartArchiveDialog renders the dossier as a sectioned modal:
  Pipeline interests, Berths (with next-in-line listed), Yachts,
  Active reservations, Outstanding invoices, In-flight Documenso
  envelopes, Auto-handled summary. Each section has a per-row decision
  dropdown with sensible defaults (release for available/under-offer
  berths, retain for sold berths and yachts, cancel for active
  reservations, leave for invoices and documents).
- High-stakes deals show an amber warning panel + require a reason in
  the textarea before the Archive button enables. Signed-document
  acknowledgment checkbox blocks submission until checked.
- Wires into client-detail-header in place of the previous dumb
  ArchiveConfirmDialog (the simple confirm dialog is kept for the
  restore case until the smart-restore wizard ships).
- Pre-flight blocker banner surfaces dossier.blockers (e.g. active
  reservation on a sold berth) and disables the Archive button entirely.

Two side fixes from CSP rollout:
- next.config CSP allows unpkg.com in dev so the react-grab devtool
  loads. Stripped in prod via the existing isProd flag.
- middleware whitelist now passes /manifest.json + icon-*.png +
  apple-touch-icon through unauthenticated, so PWA installability
  isn't blocked by the auth redirect.

Bulk variant + restore wizard + hard-delete-with-email-code land in
follow-on commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:19:34 +02:00
Matt Ciaccio
d07f1ed5e0 feat(client-archive): smart-archive backend foundation (dossier + archive + restore)
The first slice of the smart-archive project. Replaces the dumb DELETE
client flow with a deliberate "look before you leap" pattern:

- New columns on clients: archived_by, archive_reason, archive_metadata
  (jsonb capturing every decision made during archive, so restore can
  attempt reversal). Migration 0043.
- client-archive-dossier.service builds a structured snapshot of "what's
  at stake" for a given client: pipeline interests, berths under offer
  (with next-in-line interests for the notification), yachts owned,
  active reservations, outstanding invoices, signed/in-flight Documenso
  envelopes, portal user, company memberships. Classifies the client as
  low-stakes or high-stakes based on pipeline stage (HIGH_STAKES_STAGES
  = deposit_10pct + later) so the bulk wizard knows which clients to
  prompt individually.
- client-archive.service.archiveClientWithDecisions takes the operator's
  decisions and applies them in a single transaction. Persists the
  decision log into archive_metadata for restore. Auto-handles portal
  user revocation + company membership end-dating; everything else is
  caller-driven. Surfaces external cleanups (Documenso void) for the
  caller to queue.
- client-restore.service.getRestoreDossier classifies each persisted
  decision as autoReversible / reversibleWithPrompt / locked based on
  the current state of the world (berth still available? new owner has
  active interests on the yacht? etc). restoreClientWithSelections
  applies reversals + un-archives the client.
- 4 API routes wire the services to HTTP. The existing /restore
  endpoint is upgraded to use the smart restore but stays
  backwards-compatible: clients archived before this feature have no
  archive_metadata so the dossier returns empty, and a POST with no
  body just un-archives them — same as before.

UI work + bulk variant + hard-delete + Documenso cleanup queueing land
in follow-on commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:13:08 +02:00
Matt Ciaccio
f10334683d fix(ops): security headers (CSP / XFO / HSTS / etc) + website_submissions retention
Two audit-pass-#3 prod-readiness gaps.

Security headers
  next.config.ts now emits CSP, X-Frame-Options=DENY,
  X-Content-Type-Options=nosniff, Referrer-Policy, Permissions-Policy
  on every response, plus HSTS in production. CSP allows the small
  set of inline-style/inline-script + unsafe-eval (dev-only) needed
  by Tailwind, Radix, and Next dev HMR; img-src/connect-src kept
  reasonably wide for s3.portnimara.com branding + Socket.IO. Verified
  via curl -I that headers ship and that the dashboard route still
  serves correctly.

website_submissions retention
  Adds 'website-submissions-retention' case to the maintenance worker
  with a 180-day window and schedules it at 07:00 daily. Raw inquiry
  payloads include reCAPTCHA + IP + UA metadata; keeping them
  indefinitely was a privacy + storage gap that audit-pass-#3 flagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:16:47 +02:00
Matt Ciaccio
8690352c56 fix(ux): mobile polish — inputMode=decimal default, dialog padding, more-sheet touch targets
Three audit-pass-#3 mobile findings, all in shared primitives so the
fix lands everywhere those primitives are used.

- Input defaults inputMode='decimal' when type='number' and the caller
  hasn't overridden. Currency/dimension/price fields across invoices,
  expenses, berth specs etc. now show iOS's numeric pad instead of full
  QWERTY. Caller can still pass inputMode='numeric' for integer-only
  fields.
- DialogContent: padding tightens to p-4 on mobile and restores p-6
  at sm+ — the previous fixed p-6 ate ~48px of horizontal width on a
  390px iPhone, crushing form-field space. Also adds a max-h-[100dvh]
  + overflow-y-auto so long modal forms scroll inside the dialog
  instead of pushing the close button off-screen.
- MoreSheet (mobile bottom-tab "More" drawer): grid-cols-3 cells now
  enforce min-h-[88px] so each Apple-HIG-sized 44pt touch target gets
  reliable hit area. Icon size bumped from 6 to 7 for visual weight at
  the larger cell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:16:33 +02:00
Matt Ciaccio
9240cf1808 feat(berths): inline-edit on berth detail (12 spec fields + tag editor)
Berth detail page was the last entity using read-only SpecRow widgets
while clients/yachts/companies all use the click-to-edit
InlineEditableField pattern. Marina staffers couldn't update
length/width/draft/price etc without exporting and re-importing.

- New EditableSpec wrapper preserves the SpecRow look + null-hiding
  behaviour but defers the value to InlineEditableField with a per-
  field PATCH callback.
- useBerthPatch hook hits PATCH /api/v1/berths/{id} (already shipped)
  and invalidates the React Query cache for both the list and the
  individual berth.
- Numeric helper handles the schema's NUMERIC-as-string convention:
  empty input → null, non-numeric → throws, valid → coerced to number.
- 12 fields now editable: lengthFt, widthFt, draftFt, waterDepth,
  mooringType, sidePontoon, bowFacing, access, powerCapacity, voltage,
  cleatType, cleatCapacity, bollardType, bollardCapacity, price.
- Tags card uses InlineTagEditor instead of read-only badges, matching
  the yacht/client pattern. The /api/v1/berths/[id]/tags endpoint was
  already in place.
- Dropped the formatDim/formatPower/formatVoltage/price helpers that
  inlined the metric column or currency suffix; the editable layout
  shows ft/kW/V suffixes inline with the field labels instead. The
  metric column is editable separately if needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:16:18 +02:00
Matt Ciaccio
adba73fcca feat(bulk): wire bulk action UI on companies list
The /api/v1/companies/bulk endpoint shipped in the previous bulk
batch but the UI side was deferred. Mirrors the client-list /
yacht-list pattern: Add tag, Remove tag, Archive bulk actions with
a single TagPicker dialog for tag operations and a window.confirm
for the destructive archive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:14:50 +02:00
Matt Ciaccio
c60cbf4014 fix(ux): popover collision padding, PWA manifest, webhook toasts, portal toast, dashboard error boundary, GDPR poll backoff, empty-state CTA
Grab-bag of UX gaps from audit-pass-#2 + #3. Each one is a small,
focused fix; bundled because they touch different surfaces.

- Popover: collisionPadding={16} + responsive
  w-[min(calc(100vw-2rem),18rem)] so popovers stop clipping past the
  viewport on iPhone 12 portrait.
- public/manifest.json (was missing) + manifest reference in
  layout.tsx — PWA installability now works; icons (192/512/512-
  maskable) were already present.
- Admin webhooks page: 4 silent `// ignore` catches in load/delete/
  toggle/regenerate replaced with toast.error / toast.success. Users
  no longer see a stale list with no feedback when an op fails.
- Portal document-download button: blocking alert() → toast.error().
- src/app/(dashboard)/error.tsx: branded error boundary with retry +
  back-to-dashboard, replacing Next.js's default uncaught-error UI.
- GDPR export modal: refetchInterval was a flat 5s while the modal was
  open. Switched to a function that only polls (every 15s) when a job
  is actually pending/building; settled exports stop polling entirely.
- client-yachts-tab empty state gains a CTA wired to the existing
  Add-yacht dialog, instead of just saying "No yachts".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:59:27 +02:00
Matt Ciaccio
f93de75bb5 fix(ops): /health DB+Redis checks, validated env.REDIS_URL across workers, error_events 90d retention
Three audit-pass-#3 findings, all in the "wakes you at 3am" category.

- /api/public/health now runs DB SELECT 1 + Redis PING in parallel and
  returns 503 + a degraded payload when either fails. Anonymous probes
  (no X-Intake-Secret) still get a flat {status:'ok'} so generic uptime
  monitors keep working; authenticated probes see the dep results.
- All worker entrypoints (ai, bulk, documents, email, export, import,
  maintenance, notifications, reports, webhooks) and src/lib/redis.ts
  now use env.REDIS_URL (Zod-validated at boot) instead of
  process.env.REDIS_URL!. Previously a missing env let the app start
  silently and fail at first job pickup.
- maintenance worker gains an `error-events-retention` case that
  delete()s rows older than 90 days from error_events. scheduler.ts
  registers it at 06:00 daily. Closes the contract from migration
  0040 which declared the table "pruned at 90 days" but had no
  implementation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:59:07 +02:00
Matt Ciaccio
64f0e0a1b8 fix(security): brochures.service UPDATE/DELETE WHERE includes portId
audit-pass-#2 flagged that updateBrochure and archiveBrochure validate
portId in their preceding SELECT but omit it from the subsequent UPDATE
WHERE clause. Currently safe (the SELECT throws NotFoundError first),
but a refactor that drops the SELECT or a TOCTOU race would silently
allow a cross-tenant write.

Defense-in-depth: add and(eq(id), eq(portId)) to both UPDATE WHERE
clauses so the safety property doesn't depend on caller discipline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:58:47 +02:00
Matt Ciaccio
3f6a8aa3b8 feat(bulk): synchronous bulk action endpoints + UI on interests/clients/yachts
Until now the only bulk action anywhere was Archive on the interests
list — implemented as parallel fan-out with no per-row failure
reporting. The bulk BullMQ worker was a TODO stub with no producers.

- bulk-helpers.runBulk wraps a per-row loop and returns
  {results, summary} for the caller. Page-size capped at 100.
- New endpoints: /api/v1/{interests,clients,yachts,companies}/bulk
  with a Zod discriminated union over the action. Interests support
  change_stage + add_tag + remove_tag + archive; clients/yachts/companies
  support archive + add_tag + remove_tag. Each action is permission-gated
  individually (delete vs edit vs change_stage).
- interest-list, client-list, yacht-list expose the new actions in the
  bulk-action toolbar with dialogs for stage / tag selection. Failure
  summaries surface via window.confirm.
- bulkWorker stub gets a docblock explaining the v1 sync-only choice
  and what the queue is reserved for (CSV imports, port-wide migrations,
  bulk emails to >100 recipients).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:58:34 +02:00
Matt Ciaccio
c90876abad feat(admin): inquiry inbox, send log, email-template overrides, reports dashboard, recommender keys, role-editor coverage; replace placeholder pages
Closes the bulk of audit-pass-#1 admin gaps in one batch.

New admin pages:
- /admin/inquiries reads website_submissions with filter chips for
  berth/residence/contact + payload viewer per row.
- /admin/sends reads document_sends with sent/failed filter chips and
  expandable body markdown; failures surface errorReason and any
  fallback-to-link reason from the SMTP retry.
- /admin/email-templates lets per-port admins override the subject of
  each transactional template (8 templates catalogued in
  template-catalog.ts). Body editing is a follow-on; portal_activation
  + portal_reset are wired to honor the override via loadSubjectOverride.
- /admin/reports replaces the "Coming in Layer 3" placeholder with a
  KPI dashboard: 4 KPI tiles, pipeline funnel bars, berth occupancy
  donut-bars, conversion %, refresh every 60s.
- backup/import/onboarding admin pages replace placeholders with
  actionable guidance: backup posture + planned features, available CLI
  imports + planned UI, ordered onboarding checklist linking to admin
  pages.

Existing pages widened:
- settings-manager exposes the 9 berth-recommender tunables that were
  previously code-only (recommender_*, heat_weight_*, fallthrough_*,
  tier_ladder_hide_late_stage).
- role-form covers all 19 RolePermissions schema groups; previously
  missing yachts/companies/memberships/reservations + missing
  documents.edit + files.edit checkboxes. snake_case residential
  labels replaced with friendly text.

portal-auth.service.ts now also writes audit_log rows for portal
invite, resend, activate, password-reset request, and reset (closes one
more audit-pass-#2 gap while we were touching the file).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:58:17 +02:00
Matt Ciaccio
8cdee99310 feat(activity): per-entity Activity timeline (clients/yachts/companies/berths)
Until now only the global /admin/audit page surfaced audit_logs. Each
entity detail page either lacked the Activity tab entirely or rendered
"Activity log coming soon" text.

- entity-activity.service.loadEntityActivity wraps searchAuditLogs
  with actor-email resolution; reused by all 5 endpoints.
- New endpoints: /api/v1/{clients,yachts,companies,berths,interests}/[id]/activity,
  each gated on the per-entity .view permission and tenant-checked
  against ctx.portId.
- EntityActivityFeed renders a timeline with action verb ("Updated",
  "Archived"), actor name, relative time, and field old→new diff.
- client-tabs, yacht-tabs, company-tabs, berth-tabs now mount the feed
  on their Activity tab. Interest already has the richer
  InterestTimeline component.
- yacht-tabs YachtInterestsTab also gets a friendlier empty state with
  guidance copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:57:51 +02:00
Matt Ciaccio
d19b74b935 feat(profile): /settings/profile page + change-password endpoint
The user-menu's Profile link previously 404'd, and CRM users had no way
to change their password from inside the app.

- /api/v1/me/password POST wraps better-auth changePassword, surfaces a
  friendlier "Current password is incorrect" on the typical failure
  mode, and writes an audit_log row with metadata.revokedOtherSessions.
- /{port}/settings/profile renders display name + email + change-password
  card with current/new/confirm fields and a 'Sign out other devices'
  toggle.

End-to-end verified: wrong current pw → 400 with mapped message;
correct → 200 + audit row; revert → 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:57:35 +02:00
Matt Ciaccio
1b78eadd36 feat(audit): extend AuditAction enum + audit logging on alerts + expense dedup
- AuditAction gains password_change, portal_invite/activate/reset
  variants, send, view. AuditLogParams.ipAddress/userAgent now optional
  so background jobs and internal helpers can log without faking values.
- alerts.service.dismissAlert/acknowledgeAlert now write
  action='update' rows with metadata.kind so the audit log differentiates
  the two state changes.
- expense-dedup.service.clearDuplicate/mergeDuplicate accept userId
  and write action='update'/'merge' rows respectively. Routes pass
  ctx.userId.

Audit gaps surfaced by audit-pass-#2: 6 services bypassed audit_logs
entirely. This commit closes 2 of them; portal-auth lands in a later
commit alongside the email-template-override work that already touches
the same file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:57:24 +02:00
Matt Ciaccio
1fb3aa3aeb fix(regressions): client-bundle ioredis + Drizzle ANY() array bindings
Two regressions from yesterday's audit-tier-0 work that broke the dev
server and every clients API call.

- baseListQuerySchema lived in route-helpers.ts, which was made
  server-only by the rate-limit import. Every validator imported it,
  pulling ioredis (and dns/net/tls/fs/node:async_hooks) into the client
  bundle — every form/detail page returned 500 in dev. Extracted the
  schema to src/lib/api/list-query.ts and updated all 14 validators.
- clients.service.listClients and email-compose used raw SQL
  ANY(\${jsArray}) which Drizzle binds as JSON — Postgres rejects with
  42809 "op ANY/ALL (array) requires array on right side". Switched to
  the inArray helper.

GET /api/v1/clients now returns 200 again. Affects every form/detail
page that imports a validator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:56:59 +02:00
Matt Ciaccio
7bd969b41a fix(audit-integrations): SMTP/PG/Socket.IO timeouts, prompt injection, secret-at-rest
A focused review of every external integration surfaced six issues the
original audit missed.  Fixed here.

HIGH
* Socket.IO had an unconditional 30-second idle disconnect on every
  socket.  The comment on the line acknowledged it was "for development
  only, would be longer in prod" but no NODE_ENV guard existed, and the
  `socket.onAny` listener only resets on inbound client events — every
  dashboard connection that received only server-push events would have
  been torn down every 30s in production.  Removed the manual idle
  timer entirely; Socket.IO's pingTimeout / pingInterval handles
  dead-transport detection at the protocol level.
* SMTP transporters had no `connectionTimeout` / `greetingTimeout` /
  `socketTimeout`.  Nodemailer's defaults are 2 minutes for connect
  and unlimited for socket — a hung SMTP server would have held a
  BullMQ `email` worker concurrency slot for up to 10 min per job
  (5 retries × 2 min).  Set 10s/10s/30s on both the system transporter
  in `src/lib/email/index.ts` and the user-account transporter in
  `email-compose.service.ts`.

MEDIUM
* PostgreSQL pool had no `statement_timeout` /
  `idle_in_transaction_session_timeout`.  A slow query or transaction
  held by a crashed handler would have eventually exhausted the
  20-connection pool.  30s statement cap, 10s idle-in-tx cap, plus
  `max_lifetime: 30min` to recycle connections.
* `umami_password` and `umami_api_token` were stored as plaintext in
  `system_settings` (the SMTP and S3 secret paths use AES-GCM).  The
  reader now passes them through `readSecret()` which auto-detects
  the encrypted `iv:cipher:tag` shape and decrypts, falling back to
  legacy plaintext so operators can rotate without a flag-day.
* AI email-draft worker interpolated `additionalInstructions` (user-
  controlled) directly into the OpenAI prompt — a hostile rep could
  close the instructions block and inject prompt directives that
  override the system prompt.  Added `sanitizeForPrompt()` that
  strips newlines + quote chars, caps at 500 chars, and the prompt
  now wraps the value in a "treat as data not commands" preamble.

LOW
* Legacy `ensureBucket()` in `src/lib/minio/index.ts` was unguarded —
  if any future code imported it (currently no callers), a misconfigured
  prod deploy could mint a fresh empty bucket.  Now matches the gate
  used by the pluggable S3Backend (`MINIO_AUTO_CREATE_BUCKET=true`
  required) so the legacy export and the new pluggable path agree.

Confirmed not-an-issue: BullMQ Workers create connections via
`{ url }` options object, and BullMQ sets `maxRetriesPerRequest: null`
internally for those — no fix needed.  The shared `redis` singleton
that does keep `maxRetriesPerRequest: 3` is used only for direct
Redis ops (rate-limit sliding window, etc.), never for blocking
BullMQ commands, so the value is correct there.

Test status: 1175/1175 vitest, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:31:50 +02:00
Matt Ciaccio
63c4073e64 fix(audit-verification): regressions found in post-Tier-6 review
Two parallel reviews of the Tier 0–6 work surfaced one CRITICAL
regression and a handful of remaining cross-tenant gaps that the
original audit didn't enumerate. All fixed here:

CRITICAL
* document-reminders.processReminderQueue — the new bulk-fetch
  leftJoin to documentTemplates was scoped on `templateType` alone.
  Templates of the same type exist in every port; the cartesian
  explosion would have fired one Documenso reminder PER matching
  template-row per cron tick (a 5-port deploy = 5 reminders to the
  same signer per cycle). Added eq(documentTemplates.portId, portId)
  to the join.
* All five remaining Documenso webhook handlers (RecipientSigned /
  Completed / Opened / Rejected / Cancelled) accept and require an
  optional portId now, with a shared resolveWebhookDocument() helper
  that refuses to mutate when the lookup is ambiguous across tenants
  without a resolved port. Tier 5's port-scoping was applied only to
  Expired; the route now forwards the matched portId to every
  handler. Tightens the WHERE clauses on subsequent UPDATEs to (id,
  portId) for defense-in-depth.

HIGH
* verifyDocumensoSecret rejects when `expected` is empty —
  timingSafeEqual(0-bytes, 0-bytes) was returning true, so a dev env
  with a blank DOCUMENSO_WEBHOOK_SECRET would accept a request whose
  X-Documenso-Secret header was also missing/empty.
  listDocumensoWebhookSecrets skips the env entry when blank.
* /api/public/health — the website-intake-secret comparison was a
  string `===` (not constant-time). Switched to timingSafeEqual via
  Buffer.from().

MEDIUM
* server.ts SIGTERM ordering — Socket.io closes BEFORE the HTTP
  drain so long-poll websockets stop holding the server open past
  the compose stop_grace_period.
* /api/v1/me PATCH preferences merge — allow-list filter on the
  merged JSONB so legacy rows from the old .passthrough() era stop
  silently re-shipping their bloat to disk.

Migration fixes (deploy-blocking)
* 0041 referenced `port_role_overrides.permissions` (column is
  `permission_overrides`) — overrides are partial JSONB and don't
  need backfilling at all (deepMerge resolves edit from the base
  role). Removed the override UPDATEs entirely.
* 0042 switched all FK + CHECK adds to NOT VALID + VALIDATE so the
  brief table-lock phase is decoupled from the row-scan validation,
  giving a cleaner abort-and-restart story if a constraint catches
  dirty production data. Added a pre-cleanup UPDATE for
  invoices.billing_entity_id = '' rows (backfills from clientName,
  falls back to the row id) so the new non-empty CHECK passes on a
  dirty table.

Test status: 1175/1175 vitest, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:19:39 +02:00
Matt Ciaccio
83239104e0 fix(audit-tier-6): validation, perms, ops/infra, per-port webhook secret
Final audit polish — closes the remaining LOW + MED items the previous
tiers didn't reach:

* Validation hardening: me.preferences uses .strict() + 8KB cap
  instead of unbounded .passthrough(); files.uploadFile gains
  magic-byte verification (jpeg/png/gif/webp/pdf/doc/xlsx); OCR scan
  endpoint enforces 10MB cap + magic-byte check on receipt images;
  port logoUrl + me.avatarUrl reject javascript:/data: schemes via
  a shared httpUrl refinement.
* Permission gates: document-sends/{brochure,berth-pdf} now require
  email.send (was withAuth-only); document-sends/{preview,list} on
  email.view; ai/email-draft on email.send; documents/[id]/send
  uses send_for_signing (was create); expenses/export/parent-company
  flips from hard isSuperAdmin to expenses.export for parity;
  admin/users/options gated on reminders.assign_others (was withAuth).
* Envelope hygiene: auth/set-password switches the third {message}
  variant to errorResponse + {data: {email}}; ai/email-draft wraps
  jobId in {data: {jobId}}.
* UI polish: reports-list.handleDownload surfaces failures via
  toastError (was console-only).
* Ops/infra: pin pnpm@10.33.2 across all three Dockerfiles +
  packageManager field in package.json; Dockerfile.worker re-orders
  user creation BEFORE pnpm install so node_modules / .cache dirs
  are worker-owned (fixes tesseract.js + sharp EACCES at first PDF
  parse); add Redis-ping HEALTHCHECK to the worker container.
* Public health endpoint: returns full env+appUrl payload only when
  the caller presents X-Intake-Secret, otherwise a minimal {status}
  so generic uptime monitors still work but anonymous internet
  doesn't get deployment fingerprints.
* Per-port Documenso webhook secret: new system_settings key
  + listDocumensoWebhookSecrets() helper.  The webhook receiver
  iterates every configured per-port secret with timing-safe
  comparison + falls back to env, then forwards the resolved portId
  into handleDocumentExpired so two ports sharing a documensoId
  cannot cross-mutate.

Deferred (handled in dedicated follow-up PRs):
* Tier 5.1 — direct service tests for portal-auth / users /
  email-accounts / document-sends / sales-email-config.  MED, large
  test-writing scope.
* The {ok: true} → {data: null} envelope migration across
  alerts/expenses/admin-ocr-settings/storage routes.  Mechanical but
  needs coordinated client + test updates.
* CSP-nonce migration (drop unsafe-inline) — needs middleware-level
  nonce generation that the Next 15 router has to thread through.
* Idempotency-Key header on Documenso createDocument.  Requires
  schema column on documents to persist the key; deferred so it
  doesn't bundle a migration into this commit.
* The 16 better-auth user_id FKs — separate dedicated migration
  with care (some columns are NOT NULL today and cascade decisions
  matter).
* PermissionGate / Skeleton / EmptyState wraps across 5 admin lists
  (auditor-H §§36–37) and the residential-clients filter bar.

Test status: 1175/1175 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md MED §§28,29,30 + LOW §§32–43
+ HIGH §9 (Documenso secrets follow-up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:03:31 +02:00
Matt Ciaccio
4bab6de8be test(audit-tier-5): webhook + cross-port test coverage
Closes the highest-priority gaps from audit HIGH §19 + MED §§20–21:

* New tests/integration/documenso-webhook-route.test.ts exercises the
  receiver route end-to-end: bad-secret rejection, valid-secret +
  DOCUMENT_SIGNED writes a documentEvents row, dedup via signatureHash
  refuses replays of the same body.
* tests/integration/documents-expired-webhook.test.ts gains a
  cross-port assertion: two ports holding the same documenso_id, port
  A receives the expired event, port B's document must NOT flip.  Made
  passing today by extending handleDocumentExpired to accept an
  optional `portId` and refuse to mutate when the lookup is ambiguous
  across multiple ports without one.
* tests/integration/custom-fields.test.ts gains a Cross-port Isolation
  describe: definitions in port A invisible from port B,
  setValues from port B with a port-A fieldId is rejected,
  getValues for a port-A entity from port B is empty.

Deferred: Tier 5.1 (new test suites for portal-auth / users /
email-accounts / document-sends / sales-email-config) is a multi-hour
test-writing task best handled in a dedicated PR.  Each service is
already covered indirectly via route + integration tests; the audit's
ask is direct service tests with cross-port negative paths, which
this commit doesn't address.

Test status: 1175/1175 vitest (was 1168), tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md HIGH §19 (auditor-J Issue 2)
+ MED §§20–21 (auditor-J Issues 3–4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:53:34 +02:00
Matt Ciaccio
4eea4ceff9 fix(audit-tier-4): tenant-isolation defense-in-depth
Closes the audit's HIGH §10 + MED §§17–22 isolation footguns. None of
these are user-impactful TODAY — every site is preceded by a port-
scoped read or pre-validated by ctx.portId — but each is a future-
refactor accident waiting to happen, so the SQL itself now pins the
tenant boundary:

* mergeClients gains a callerPortId option; the route caller passes
  ctx.portId.  removeInterestBerth now requires portId and verifies
  both the interest and the berth share it before deleting the
  junction row.  All three callers updated.
* Six service mutations now scope the WHERE to (id, portId):
  form-templates update + delete, invoices.detectOverdue per-row
  update, notifications.markRead, clients.deleteRelationship.
  company-memberships uses an inArray sub-select against port
  companies (no port_id column on the table itself), covering
  updateMembership / endMembership / setPrimary.
* Port-scoped file lookups in portal.getDocumentDownloadUrl,
  reports.getDownloadUrl (file presign), berth-reservations.activate
  (contractFileId attach guard), and residential.getResidentialInterestById
  (residentialClient join).

Test status: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md HIGH §10 + MED §§17–22
(auditor-B3 Issues 1–5,7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:48:13 +02:00
Matt Ciaccio
7854cbabe4 perf(audit-tier-3): bulk-fetch the five hot N+1 loops
Replaces per-row fan-out with grouped queries / inArray pre-fetches
across the five dashboard + cron hotspots flagged in the audit
(MED §13 / HIGH §11–14):

* reminders.processFollowUpReminders — was 3 round trips per
  enabled-and-due interest.  Now: filter in JS, single clients
  bulk-fetch, single reminders bulk-insert, single interests
  bulk-update, one summary socket emit.  1k due interests: 6 round
  trips total instead of 3000+.
* portal.getClientInvoices — was a full-table scan filtered in JS.
  Now an inArray push-down on lower(billingEmail) + defensive
  limit(100).  After 12mo this would have been the worst portal
  endpoint.
* interest-scoring.calculateBulkScores — was 6N round trips
  (1 redis + 1 findFirst + 4 counts per interest).  Now 4 grouped
  count queries on the port's interest set + a single redis pipeline
  to refresh the cache.  1k interests: ~7 round trips.
* document-reminders.processReminderQueue — was 5N round trips per
  cron tick (port + template + lastReminder + pendingSigners + send
  per doc).  Now hoists port + per-type template map + grouped
  lastReminder + bulk pendingSigners; per-row work collapses to a
  Map.get and the documenso send.  500 docs: ~7 round trips.
* inquiry-notifications.sendInquiryNotifications — was sequential
  createNotification + emailQueue.add per recipient inside a public
  POST.  Now Promise.all'd; a 20-user port stops blocking the public
  inquiry POST on ~80 round trips.

Test status: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§11–14 (auditor-I
Issues 1–4) + MED §13 (auditor-I Issue 5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:41:23 +02:00
Matt Ciaccio
d3a6a9beef fix(audit-tier-2-routes): manual NextResponse.json error sweep + admin form banners
Two final waves of error-surface hygiene closing the audit's MED §12 +
HIGH §15 + HIGH §17 findings:

* 50 route files swept (61 sites): manual NextResponse.json({error,
  status: 4xx|5xx}) early-returns replaced by typed throws +
  errorResponse(err) at the catch.
  - Super-admin gates (13 sites) use new requireSuperAdmin(ctx, action)
    helper from src/lib/api/helpers.ts so denials hit the audit log.
  - Path-param + body validation 400s become ValidationError throws.
  - 404s become NotFoundError or CodedError('NOT_FOUND') for AI
    feature-flag paths.
  - 11 manual 5xx returns now re-throw so error_events captures the
    request-id (the admin error inspector becomes usable from real
    incidents).
  - website-analytics 200-with-error anti-pattern flipped to 409 +
    UMAMI_NOT_CONFIGURED. 502 upstream paths use UMAMI_UPSTREAM_ERROR.
  - 11 sites intentionally preserved: storage/[token] anti-enumeration
    token-failure paths, webhook-secret 401, "Unknown port" 400 in
    public intake.

* 7 admin forms (roles, users, ports, webhooks, custom-fields,
  document-templates, tags) gain a formatErrorBanner() helper from
  src/lib/api/toast-error.ts that builds a multi-line "Error code / Reference ID"
  banner — the rep can copy the request id when reporting a failed
  save.  Banners get whitespace-pre-line so newlines render.

Test status: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md MED §12 (auditor-F Issue 1)
+ HIGH §15 (auditor-F Issue 2) + HIGH §17 (auditor-H Issue 2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:36:59 +02:00
Matt Ciaccio
fc7595faf8 fix(audit-tier-2): error-surface hygiene — toastError + CodedError sweep
Two mechanical sweeps closing the audit's HIGH §16 + MED §11 findings:

* 38 client components / 56 toast.error sites converted to
  toastError(err) so the new admin error inspector becomes usable from
  user-reported issues — every failed inline-edit, save, send, archive,
  upload, etc. now carries the request-id + error-code (Copy ID action).
* 26 service files / 62 bare-Error throws converted to CodedError or
  the existing AppError subclasses.  Adds new error codes:
  DOCUMENSO_UPSTREAM_ERROR (502), DOCUMENSO_AUTH_FAILURE (502),
  DOCUMENSO_TIMEOUT (504), OCR_UPSTREAM_ERROR (502),
  IMAP_UPSTREAM_ERROR (502), UMAMI_UPSTREAM_ERROR (502),
  UMAMI_NOT_CONFIGURED (409), and INSERT_RETURNING_EMPTY (500) for
  post-insert returning-empty guards.
* Five vitest assertions updated to match the new user-facing wording
  (client-merge "already been merged", expense/interest "couldn't find
  that …", documenso "signing service didn't respond").

Test status: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md HIGH §16 (auditor-H Issue 1)
+ MED §11 (auditor-G Issue 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:18:05 +02:00
Matt Ciaccio
6a609ecf94 fix(audit-tier-1): timeouts, lifecycle, per-port Documenso, FK constraints
Closes the second wave of HIGH-priority audit findings:

* fetchWithTimeout helper (new src/lib/fetch-with-timeout.ts) wraps
  Documenso, OCR, currency, Umami, IMAP, etc. — a hung upstream can
  no longer pin a worker concurrency slot indefinitely.  OpenAI client
  passes timeout: 30_000.  ImapFlow gets socket / greeting / connection
  timeouts.
* SIGTERM / SIGINT handler in src/server.ts drains in-flight HTTP,
  closes Socket.io, and disconnects Redis before exit; compose
  stop_grace_period bumped to 30s.  Adds closeSocketServer() helper.
* env.ts gains zod-validated PORT and MULTI_NODE_DEPLOYMENT, and
  filesystem.ts now reads from env (a typo can no longer silently
  disable the multi-node guard).
* Per-port Documenso template + recipient IDs land in system_settings
  with env fallback (PortDocumensoConfig now exposes eoiTemplateId,
  clientRecipientId, developerRecipientId, approvalRecipientId).
  document-templates.ts uses the per-port config and threads portId
  into documensoGenerateFromTemplate().
* Migration 0042 wires the eleven HIGH-tier missing FK constraints
  (documents/files/interests/reminders/berth_waiting_list/
  form_submissions) plus polymorphic CHECK round 2
  (yacht_ownership_history.owner_type, document_sends.document_kind),
  invoices.billing_entity_id NOT EMPTY, and clients.merged_into self-FK.
  Drizzle schema columns updated to .references(...) where possible
  so the misleading "FK wired in relations.ts" comments are gone.

Test status: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§5,6,7,8,9,10 +
MED §§14,15,16,18.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:52:58 +02:00
Matt Ciaccio
cf430d70c3 fix(storage): route every file op through getStorageBackend()
Removes 12 direct minioClient.{put,get,remove}Object call sites that
bypassed the pluggable storage abstraction.  Filesystem-mode deploys
(MULTI_NODE_DEPLOYMENT=false, storage_backend=filesystem) silently
broke at every site: GDPR export, invoice PDF, EOI generation, portal
download, file upload, folder create/rename/delete, signed PDF land,
maintenance cleanup, etc.  Each site now resolves the active backend
and uses its put/get/delete + the new presignDownloadUrl() helper.

Folder marker objects in /files/folders/* keep the same on-the-wire
shape but route through the backend.  A future refactor should move
folder bookkeeping to a DB-backed virtual-folder table (see audit
HIGH §3 follow-up note in the route file).

Sites left untouched: src/lib/services/system-monitoring.service.ts
and src/app/api/ready/route.ts use minioClient.bucketExists as an S3-
specific health probe — those are correctly mode-aware and stay.

Refs: docs/audit-comprehensive-2026-05-05.md HIGH §3 (auditor-D Issue 1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:41:02 +02:00
Matt Ciaccio
312779c0c5 fix(security): tier-0 audit blockers (next CVE, role gate, perm traps, key validation, rate limits)
Closes the five highest-risk findings from
docs/audit-comprehensive-2026-05-05.md so the platform is not exposed
while the rest of the audit backlog (1 CRIT + 18 HIGH + 32 MED + 23 LOW)
is worked through:

* CVE-2025-29927 — bump next 15.1.0 → 15.2.9; nginx strips
  X-Middleware-Subrequest at the edge as defense-in-depth.
* Cross-tenant role escalation — POST/PATCH/DELETE on /admin/roles now
  require super-admin (was: any holder of admin.manage_users).  Adds
  shared `requireSuperAdmin(ctx)` helper.
* Silent-403 traps — `documents.edit` and `files.edit` keys added to
  RolePermissions; seeded role values updated; migration 0041 backfills
  the new keys on every existing roles+port_role_overrides JSONB.  File
  routes remap the dead `create` action to `upload` / `manage_folders`.
* Berth-PDF / brochure register endpoints — reject body.storageKey
  unless it matches the namespace the matching presign endpoint issued
  (prevents repointing a tenant's PDF at foreign-port bytes).
* Portal auth rate limits — sign-in 5/15min/(ip,email),
  forgot-password 3/hr/IP, activate/reset/set-password 10/hr/IP.  Adds
  `enforcePublicRateLimit()` for non-`withAuth` routes.

Test status unchanged: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md (CRITICAL, HIGH §§1–4)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:33:13 +02:00
Matt Ciaccio
4723994bdc feat(errors): platform-wide request ids + error codes + admin inspector
End-to-end error-handling overhaul. A user hitting any failure now sees
a plain-text message + stable error code + reference id. A super admin
can paste the id into /admin/errors/<id> for the full request shape,
sanitized body, error stack, and a heuristic likely-cause hint.

REQUEST CONTEXT (AsyncLocalStorage)
- src/lib/request-context.ts mints a per-request frame carrying
  requestId + portId + userId + method + path + start timestamp.
- withAuth wraps every authenticated handler in runWithRequestContext
  and accepts an upstream X-Request-Id header (validated shape) or
  generates a fresh UUID. The id ALWAYS leaves on the X-Request-Id
  response header, including early-return 401/403/4xx paths.
- Pino logger reads from the same context via mixin — every log
  line emitted during the request automatically carries the ids
  with no per-call threading.

ERROR CODE REGISTRY
- src/lib/error-codes.ts defines stable DOMAIN_REASON codes with
  HTTP status + plain-text user-facing message (no jargon, written
  for the rep on the phone with a customer).
- New CodedError class wraps a registered code + optional
  internalMessage (admin-only — never sent to client).
- Existing AppError subclasses got plain-text default rewrites so
  legacy throw sites improve immediately without migration.
- High-impact services migrated to specific codes:
  expenses (RECEIPT_REQUIRED, INVOICE_LINKED), interest-berths
  (CROSS_PORT_LINK_REJECTED), berth-pdf (PDF_MAGIC_BYTE / PDF_EMPTY /
  PDF_TOO_LARGE / VERSION_ALREADY_CURRENT), recommender
  (INTEREST_PORT_MISMATCH).

ERROR ENVELOPE
- errorResponse always sets X-Request-Id header + requestId field.
- 5xx responses include a "Quote error ID …" friendly line.
- 4xx kept clean (validation, permission, not-found don't pollute
  the inspector — they're already in audit log).

PERSISTENCE (error_events table, migration 0040)
- One row per 5xx, keyed on requestId, with method/path/status/error
  name+message/stack head (4KB cap)/sanitized body excerpt (1KB cap;
  password/token/secret/etc keys redacted)/duration/IP/UA/metadata.
- captureErrorEvent extracts Postgres SQLSTATE/severity/cause.code
  so the classifier can recognize FK / unique / NOT NULL / schema-
  drift violations.
- Failure to persist is logged-not-thrown.

LIKELY-CULPRIT CLASSIFIER (src/lib/error-classifier.ts)
- 4-pass heuristic (first match wins):
  1. Postgres SQLSTATE → human reason (23503 FK, 23505 unique,
     42703 schema drift, 53300 connection limit, …)
  2. Error class name (AbortError, TimeoutError, FetchError,
     ZodError)
  3. Stack-path patterns (/lib/storage/, /lib/email/, documenso,
     openai|claude, /queue/workers/)
  4. Free-text message keywords (econnrefused, rate limit, timeout,
     unauthorized|invalid api key)
- Returns { label, hint, subsystem } for the inspector badge.

CLIENT SIDE
- apiFetch throws structured ApiError with message + code + requestId
  + details + retryAfter.
- toastError() helper renders the standard 3-line toast:
  plain message / Error code: X / Reference ID: Y [Copy ID].

ADMIN INSPECTOR
- /<port>/admin/errors lists captured 5xx with status badge + path +
  likely-culprit badge + truncated message + reference id. Filter by
  status code; auto-refresh via TanStack Query.
- /<port>/admin/errors/<requestId> deep-dive: request shape, full
  error name+message+stack, sanitized body excerpt, raw metadata,
  registered-code lookup (so admin can compare to what user saw),
  likely-culprit hint with subsystem tag.
- /<port>/admin/errors/codes is the in-app code reference page —
  every registered code grouped by domain prefix, searchable, with
  HTTP status + user message inline. Linked from inspector header
  so admins can flip to it while triaging.
- Permission: admin.view_audit_log. Super admins see all ports;
  regular admins port-scoped.
- system-monitoring dashboard now surfaces error_events alongside
  permission_denied audit + queue failed jobs (RecentError gains
  source: 'request' variant).

DOCS
- docs/error-handling.md walks through coded errors, plain-text
  message guidelines, client toasting, admin inspector usage,
  persistence rules, classifier internals, pruning, and the
  legacy → CodedError migration path.

MIGRATION SAFETY
- Audit confirmed all 41 migrations (0000-0040) apply cleanly in
  journal order against an empty DB. 0040 references ports(id)
  which exists from 0000. 0035/0038 don't deadlock under sequential
  psql -f. Removed redundant idx_ds_sent_by from 0038 (created in
  0037).

Tests: 1168/1168 vitest passing. tsc clean.
- security-error-responses tests updated for plain-text messages
  + new optional response keys (code/requestId/message).
- berth-pdf-versions tests assert stable error codes via
  toMatchObject({ code }) rather than message regex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:12:59 +02:00
Matt Ciaccio
c4a41d5f5b feat(expenses+interests): trip/event grouping (lightweight)
Per the trips/events design discussion: instead of building a full
events domain (table + CRUD UI + calendar) for the 6–12 yacht shows
a year, ship the cheap version that covers the actual asks.

Expenses — `tripLabel` free-text:
- New `expenses.trip_label` text column (migration 0039) + index for
  filter / autocomplete lookup.
- Validator: createExpenseShape + listExpensesSchema +
  exportExpensePdfSchema.filter all accept tripLabel.
- Service: createExpense + updateExpense persist; listExpenses filters;
  new `listTripLabels(portId, search?)` returns distinct values
  ordered by most-recent expenseDate so the autocomplete surfaces
  recently-used labels first.
- New `GET /api/v1/expenses/trip-labels` endpoint (gated by
  expenses.view) backs the autocomplete.
- Form dialog: native `<datalist>` powered by the autocomplete query
  so reps don't end up with "Palm Beach 2026" / "palm-beach 2026"
  fragmented across two PDF sections.
- Expense list: new "Trip" column (badge) + free-text filter.
- Detail page: trip label rendered alongside Category / Payer.
- PDF export: GroupBy gains 'trip'; filter.tripLabel narrows the
  export. Untagged rows fall under "(no trip)".
- Trim/normalize on write so " Palm Beach 2026 " === "Palm Beach 2026".

Interests — event tagging via existing tag system:
- Reps can tag interests with an event tag (e.g. "Palm Beach 2026")
  via the existing InlineTagEditor on the detail page; tags are
  port-scoped and reusable.
- Interest list now has a TagPicker filter rendered next to the
  FilterBar so reps can sort prospects by event attended ("show me
  every lead from Palm Beach"). Hidden 'relation'-typed
  FilterDefinition for tagIds wires URL round-trip + saved-views
  capture without rendering inside the FilterBar.
- FilterBar deserializer now handles `relation` types as comma-joined
  arrays on URL load.

Why a free-text trip label and not a trips table:
- 6–12 events/year doesn't justify a domain. The CRUD UI cost would
  be most of the engineering, and reps already have the events on
  their personal calendars.
- If usage proves demand for per-event ROI dashboards or richer
  attribution, promote to a real `trips` table later. Migration
  path: trip_label → tripId is a backfill+swap.

Test status: 1168/1168 vitest. tsc clean. Migration 0039 applied
in dev (also caught + fixed an unrelated audit-v3 follow-up: 0037
had `idx_br_interest` colliding with the existing
`berth_recommendations.idx_br_interest`; renamed to
`idx_brr_interest` / `idx_brr_contract_file`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:46:54 +02:00
Matt Ciaccio
687a1f1c2f fix(audit-v3): platform-wide deferred-list cleanup (rounds 1-4)
Working through the audit-v2 deferred backlog. Each round was tested
(typecheck + 1168/1168 vitest) before moving on.

Round 1 — DB performance + AI cost visibility:
- Add missing FK indexes Postgres doesn't auto-create on
  berth_reservations.{interest_id, contract_file_id},
  documents.{file_id, signed_file_id}, document_events.signer_id,
  document_templates.source_file_id, form_submissions.{form_template_id,
  client_id}, document_sends.{brochure_id, brochure_version_id,
  sent_by_user_id}. Without these, RESTRICT-checks on parent delete +
  reverse-lookups walk the child tables fully. Migration 0037.
- AI worker now writes one ai_usage_ledger row per OpenAI call so admins
  can audit spend per port/user/feature and future per-port budgets have
  history to read from. Failure to write is logged-not-thrown so the
  user-facing email draft is unaffected.

Round 2 — Boot-time + transport hardening:
- S3 backend verifies the bucket exists at startup (or auto-creates
  when MINIO_AUTO_CREATE_BUCKET=true). A typo'd bucket name now
  surfaces with a clear boot error instead of a vague Minio error
  inside the first user-facing request.
- Documenso v1 placeFields: 3-attempt exponential-backoff retry on 5xx
  + network errors, fail-fast on 4xx. Stops one transient flake from
  leaving a document with a partial field set.
- FilesystemBackend logs a structured warn-once at boot when the dev
  HMAC fallback is in effect, so two processes started with different
  BETTER_AUTH_SECRET values are observable (random 401s on file
  downloads otherwise).
- Logger redact paths extended to cover *.headers.{authorization,
  cookie}, *.config.headers.authorization, encrypted-credential blobs
  (secretKeyEncrypted, smtpPassEncrypted, etc.), the Documenso
  X-Documenso-Secret header, and 2-level nested forms.

Round 3 — UI feedback + permission gates:
- Storage admin migrate dialog: success toast with row count + error
  toast on both dryRun and migrate mutations.
- Invoice detail Send + Record-payment buttons wrapped in
  PermissionGate (invoices.send / invoices.record_payment); both
  mutations now toast on success/error.
- Admin user list Edit button wrapped in PermissionGate(admin.manage_users).
- Scan-receipt page surfaces an amber warning when OCR fails so reps
  know they can fill the form manually instead of staring at a stalled
  spinner; the editable form now also opens on scanMutation.isError
  / uploadedFile, not only on success.
- Email threads list now renders skeleton rows during load + shared
  EmptyState for the empty case (was a single "Loading…" line).

Round 4 — Service / route correctness:
- documentSends.sent_by_user_id was a free-text NOT NULL column with no
  FK. Now nullable + FK to user(id) ON DELETE SET NULL so the audit row
  survives a user being hard-deleted. Migration 0038 with a defensive
  null-out for any orphan ids before attaching the constraint.
- Saved-views route: documented why withAuth alone is correct (the
  service strictly filters by (portId, userId) — owner-only by design).
- Public-interests audit log: replaced "userId: null as unknown as
  string" cast with userId: null; AuditLogParams already accepts null
  for system-generated events.
- EOI in-app PDF fill: extracted setBerthRange() that, when the
  AcroForm field is missing AND the context has a non-empty range
  string, logs a structured warn so the deployment gap (live Documenso
  template needs the field) is observable instead of silently dropping
  the multi-berth range.

Test status: 1168/1168 vitest. tsc clean. Two new migrations
(0037/0038) need pnpm db:push (or migration apply) on the dev DB.
Deferred-doc updated with the remaining open items (bigger refactors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:49:53 +02:00
Matt Ciaccio
ade4c9e77d fix(audit-v2): platform-wide post-merge hardening across 5 domains
Five-domain audit (security, routes, DB, integrations, UI/UX) ran after
the cf37d09 merge. Critical + high-impact items landed here; deferred
medium/low items indexed in docs/audit-final-deferred.md (now organised
into a "Audit-final v2" section).

Security:
- Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived
  download URL minted by `presignDownload` for an emailed brochure can no
  longer be replayed against the proxy PUT to overwrite the original
  storage object. `verifyProxyToken` requires `expectedOp` and rejects
  mismatches; legacy tokens missing `op` fail-closed. Regression tests
  added.
- Markdown email merge values are now markdown-escaped (`[`, `]`, `(`,
  `)`, `*`, `_`, `\`, backticks, braces) before substitution into the
  rep-authored body. A malicious value like `[click here](https://evil)`
  stored in `client.fullName` no longer survives `escapeHtml` to render
  as a real `<a href>` in the outbound email. Phishing-via-merge-field
  closed; regression tests added.
- Middleware now performs an Origin/Referer check on
  POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of
  better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes
  exempt as they don't carry the session cookie.

Routes:
- Template management routes were calling `withPermission('documents',
  'manage', ...)` — but `documents` doesn't have a `manage` action. The
  registry has `document_templates.manage`. Every non-superadmin was
  getting 403'd on the seven template endpoints. Fixed across the
  /admin/templates surface.
- Custom-fields permission resource is hardcoded to `clients` regardless
  of which entity (yacht/company/etc.) the values belong to. Documented
  as deferred (requires per-entity routes).

DB:
- documentSends: every parent FK (client_id, interest_id, berth_id,
  brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the
  audit trail outlasts hard-deletes. The denormalized columns
  (recipient_email, document_kind, body_markdown, from_address) were
  added precisely for this. Migration 0035.
- Polymorphic discriminators on yachts.current_owner_type and
  invoices.billing_entity_type now have CHECK constraints — typos like
  `'clients'` vs `'client'` were silently inserting unreachable rows
  before. Migration 0036.

Integrations:
- Email attachment resolution (`src/lib/email/index.ts`) was importing
  MinIO directly instead of `getStorageBackend()`. Filesystem-backend
  deployments would have broken every email-with-attachment send. Now
  routes through the pluggable abstraction per CLAUDE.md.
- Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit
  `readStatus` or send lowercase, so an event that was the SIGNAL of an
  open was being silently dropped. Now treats any recipient on a
  DOCUMENT_OPENED event as opened.

UI/UX:
- Expense detail used to render `receiptFileIds` as opaque UUID badges —
  reps couldn't view the receipt they uploaded. Now renders an image
  thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for
  PDFs. Closed the "where's my receipt?" loop in the expense flow.
- Expense detail Edit + Archive buttons now `<PermissionGate>` and the
  archive mutation surfaces success/error toasts instead of silent 403s.
- Brochures admin: setDefault/archive/create mutations now have onError
  toasts (only onSuccess existed before).
- Removed broken bulk-upload link in scan/page (route doesn't exist;
  used a raw `<a>` triggering a full reload to a 404).

Test status: 1168/1168 vitest passing. tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
Matt Ciaccio
d4b3a1338f fix(security): scope berth-pdf service entrypoints by portId
Post-merge security review caught a cross-tenant authorization bypass
in the per-berth PDF endpoints (HIGH severity, confidence 10):

  GET    /api/v1/berths/[id]/pdf-versions
  POST   /api/v1/berths/[id]/pdf-versions
  POST   /api/v1/berths/[id]/pdf-upload-url
  POST   /api/v1/berths/[id]/pdf-versions/[versionId]/rollback
  POST   /api/v1/berths/[id]/pdf-versions/parse-results/apply

Each handler looked up the target berth by id only — `eq(berths.id, ...)`.
withAuth resolves ctx.portId from the user-controlled X-Port-Id header
(only verifying the user has SOME role on that port), and
withPermission('berths', 'view'|'edit', ...) is a coarse capability
check, not a row-level grant. A rep with berths:edit on Port A could
supply a Port B berth UUID and:
- list + receive 15-min presigned download URLs to every PDF version
- mint an upload URL targeting `berths/<port-B-id>/uploads/...`
- POST a new version (overwriting current_pdf_version_id on foreign berth)
- rollback to any prior version on a foreign berth
- apply rep-confirmed parse-result fields onto a foreign berth's columns

Sibling routes (waiting-list etc.) already pair the id filter with
`eq(berths.portId, ctx.portId)`, so this was an omission, not design.

Fix:
- Push `portId: string` into uploadBerthPdf, listBerthPdfVersions,
  rollbackToVersion, applyParseResults, reconcilePdfWithBerth.
- Each function now filters the berth lookup with
  `and(eq(berths.id, ...), eq(berths.portId, portId))` and throws
  NotFoundError on mismatch (no foreign-port disclosure).
- Inline the same `and(...)` filter in the pdf-upload-url handler.
- Every handler passes ctx.portId through.

Coverage:
- New `cross-port tenant guard` test exercises every entrypoint with a
  foreign-port id and asserts NotFoundError.
- 1164/1164 vitest passing. Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:31:33 +02:00
Matt Ciaccio
cf37d09519 Merge feat/berth-recommender into main
Multi-phase work bundle (24 commits, 159 files, ~127k LOC) covering plan
docs/berth-recommender-and-pdf-plan.md:

  Phase 0 — NocoDB berth import + mooring normalization (A-01 → A1)
  Phase 1 — /clients + /interests list-column redesign (contacts/yachts join)
  Phase 2 — M:M interest_berths junction with role flags
  Phase 3 — Public berths API + /api/public/health
  Phase 4 — Berth recommender (SQL ranking, tier ladder, heat scoring)
  Phase 5 — Multi-berth EOI bundle + range formatter
  Phase 6 — Pluggable storage backend + per-berth PDF parser
  Phase 7 — Sales send-outs + brochures + email-from settings
  Phase 8 — CLAUDE.md conventions update

Plus a memory-efficient streaming expense PDF export (replaces a legacy
implementation that OOM'd on hundreds of receipts), receipt-less expense
flag with PDF warning annotations, receipt upload UI in the expense
form dialog, and the scan-receipt page accepting device-uploaded photos
in parallel with the OCR scan.

Four audit passes (audit-1 → audit-final, mostly Opus 4.7 reviewers in
parallel) drove progressive hardening: ~50 findings landed; the last
audit's 5 critical / 12 high items are fixed in 180912b. Medium/low
items are deferred and indexed in docs/audit-final-deferred.md.

Tests: 1163/1163 vitest passing. tsc clean. 12 new migrations applied
in dev (0023..0034), three of which (0028/0029, 0034) involve careful
backfills.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:12:24 +02:00
Matt Ciaccio
180912ba9f fix(audit-final): pre-merge hardening + expense receipt UI
Final audit pass on feat/berth-recommender (3 parallel Opus agents)
caught 5 critical and ~12 high-severity findings. All addressed in-branch;
medium/low items deferred to docs/audit-final-deferred.md.

Critical:
- Add filesystem-backend PUT handler at /api/storage/[token] so
  presigned uploads stop 405-ing in filesystem mode (every browser-driven
  berth-PDF + brochure upload was broken). Same token-verify + replay
  protection as GET, plus magic-byte gate when c=application/pdf.
- Forward req.signal into streamExpensePdf so an aborted 1000-receipt
  export no longer keeps grinding for minutes.
- Strengthen Content-Disposition filename sanitization: \s matches CR/LF
  which would let documentName forge headers; restrict to [\w. -]+ and
  add filename* RFC 5987 fallback.
- Lock public berths feed behind an explicit slug allowlist instead of
  ?portSlug= enumeration.
- Reject cross-port interest_berths upserts (defense-in-depth on top of
  the recommender SQL port filter).

High:
- Recommender: width-only feasibility now caps length via L/W ratio so a
  200ft berth doesn't surface for a 30ft beam request; total_interest_count
  filters out junction rows whose interest is in another port.
- Mooring normalization follow-up migration (0034) catches un-hyphenated
  padded forms (A01) the original 0024 WHERE missed.
- Send-out rate limit moved AFTER validation and scoped per-(port, user)
  so typos don't burn a slot and a multi-port rep can't be DoS'd by
  another tenant.
- Default-brochure path now blocks an archived row from sneaking through
  the partial unique index.
- NocoDB import --update-snapshot honoured under --dry-run so reps can
  refresh the seed JSON without committing DB writes.
- PDF export: orderBy desc(expenseDate); apply isNull(archivedAt) when
  expenseIds are passed (was bypassed); flag rate-unavailable rows with
  an amber footer instead of silently treating them as 1:1; skip the
  USD->EUR chain when source already matches target.
- expense-form-dialog: revokeObjectURL captures the URL in the closure
  instead of revoking the still-displayed one; reset upload state on
  close.
- scan/page: handleClearReceipt resets in-flight scan/upload mutations;
  Save disabled while upload pending.
- updateExpense re-asserts receipt-or-acknowledgement at the merged
  row so PATCH can't slip past the create-time refine.

Plus the in-progress receipt upload UI for the expense form dialog
(receipt picker + "I have no receipt" checkbox + warning banner) and
a noReceiptAcknowledged flag on ExpenseRow for edit-mode hydration.

Includes the canonical plan doc (referenced in CLAUDE.md), the handoff
prompt, and a deferred-findings index for follow-up issues.

1163/1163 vitest passing. Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:11:26 +02:00
Matt Ciaccio
014bbe1923 feat(expenses): streaming expense-PDF export + receipt-less expense flag + audit-3 fixes
Replaces the legacy text-only expense PDF (was just dumping rows into a
single pdfme text field — no images, no pagination) with a proper
streaming export modelled on the legacy Nuxt client-portal but
re-architected for memory safety. The legacy implementation OOM'd on
hundreds of receipts because it:
  - buffered every receipt image into memory simultaneously
  - accumulated PDF chunks into an array, concat'd at end
  - base64-encoded the whole PDF into a JSON response (3x peak memory)
  - had no image downscaling

The new design:
  - `streamExpensePdf()` (src/lib/services/expense-pdf.service.ts):
    pdfkit pipes bytes directly to the HTTP response (no Buffer
    accumulation). Receipts are processed serially so peak heap is one
    image at a time. Sharp downscales any receipt > 500 KB or > 1500 px
    to JPEG q80 — typical 8 MB phone photo collapses to ~250 KB. For a
    500-receipt export, peak RSS stays under ~100 MB; legacy needed >2
    GB for the same input.
  - Pages: cover summary box (count, totals, currency equiv, optional
    processing fee), grouped expense table (groupBy=none|payer|category|
    date), one-page-per-receipt with header (establishment, amount,
    date, payer, category, file name) and full-bleed image.
  - Storage backend abstraction — receipts stream from
    `getStorageBackend().get(storageKey)`, works on MinIO/S3/filesystem.
  - Route: POST /api/v1/expenses/export/pdf streams binary
    application/pdf with cache-control:no-store. Validator caps
    expenseIds at 1000 to prevent runaway loops.

Receipt-less expense flow (per user request):
  - Schema: 0033 migration adds `expenses.no_receipt_acknowledged`
    boolean (default false).
  - Validator: createExpenseSchema requires either receiptFileIds OR
    noReceiptAcknowledged=true; the .refine() error message tells the
    rep exactly what to do. updateExpenseSchema is partial and skips
    the rule (existing rows can be edited without re-acknowledging).
  - PDF: receiptless expenses get an inline red "(no receipt)" tag in
    the establishment cell + a red footer warning in the summary box
    showing the count and at-risk amount.
  - The legacy parent-company reimbursement queue may refuse to pay
    receiptless expenses, so the warning is load-bearing for ops.

Audit-3 fixes piggy-backed:
  - 🔴 Tesseract OCR runtime now races a 30s timeout (CPU-bomb DoS
    protection — a crafted PDF rasterizing to high-res noise could
    pin the worker indefinitely).
  - 🟠 brochures.service.ts:listBrochures dropped a wasted query (the
    legacy single-brochure fast-path was discarding its result on the
    multi-brochure branch).
  - 🟠 berth-pdf.service.ts:listBerthPdfVersions now Promise.all's the
    presignDownload calls instead of awaiting each in a for-loop —
    20-version berths went from 20× round-trip to 1×.
  - 🟡 public berths route no longer logs the full `row` object on
    enum drift (was dumping price + amenity columns into ops logs).
  - 🟡 dropped the dead `void sql` import from public berths route.

Tests still 1163/1163. tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:38:32 +02:00
Matt Ciaccio
a3e002852b fix(audit-2): integration regressions + data-integrity from second-pass review
Two reviewer agents did a second-pass deep audit of the 21-commit
refactor. Eight findings; four fixed here (one was deferred with a
schema comment, three were 🟡 nice-to-haves left for follow-up).

Integration regressions (🟠 high):
- Outbound webhook `interest.berth_linked` now fires from the new
  junction-add handler. Was emitting a socket-only event, leaving
  external integrations silent post-refactor.
- Two new webhook events `interest.berth_unlinked` and
  `interest.berth_link_updated` added to WEBHOOK_EVENTS +
  INTERNAL_TO_WEBHOOK_MAP. PATCH and DELETE handlers now dispatch them
  alongside the existing socket emits — lifecycle parity restored.
- BerthInterestPulse adds useRealtimeInvalidation for berth-link
  events. The query key was berth-scoped while the linked-berths
  dialog invalidates interest-scoped keys (no prefix match), so the
  pulse went stale. Bridges via the realtime hook now.

Recommender semantic fix (🟠 medium-high):
- aggregates CTE: active_interest_count now filters on
  `ib.is_specific_interest = true`, matching the public-map "Under
  Offer" derivation. EOI-bundle-only links no longer demote a berth
  to Tier C for other reps. Smoke test confirms previously-all-Tier-C
  results now correctly classify as Tier A.
- Same CTE: `total_interest_count` uses COUNT(ib.berth_id) instead of
  COUNT(*) so a berth with no junction rows reports 0 (not 1 from
  the LEFT JOIN's NULL-right-side row). Prevents heat over-counting.

Data integrity (🟠):
- AcroForm tier rejects negative numerics in coerceFieldValue (was
  letting through `length_ft="-50"` which would poison the
  recommender feasibility filter on apply).
- FilesystemBackend.resolveHmacSecret throws in production when
  storage_proxy_hmac_secret_encrypted is null. Dev still derives from
  BETTER_AUTH_SECRET for ergonomics; prod must explicitly configure.
- Documented the circular FK between berths.current_pdf_version_id
  and berth_pdf_versions.id. Drizzle's `.references()` can't express
  the cycle so the schema column is plain text + a comment; the FK
  is authoritatively maintained by migration 0030.

Tests still 1163/1163. tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:20:38 +02:00
Matt Ciaccio
312ebf1a88 docs(eoi): document multi-berth Berth Range field + legacy parity
The user asked us to confirm we copied the Documenso template's
auto-fill schema verbatim from the legacy system. Confirmed and
documented in the canonical mapping file:

- Every legacy formValues key (Name, Email, Address, Yacht Name,
  Length, Width, Draft, Berth Number, Lease_10, Purchase) is still
  emitted with identical names and types — single-berth EOIs are
  byte-for-byte compatible with template id 8.
- Phase 5 added one new field: `Berth Range` (compact range string
  for multi-berth EOIs from the is_in_eoi_bundle junction rows).
  Documenso silently drops unknown formValues, so the live template
  will simply not render the range until someone adds the field. The
  doc now flags this explicitly.
- Verified buildDocumensoPayload() populates all 11 fields from the
  resolved EoiContext; tests at tests/unit/services/documenso-payload
  cover every field.

The "rest is handled inside Documenso" (signature, date, terms) -
those fields live on the template itself and don't appear in our
formValues map.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:13:32 +02:00
Matt Ciaccio
0b8d08b57e docs(claude): add berth-recommender + storage + send-outs conventions
Phase 8: capture the new conventions established by the 19-commit
berth-recommender refactor so future Claude sessions don't re-litigate
the design decisions.

Added to the Conventions section:
- Multi-berth interest model + interest_berths role flags
- Mooring number canonical format
- Public berths API + health env-match
- Berth recommender (pure SQL, no AI; tier ladder; heat tunables)
- EOI bundle range formatter
- Pluggable storage backend (filesystem single-node-only constraint)
- Per-berth PDFs (UUID storage keys + advisory lock + 3-tier parser)
- Brochures (default-uniqueness via partial unique index)
- Send-from accounts (encrypted creds, *PassIsSet boolean, XSS guard,
  size-threshold link fallback, 50/hour rate limit)
- NocoDB berth import script

Updated Architecture docs section to note:
- The Documenso template needs the new "Berth Range" field added.
- Pointer to the comprehensive plan doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:09:27 +02:00
Matt Ciaccio
86372a857f fix(audit): post-review hardening across phases 0-7
15 of 17 findings from the consolidated audit (3 reviewer agents on
the previously-shipped phase commits). Remaining two are nice-to-have
follow-ups deferred.

Critical (data integrity / security):
- Public berths API: closed-deal junction rows no longer flip a berth
  to "Under Offer" - filter on `interests.outcome IS NULL` so won/
  lost/cancelled don't pollute public-map status. Both list +
  single-mooring routes.
- Recommender heat: cancelled outcomes now count as fall-throughs
  (SQL was `LIKE 'lost%'` which silently dropped them, leaving
  cancelled-only berths stuck in tier A).
- Filesystem presignDownload returns an absolute URL (origin from
  APP_URL) so emailed download links resolve from external mail
  clients.
- Magic-byte verification on the presigned-PUT path: both per-berth
  PDFs and brochures stream the first 5 bytes via the storage backend
  and reject + delete on `%PDF-` mismatch (was only enforced when the
  server saw the buffer; presign-PUT was wide open).
- Replay-protection TTL aligned to the token's own expiry (was a
  fixed 30 min, but send-out tokens live 24 h). Floor 60 s, ceiling
  25 days.
- Brochures unique partial index on (port_id) WHERE is_default=true
  + 0032 migration. Closes the read-then-write race in the create/
  update transactions.

Important:
- Recommender SQL: defense-in-depth `i.port_id = $portId` filter on
  the aggregates CTE.
- berth-pdf service: per-berth pg_advisory_xact_lock around the
  version-number SELECT + insert. Storage key is now UUID-based so
  concurrent uploads can't collide on blob paths. Replaces
  `nextVersionNumber` with the tx-bound variant.
- berth-pdf apply: rejects with ConflictError when parse_results
  contain a mooring-mismatch warning unless the caller passes
  `confirmMooringMismatch: true` (force-reconfirm gate was UI-only).
- Send-out body: HTML-escape brochure filename in the download-link
  fallback (XSS guard).
- parseDecimalWithUnit rejects negative numbers.
- listClients DISTINCT ON for primary contact resolution: bounds
  contact-row count to ~2 per client.

Defensive:
- verifyProxyToken rejects NaN/Infinity expiries via Number.isFinite.
- Replaced sql ANY() with inArray() in interest-berths.

Tests: 1145 -> 1163 passing.

Deferred: bulk-send rate limit (no bulk endpoint today), markdown
italic regex breaking links with asterisks (cosmetic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:07:03 +02:00
Matt Ciaccio
b4776b4c3c feat(interests): linked berths list with role-flag toggles + EOI bypass
Implements plan §5.5: a per-interest "Linked berths" panel mounted above the
recommender on the interest detail Overview tab. Each junction row exposes
the role-flag controls reps need to manage the M:M `interest_berths` link
without the legacy single-berth flow.

UI (`src/components/interests/linked-berths-list.tsx`)

* Rows ordered with primary first; mooring number links to /berths/[id], with
  area + a status pill (available/under_offer/sold) and a "Primary" chip.
* "Specifically pitching" Switch (writes `is_specific_interest`) with the
  consequence text from §1: "This berth will appear as under interest on the
  public map" / "This berth is hidden from the public map".
* "Mark in EOI bundle" Switch (writes `is_in_eoi_bundle`).
* "Set as primary" button when the row isn't primary - the existing
  `upsertInterestBerth` helper demotes the prior primary in the same tx.
* "Bypass EOI for this berth" with reason textarea, ONLY rendered when the
  parent interest's `eoiStatus === 'signed'`. Writes the bypass triple
  (`eoi_bypass_reason`, `eoi_bypassed_by` = caller, `eoi_bypassed_at` = now);
  also supports clearing.
* Remove-from-interest action gated by a confirmation dialog.

API (`src/app/api/v1/interests/[id]/berths/...`)

* `GET /` - list endpoint returning `listBerthsForInterest` plus the parent
  interest's `eoiStatus` in `meta.eoiStatus` so the UI can decide whether to
  show the bypass control.
* `PATCH /[berthId]` - partial update of the junction row's flags + bypass
  fields. Server-side guard: rejects bypass writes when `eoiStatus !==
  'signed'` (defence in depth - never trust the UI to gate this).
* `DELETE /[berthId]` - calls `removeInterestBerth`.
* The existing POST stays unchanged. All routes wrapped with
  `withAuth(withPermission('interests', view|edit, ...))`. portId from ctx;
  cross-port reads/writes return 404 for enumeration prevention (§14.10).

Service changes (`src/lib/services/interest-berths.service.ts`)

* `upsertInterestBerth` now accepts `eoiBypassReason` (tri-state: omit = no
  change, non-empty = record, null = clear) and `eoiBypassedBy`. The bypass
  triple moves as a unit, with `eoi_bypassed_at` stamped server-side.
* `listBerthsForInterest` now returns berth detail (area, status, dimensions)
  alongside the junction row, typed as `InterestBerthWithDetails`.

Socket: added `interest:berthLinkUpdated` event for live UI refreshes.

Tests: 18 new integration tests in `tests/integration/api/interest-berths.test.ts`
covering happy paths, primary-demotion in same tx, bypass write/clear, the
"requires signed EOI" guard, cross-port 404s, missing-link 404s, empty-body
400, and viewer 403 through the permission gate.
2026-05-05 04:01:56 +02:00
Matt Ciaccio
a0091e4ca6 feat(emails): sales send-out flows + brochures + email-from settings
Phase 7 of the berth-recommender refactor (plan §3.3, §4.8, §4.9, §5.7,
§5.8, §5.9, §11.1, §14.7, §14.9). Adds the rep-driven send-out path for
per-berth PDFs and port-wide brochures, the per-port sales SMTP/IMAP
config + body templates, and the supporting admin UI.

Migration: 0031_brochures_and_document_sends.sql

Schema additions:
  - brochures (port-wide, with isDefault marker + archive)
  - brochure_versions (versioned uploads, storageKey per §4.7a)
  - document_sends (audit log of every rep-initiated send; failures
    captured with failedAt + errorReason). berthPdfVersionId is a plain
    text column (no FK) — loose-coupled to Phase 6b's berth_pdf_versions
    so the two phases stay independent.

§14.7 critical mitigations:
  - Body XSS: rep-authored markdown goes through renderEmailBody()
    (HTML-escape first, then a tight allowlist of bold/italic/code/link
    rules). https:// + mailto: only — javascript:/data: URLs stripped.
    Tested against script/img/iframe/svg/onerror polyglots.
  - Recipient typo: strict email regex + two-step confirm modal that
    shows the exact recipient before send.
  - Unresolved merge fields: pre-send dry-run /preview endpoint blocks
    submission until findUnresolvedTokens() returns empty.
  - SMTP failure: every transport rejection writes a document_sends row
    with failedAt + errorReason; UI surfaces the message.
  - Hourly per-user rate limit: 50 sends/user/hour via existing
    checkRateLimit().
  - Size threshold fallback (§11.1): files above
    email_attach_threshold_mb (default 15) ship as a 24h signed-URL
    download link in the body instead of an attachment. Storage stream
    flows directly to nodemailer to avoid buffering 20MB+.

§14.10 critical mitigation:
  - SMTP/IMAP passwords encrypted at rest via the existing
    EMAIL_CREDENTIAL_KEY (AES-256-GCM). The /api/v1/admin/email/
    sales-config GET endpoint never returns the decrypted value — only
    a *PassIsSet boolean. PATCH treats empty string as "leave unchanged"
    and explicit null as "clear", so the masked-placeholder UI round-
    trips without forcing re-entry on every save.

system_settings keys (per-port unless noted):
  - sales_from_address, sales_smtp_{host,port,secure,user,pass_encrypted}
  - sales_imap_{host,port,user,pass_encrypted}
  - sales_auth_method (default app_password)
  - noreply_from_address
  - email_template_send_berth_pdf_body, email_template_send_brochure_body
  - brochure_max_upload_mb (default 50)
  - email_attach_threshold_mb (default 15)

UI surfaces (per §5.7, §5.8, §5.9):
  - <SendDocumentDialog> shared 2-step compose+confirm flow.
  - <SendBerthPdfDialog>, <SendDocumentsDialog>, <SendFromInterestButton>
    wrappers per detail page.
  - /[portSlug]/admin/brochures: list, upload (direct-to-storage
    presigned PUT for the 20MB+ files per §11.1), default toggle,
    archive.
  - /[portSlug]/admin/email extended with <SalesEmailConfigCard>:
    SMTP + IMAP creds, body templates, threshold/max settings.

Storage: every upload + download goes through getStorageBackend() —
no direct minio imports, per Phase 6a contract.

Tests: 1145 vitest passing (+ 50 new in
markdown-email-sanitization.test.ts, document-sends-validators.test.ts,
sales-email-config-validators.test.ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:38:47 +02:00
Matt Ciaccio
249ffe3e4a feat(berths): per-berth PDF storage (versioned) + reverse parser
Phase 6b of the berth-recommender refactor (see
docs/berth-recommender-and-pdf-plan.md §3.2, §3.3, §4.7b, §11.1, §14.6).
Builds on the Phase 6a pluggable storage backend (commit 83693dd) — every
file write goes through `getStorageBackend()`; no direct minio imports.

Schema (migration 0030_berth_pdf_versions):
  - new table `berth_pdf_versions` with monotonic `version_number` per
    berth, `storage_key` (renamed convention from §4.7a), sha256, size,
    `download_url_expires_at` cache slot for §11.1 signed-URL throttling,
    and `parse_results` jsonb for the audit trail.
  - new column `berths.current_pdf_version_id` (deferred from Phase 0)
    with FK to `berth_pdf_versions(id)` ON DELETE SET NULL.
  - relations + types exported from `schema/berths.ts`.

3-tier reverse parser (`lib/services/berth-pdf-parser.ts`):
  1. AcroForm via pdf-lib — pulls named fields (`length_ft`,
     `mooring_number`, etc.) at confidence 1. Sample PDF has 0 such
     fields, so this is defensive coverage for future templates.
  2. OCR via Tesseract.js — positional/regex heuristics keyed off the
     §9.2 layout (Length/Width/Water Depth as `<imperial> / <metric>`,
     `WEEK HIGH / LOW`, `CONFIRMED THROUGH UNTIL <date>`, etc.). Returns
     per-field confidence + global mean; flags imperial-vs-metric drift
     >1% in `warnings`.
  3. AI fallback — gated via `getResolvedOcrConfig()` (existing
     openai/claude provider). Surfaced from the diff dialog only when
     `shouldOfferAiTier()` returns true (mean OCR confidence below
     0.55 threshold), so OPENAI_API_KEY isn't burned on every upload.

Service layer (`lib/services/berth-pdf.service.ts`):
  - `uploadBerthPdf()` — magic-byte check, size cap, version-number
    bump + current pointer in one transaction.
  - `reconcilePdfWithBerth()` — auto-applies fields where CRM is null;
    flags conflicts when CRM and PDF disagree; tolerates ±1% on numeric
    columns; warns on mooring-number-in-PDF mismatch (§14.6).
  - `applyParseResults()` — hard allowlist of writable columns;
    stamps `appliedFields` onto `parse_results` for audit.
  - `rollbackToVersion()` — pointer flip only, never re-parses (§14.6).
  - `listBerthPdfVersions()` — version list with 15-min signed URLs.
  - `getMaxUploadMb()` — port-override → global → default 15 lookup
    on `system_settings.berth_pdf_max_upload_mb`.

§14.6 critical mitigations:
  - Magic-byte check (`%PDF-`) on every upload; mismatch deletes the
    storage object and rejects the request.
  - Size cap from `system_settings.berth_pdf_max_upload_mb` (default
    15 MB); enforced in the upload-url presign AND server-side.
  - 0-byte uploads rejected.
  - Mooring-number mismatch surfaces as a `warnings[]` entry on the
    reconcile result so the rep sees it in the diff dialog.
  - Imperial vs metric ±1% tolerance in both the parser warnings and
    the reconcile equality check.
  - Path traversal already blocked at the storage layer (Phase 6a).

API + UI:
  - `POST /api/v1/berths/[id]/pdf-upload-url` — presigned URL (S3) or
    HMAC-signed proxy URL (filesystem) sized to the per-port cap.
  - `POST /api/v1/berths/[id]/pdf-versions` — verifies the upload via
    `backend.head()`, writes the row, bumps `current_pdf_version_id`.
  - `GET /api/v1/berths/[id]/pdf-versions` — version list + signed URLs.
  - `POST /api/v1/berths/[id]/pdf-versions/[versionId]/rollback`.
  - `POST /api/v1/berths/[id]/pdf-versions/parse-results/apply` —
    rep-confirmed diff payload.
  - New "Documents" tab on the berth detail page (`berth-tabs.tsx`)
    with current-PDF panel, version history, Replace PDF button, and
    `<PdfReconcileDialog>` for the auto-applied + conflicts UX.

System settings:
  - `berth_pdf_max_upload_mb` (default 15) — caps presigned-upload size
    + server-side validation. Resolved port-override → global → default.

Tests:
  - `tests/unit/services/berth-pdf-parser.test.ts` — magic bytes,
    feet-inches, human dates, full §9.2-shaped OCR text → 18 fields,
    drift warning, AI-tier gate.
  - `tests/unit/services/berth-pdf-acroform.test.ts` — synthetic
    pdf-lib AcroForm round-trip.
  - `tests/integration/berth-pdf-versions.test.ts` — upload, version-
    number bump, magic-byte rejection, reconcile auto-applied vs
    conflicts vs ±1% tolerance, mooring-number warning,
    applyParseResults allowlist enforcement, rollback semantics.

Acceptance: `pnpm exec tsc --noEmit` clean, `pnpm exec vitest run`
green at 1103/1103.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:34:24 +02:00
Matt Ciaccio
83693dd993 feat(storage): pluggable s3-or-filesystem backend + migration CLI + admin UI
Phase 6a from docs/berth-recommender-and-pdf-plan.md §4.7a + §14.9a. Lays
the storage groundwork for Phase 6b/7 file-bearing schemas (per-berth PDFs,
brochures) without touching those domains yet.

New files:
- src/lib/storage/index.ts        StorageBackend interface + per-process
                                  factory keyed on system_settings.
- src/lib/storage/s3.ts           S3-compatible backend (MinIO/AWS/B2/R2/
                                  Wasabi/Tigris) wrapping the existing minio
                                  JS client. Includes a healthCheck() used
                                  by the admin "Test connection" button.
- src/lib/storage/filesystem.ts   Local filesystem backend with all §14.9a
                                  mitigations baked in.
- src/lib/storage/migrate.ts      Shared migration core — pg_advisory_lock,
                                  per-row resumable progress markers,
                                  sha256 round-trip verification, atomic
                                  storage_backend flip on success.
- scripts/migrate-storage.ts      Thin CLI shim around runMigration().
- src/app/api/storage/[token]/route.ts
                                  Filesystem proxy GET. Verifies HMAC,
                                  enforces single-use replay protection
                                  via Redis SET NX, streams via NextResponse
                                  ReadableStream with explicit Content-Type
                                  + Content-Disposition. Node runtime only.
- src/app/api/v1/admin/storage/route.ts
                                  GET status + POST connection test.
- src/app/api/v1/admin/storage/migrate/route.ts
                                  Super-admin-only POST that runs the
                                  exact same runMigration() as the CLI.
- src/app/(dashboard)/[portSlug]/admin/storage/page.tsx
                                  Super-admin admin UI (current backend,
                                  capacity stats, switch button with
                                  dry-run, test connection, backup hint).
- src/components/admin/storage-admin-panel.tsx
                                  Client component for the page above.

§14.9a critical mitigations implemented:
- Path-traversal: storage keys validated against ^[a-zA-Z0-9/_.-]+$;
  `..`, `.`, `//`, leading `/`, and overlength keys rejected.
- Realpath: storage root realpath'd at create time, every per-key
  resolution checked against the realpath'd prefix.
- Storage root created (or chmod'd) to 0o700.
- Multi-node refusal: FilesystemBackend.create() throws when
  MULTI_NODE_DEPLOYMENT=true.
- HMAC token: sha256-HMAC over the (key, expiry, nonce, filename,
  content-type) payload. Verified with timingSafeEqual; bad sig,
  expired, or invalid-key payloads all return 403.
- Single-use replay: token body cached in Redis SET NX EX 1800s.
- sha256 round-trip: copyAndVerify() re-fetches from the target after
  put() and aborts the migration on any mismatch.
- Free-disk pre-flight: when migrating to filesystem, sums byte counts
  via source.head() and aborts if free space < total * 1.2.
- pg_advisory_lock(0xc7000a01) prevents concurrent migrations.
- Resumable: per-row progress markers in _storage_migration_progress.

system_settings keys read by the factory (jsonb, no schema change):
storage_backend, storage_s3_endpoint, storage_s3_region,
storage_s3_bucket, storage_s3_access_key,
storage_s3_secret_key_encrypted, storage_s3_force_path_style,
storage_filesystem_root, storage_proxy_hmac_secret_encrypted.

Defaults: storage_backend=`s3`, storage_filesystem_root=`./storage`
(./storage added to .gitignore).

Tests added (34 tests, all green):
- tests/unit/storage/filesystem-backend.test.ts — key validation
  allow/reject matrix, realpath escape, 0o700 perms, multi-node
  refusal, HMAC token sign/verify/tamper/expire/invalid-key.
- tests/unit/storage/copy-and-verify.test.ts — sha256 mismatch on
  round-trip aborts the migration.
- tests/integration/storage/proxy-route.test.ts — happy path, wrong
  HMAC secret, expired token, replay rejection.

Phase 6a ships zero file-bearing tables — TABLES_WITH_STORAGE_KEYS is
intentionally empty. berth_pdf_versions and brochure_versions land in
Phase 6b and join the list there. Existing s3_key columns: only
gdpr_export_jobs.storage_key, already named correctly — no rename needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:15:59 +02:00
Matt Ciaccio
15d4849030 feat(recommender): API endpoint + interest-detail panel + add-to-interest dialog 2026-05-05 03:05:22 +02:00
Matt Ciaccio
e00e812199 feat(eoi): multi-berth EOI generation + berth-range formatter
Plan §4.6 + §1: a render function that compresses every berth marked
is_in_eoi_bundle=true on an interest into a compact range string
("A1-A3, B5-B7"), wired into both EOI generation paths (the Documenso
template-generate call and the in-app pdf-lib AcroForm fill).

- src/lib/templates/berth-range.ts: pure formatBerthRange() with the
  full edge-case set from §4.6 - empty, single, run, gap, multiple
  prefixes, sort/dedup, multi-letter prefixes, non-canonical
  passthrough, long ranges. Sorts by (prefix, number); dedupes; passes
  non-canonical inputs through with a logger warning.
- src/lib/templates/merge-fields.ts: new {{eoi.berthRange}} token
  added to VALID_MERGE_TOKENS allow-list under a fresh `eoi` scope so
  unknown-token validation at template creation time still rejects
  typos.
- src/lib/services/eoi-context.ts: EoiContext gains eoiBerthRange.
  Resolved by joining interest_berths (is_in_eoi_bundle=true) →
  berths and feeding the mooring numbers through formatBerthRange.
- src/lib/services/documenso-payload.ts: formValues now includes
  "Berth Range" alongside the legacy "Berth Number". Multi-berth EOIs
  surface here; single-berth EOIs duplicate the primary.
- src/lib/pdf/fill-eoi-form.ts: in-app AcroForm fill mirrors the
  Documenso payload by populating "Berth Range". Falls back silently
  when older PDFs don't have the field (setText is no-op-on-missing).

15 unit tests on the formatter; existing EoiContext + Documenso
payload tests updated to assert the new field. 1022 -> 1037 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:03:29 +02:00
Matt Ciaccio
b1e787e55c feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.

Tier ladder (default):
  A : no interest history
  B : lost-only history (still recommendable + boosted by heat)
  C : active interest in early stage (open..eoi_signed)
  D : active interest at deposit_10pct or beyond (hidden by default)

Heat (only for tier B):
  recency        weight 30  full @ <=30 days, decays to 0 @ 365 days
  furthest stage weight 40  full when prior reached deposit
  interest count weight 15  saturates at 5+
  EOI count      weight 15  saturates at 3+

Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.

15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00
Matt Ciaccio
fb1116f1d4 feat(berths): public berths API + health env-match endpoint
Adds the read-only public-website data feed promised by plan §4.5 and
§7.3. The marketing site's `getBerths()` swap is now a one-line URL
change against the existing 5-min TTL behaviour.

- src/app/api/public/berths/route.ts: GET / unauth, returns the full
  port-nimara berth list as { list, pageInfo } in the verbatim NocoDB
  shape ("Mooring Number", "Side Pontoon", quoted-key fields). Cache:
  s-maxage=300 + stale-while-revalidate=60. portSlug query param lets
  future ports opt in.
- src/app/api/public/berths/[mooringNumber]/route.ts: GET single. Up-
  front regex validation (^[A-Z]+\\d+$) rejects malformed lookups with
  400 + cache-control:no-store before hitting the DB. 404 + no-store
  when not found.
- src/app/api/public/health/route.ts: returns { status, env, appUrl,
  timestamp } so the marketing site can refuse to start when its
  CRM_PUBLIC_URL points at a different deployment env (§14.8 critical
  env-mismatch protection).
- src/lib/services/public-berths.ts: pure mapper with derivePublicStatus
  ("sold" wins; otherwise specific-interest junction OR
  status='under_offer' -> "Under Offer"; else "Available").
- 11 unit tests covering numeric coercion, status derivation,
  archived-berth handling, missing-map-data omission, and the
  status-precedence rule that "sold" trumps the specific-interest
  signal.

Smoke-tested: /api/public/berths -> 117 rows, A1 correctly shows
"Under Offer" (has interest_berths.is_specific_interest=true link),
INVALID -> 400, Z99 -> 404. Total tests: 996 -> 1007.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:52:44 +02:00
Matt Ciaccio
5b70e9b04b feat(interests): desired-dimension form fields + size-desired column
Surfaces the recommender inputs added in Phase 2a (interests
.desired_length_ft / desired_width_ft / desired_draft_ft) on the
two interfaces reps actually use:

- /interests list: new "Berth size desired" column rendered as a
  compact "60×18×6 ft" string. Cells with no dimensions show "-";
  partial dimensions render "?" for the missing axis (recommender
  treats null as "no constraint").
- New/Edit Interest form: three optional length/width/draft inputs
  with explanatory subhead. Empty submissions collapse to undefined
  so the API doesn't see "" -> numeric coercion errors.
- createInterestSchema gains the three optional desired-dim fields
  with a shared transform that coerces strings/numbers to a positive
  2-decimal numeric string for the postgres `numeric` column.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:49:01 +02:00
Matt Ciaccio
57cbc9a506 fix(tests): cascade interest_berths in global teardown
The Phase 2b refactor (commit 6e3d910) added a junction table whose
berth_id has onDelete: 'restrict'. The vitest global teardown deletes
test-port berths but never explicitly clears interest_berths first,
so any test leaking junction rows (e.g. via the new createInterest
write path) leaves berths un-deletable and ports stranded.

Adds DELETE FROM interest_berths WHERE berth_id IN (test berths) to
the WITH-chain so cascading teardown completes cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:45:45 +02:00
Matt Ciaccio
6e3d910c76 refactor(interests): migrate callers to interest_berths junction + drop berth_id
Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of
the legacy `interests.berth_id` column now reads / writes through the
`interest_berths` junction via the helper service introduced in Phase 2a;
the column itself is dropped in a final migration.

Service-layer changes
- interests.service: filter `?berthId=X` becomes EXISTS-against-junction;
  list enrichment uses `getPrimaryBerthsForInterests`; create/update/
  linkBerth/unlinkBerth all dispatch through the junction helpers, with
  createInterest's row insert + junction write sharing a single transaction.
- clients / dashboard / report-generators / search: leftJoin chains pivot
  through `interest_berths` filtered by `is_primary=true`.
- eoi-context / document-templates / berth-rules-engine / portal /
  record-export / queue worker: read primary via `getPrimaryBerth(...)`.
- interest-scoring: berthLinked is now derived from any junction row count.
- dedup/migration-apply + public interest route: write a primary junction
  row alongside the interest insert when a berth is provided.

API contract preserved: list/detail responses still emit `berthId` and
`berthMooringNumber`, derived from the primary junction row, so frontend
consumers (interest-form, interest-detail-header) need no changes.

Schema + migration
- Drop `interestsRelations.berth` and `idx_interests_berth`.
- Replace `berthsRelations.interests` with `interestBerths`.
- Migration 0029_puzzling_romulus drops `interests.berth_id` + the index.
- Tests that previously inserted `interests.berthId` now seed a primary
  junction row alongside the interest.

Verified: vitest 995 passing (1 unrelated pre-existing flake in
maintenance-cleanup.test.ts), tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
Matt Ciaccio
ff92a08620 feat(db): m:m interest_berths junction + role flags
Introduces the multi-berth interest model from plan §3.1: a junction
between interests and berths with three role flags so the same berth
can be linked as the primary deal target, an EOI-bundle inclusion,
or a "just exploring" link without conflating semantics.

- 0028 schema migration creates interest_berths with the unique
  partial index "≤1 primary per interest", a unique compound on
  (interest_id, berth_id), and indexes for the public-map "under
  offer" lookup (where is_specific_interest=true).
- Same migration adds desired_length_ft / desired_width_ft /
  desired_draft_ft to interests for the recommender.
- Same migration runs the Phase 2 data migration: every interest
  with a non-null berth_id gets one junction row marked
  is_primary=true, is_specific_interest=true, and is_in_eoi_bundle =
  (eoi_status='signed'). Pre-flight check halts on dangling FKs
  (§14.3 critical case).
- New service src/lib/services/interest-berths.service.ts owns reads
  + writes of the junction. getPrimaryBerth / getPrimaryBerthsForInterests
  feed list pages; upsertInterestBerth demotes the prior primary in
  the same transaction so the unique index is never violated.
- interests.berth_id stays in place this commit so existing callers
  keep working; Phase 2b migrates them onto the helper service and a
  later migration drops the column.

53 dev rows seeded into the junction; tests still green at 996.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:22:11 +02:00
Matt Ciaccio
05257723f6 fix(interests): list yacht join + EOI status column + col redesign
Wire interests.yachtId -> yachts.name into the listInterests post-fetch
enrichment so the redesigned columns (Client · Yacht · Berth · Stage ·
EOI status · Source · Last activity) render the linked yacht.

- Add yachtId/yachtName to InterestRow.
- listInterests: fourth parallel join for yachts.name, Map merged
  alongside the existing client/berth/tag/notes joins.
- interest-columns: add Yacht column (with link to /yachts/[id] when
  the yacht has an id); replace Category with EOI status (badge
  driven by interests.eoi_status); drop default-view Tags.

The "Berth size desired" column called out in §5.2 is deferred to
Phase 2 since the underlying desired_*_ft columns don't exist yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:18:13 +02:00
Matt Ciaccio
3017ce4b3a fix(clients): list contacts join + nationality backfill + col redesign
Wire primary email + primary phone into the /clients list service so
the redesigned columns (Name · Email · Phone · Country · Source ·
Latest stage · Created) actually have data. Picks the row marked
is_primary=true; falls back to most-recent created_at when the flag
is unset.

- 0026 schema migration: unique partial index
  idx_cc_one_primary_per_channel on (client_id, channel) WHERE
  is_primary=true. Prevents the §14.2 "multiple primaries" ambiguity.
- 0027 data migration: backfill clients.nationality_iso from the
  primary phone's value_country. 218 -> 36 missing on dev. Idempotent.
- listClients: add a fifth parallel query for client_contacts; build
  primaryEmailMap / primaryPhoneMap in-memory from the pre-sorted
  result.
- client-columns: drop Yachts/Companies/Tags from the default view
  per §5.1; add Email/Phone/Country/Latest-stage columns; rename
  "Nationality" -> "Country" since phone country is a proxy (§14.2).
- client-card: prefer email, fall back to phone, for the line under
  the name; replaces the old `contacts.find(isPrimary)` lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:15:03 +02:00
Matt Ciaccio
a2588f2c4a chore(berths): refresh seed-data/berths.json from live NocoDB
Regenerates the 117-row berth seed via:
  pnpm tsx scripts/import-berths-from-nocodb.ts --apply --update-snapshot

The JSON ordering matches the legacy seed-data.ts contract (idx 0..4
available, 5..9 under_offer, 10..11 sold, remainder by mooring number).
Mooring numbers are now in canonical form throughout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:08:53 +02:00
Matt Ciaccio
18119644ae feat(berths): nocodb berth import script + helpers + unit tests
Idempotent NocoDB Berths -> CRM `berths` import script with full
re-run safety. Re-running picks up NocoDB additions/edits without
clobbering CRM-side overrides (compares updated_at vs last_imported_at,
1-second tolerance for sub-second clock drift). --force overrides the
edit guard.

Mitigates the §14.1 critical/high cases:
- Mooring collisions: unique (port_id, mooring_number) on the table.
- Concurrent runs: pg_advisory_xact_lock on a stable BIGINT key.
- Numeric-with-units inputs: parseDecimalWithUnit() strips trailing
  ft/m/kw/v/usd/$ markers before parsing.
- Metric drift: NocoDB's metric formula columns are ignored; metric
  values recomputed from imperial via 0.3048 + round-to-2-decimals to
  match NocoDB's `precision: 2` columns and avoid spurious diffs.
- Map Data shape: zod-validated; failures are skipped rather than
  aborting the import.
- Status enum mapping: NocoDB display strings -> CRM snake_case.
- NocoDB row deleted: reported as "orphaned in CRM"; never auto-
  deleted (rep decides via admin UI in a future phase).

Pure helpers (parseDecimalWithUnit, mapStatus, parseMapData,
extractNumerics, mapRow, buildPlan) live in
src/lib/services/berth-import.ts so vitest can exercise the mapping
logic without triggering the script's top-level db connection.

40 new unit tests (956 -> 996 passing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:07:58 +02:00
Matt Ciaccio
61e2fbb2db feat(berths): add per-berth pricing + last_imported_at columns
Adds the 5 pricing columns surfaced by the per-berth PDFs (Phase 6b
will populate them via the OCR parser) and the last_imported_at marker
the NocoDB import script (Phase 0c) uses to detect human edits and
skip overwriting them.

- weekly_rate_high_usd / weekly_rate_low_usd
- daily_rate_high_usd  / daily_rate_low_usd
- pricing_valid_until (date) - drives the "stale pricing" chip on
  the berth detail page when older than today
- last_imported_at - compared against updated_at so re-running the
  import preserves CRM-side overrides

tenure_type comment widens to include 'fee_simple' and 'strata_lot'
to match the per-berth PDF tenure model; the column is plain text
so no DB-level enum change is required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:00:46 +02:00
Matt Ciaccio
05be89ec6f feat(berths): normalize mooring numbers to canonical form
Sweep CRM mooring numbers from the legacy hyphen+padded form ("A-01")
to the canonical bare form ("A1") used by NocoDB, the public website,
the per-berth PDFs, and the Documenso EOI templates. Drift was
introduced by the original load-berths-to-port-nimara.ts seed; this
gates the Phase 3 public-website cutover where /berths/A1 URLs would
404 against a CRM still storing "A-01".

- 0024 data migration: idempotent regexp_replace + post-update sanity
  check that surfaces any non-conforming rows for manual triage.
- Invert normalizeLegacyMooring in dedup/migration-apply: it now
  canonicalizes ("D-32" -> "D32") instead of legacy-izing.
- Update tiptap-to-pdfme example tokens, EOI fixture moorings, and
  smoke-test seed moorings.
- Refresh seed-data/berths.json to canonical form; drop the now-
  redundant legacyMooringNumber field.
- Delete scripts/load-berths-to-port-nimara.ts (superseded in 0c).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:59:26 +02:00
Matt Ciaccio
8699f81879 chore(style): codebase em-dash sweep + minor layout polish
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m18s
Build & Push Docker Images / build-and-push (push) Has been skipped
Replaces every em-dash and en-dash with regular ASCII hyphens
across comments, JSX strings, and dev-facing logs. Mostly cosmetic
but stops the inconsistent mix that crept in over the last few
months (some files used em-dashes in comments, others didn't,
some used both).

Bundles two small dashboard-layout tweaks that touch a couple of
already-modified files:
- (dashboard)/layout.tsx main padding goes from p-6 to pt-3 px-6
  pb-6 so page content sits closer to the topbar.
- Sidebar now receives the ports list it needs for the footer
  port switcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:57:01 +02:00
Matt Ciaccio
d62822c284 fix(migration): NocoDB import safety + dedup helpers + lead-source backfill
migration-apply: residential client + interest inserts now wrap in
db.transaction so a partial failure can't leave an orphan client
row without its interest (or vice versa).

migration-transform: buildPlannedDocument returns null when there
are no signers so the apply pass doesn't try to send a Documenso
envelope without recipients. mapDocumentStatus gets an explicit
"Awaiting Further Details" branch that no longer auto-promotes via
stale sign-time fields. parseFlexibleDate handles ISO and DD-MM-YYYY
inputs uniformly.

backfill-legacy-lead-source: chunk UPDATE WHERE clause now
isNull(source) on top of the inArray match, so a re-run can't
overwrite a more accurate source written between batches.

Adds 235 lines of vitest coverage on migration-transform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:56:18 +02:00
Matt Ciaccio
089f4a67a4 feat(receipts): upload guide page + scanner head-tag fix
Adds /invoices/upload-receipts as the dedicated explainer for the
mobile scanner PWA: install instructions for iOS/Android, direct
deep-link button, and a walkthrough of the scan -> verify -> save
flow. Sidebar entry replaces the old "Scan receipt" tab so the
desktop side picks up the install steps before sending users to
the mobile-only surface.

Scanner layout moves PWA manifest + apple-* meta tags from inline
JSX into Next.js's metadata/viewport exports so the App Router
doesn't try to render a second <head>, fixing a hydration error
that surfaced as two console warnings on the scan page.

Scanner shell gains a centered Port Nimara logo header so the
standalone PWA looks branded when launched from the home screen
without the dashboard chrome.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:55:42 +02:00
Matt Ciaccio
77ad10ced1 feat(dashboard): custom date range + KPI port-hydration gate
DateRangePicker grows a "Custom range" mode (From/To inputs capped
at today, mutually-bounded so From <= To). dashboard-shell threads
the range through to /api/v1/analytics, which validates calendar
dates via ISO round-trip and enforces a 365-day cap as a backstop
against the occupancy timeline N+1.

KpiCards now gates its query on currentPortId so the early
unhydrated-store fetch can't cache a zeroed/error response and
display "-" until staleTime expires.

MyRemindersRail drops xl:h-full so the rail no longer stretches
past its grid row and overlaps ActivityFeed below.

useRealtimeInvalidation switches to partial-prefix queryKeys so a
realtime mutation invalidates every cached range bucket at once
instead of just the one currently visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:54:55 +02:00
Matt Ciaccio
e598cc0708 feat(layout): unified Inbox + UserMenu extraction
Replaces the topbar's separate AlertBell + NotificationBell with a
single Inbox popover that tabs between alerts and notifications.
NotificationBell keeps a popover-gate so it doesn't fire its list
fetch when Inbox is mounted alongside it.

Extracts the user dropdown into <UserMenu> and moves the port
switcher + role label + theme toggle into the sidebar footer so
the topbar can reclaim space for breadcrumbs and command search.

Adds dedicated Insights / Receipts nav sections in the sidebar
(scaffolds the website-analytics + upload-receipts entry points).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:54:06 +02:00
Matt Ciaccio
f5772ce318 feat(analytics): Umami integration with per-port admin settings
Adds /[portSlug]/website-analytics dashboard page (pageviews, top
pages, top referrers) and a per-port admin config UI for the
Umami URL / website-ID / API token. Settings live in system_settings
keyed per-port so a future second port has its own Umami account.
Adds a website glance tile to the main dashboard, a server-side
test-credentials endpoint, and a stable cache key for the active-
visitor poll so React Query doesn't fragment the cache per range.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:53:06 +02:00
Matt Ciaccio
49d34e00c8 feat(website-intake): dual-write endpoint + migration chain repair
Adds website_submissions table + shared-secret POST endpoint so the
marketing site can dual-write inquiries alongside its NocoDB write.
Race-safe via INSERT ... ON CONFLICT, idempotent on submission_id,
refuses every request when WEBSITE_INTAKE_SECRET is unset. Also
repairs pre-existing 0020/0021/0022 prevId collision (renumbered +
journal re-sorted) so db:generate works again. 11 unit tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:52:33 +02:00
Matt Ciaccio
c612bbdfd9 fix(migration): legacy bare-mooring lookup + port-nimara berth backfill
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m12s
Build & Push Docker Images / build-and-push (push) Has been skipped
Two issues surfaced when applying the migration to dev:

1. Mooring number format mismatch
   The legacy NocoDB Interests table writes bare mooring strings
   ("D32", "B16", "A4"), but the new berths table (mirroring the
   NocoDB Berths snapshot) uses zero-padded dashed form ("D-32",
   "B-16", "A-04"). The interest→berth lookup missed every reference.

   migration-apply.ts now tries the literal value first, then falls
   back to a normalized form via `normalizeLegacyMooring(raw)`:
     "D32" -> "D-32"
     "A4"  -> "A-04"
     "E18" -> "E-18"
   Multi-mooring strings ("A3, D30") are left as-is so they surface in
   the warnings list for human review rather than silently picking one.

2. port-nimara only had the 12 hand-rolled seed berths, not the 117-
   berth NocoDB snapshot
   The mobile-foundation seed only places those 12 in port-nimara; the
   117-berth snapshot was added later but only seeded into Marina
   Azzurra (the secondary test port). Migrated interests reference
   moorings well beyond A-01..D-03, so most lookups failed.

   New scripts/load-berths-to-port-nimara.ts: idempotently loads any
   missing snapshot berths into port-nimara without disturbing the
   existing 12 (skips moorings that already exist). Run once;
   subsequent runs no-op.

Result of full migration run on dev:
  237 clients inserted (out of 245 total — 8 from prior seed)
  406 contacts, 52 addresses, 38 yachts, 252 interests
  27 interest→berth links resolved (only 13 source rows had a Berth
  field set in NocoDB to begin with — most legacy interests are early
  inquiries with no berth assignment)
  1 unresolved warning: source=277 has multi-mooring "A3, D30"

Verified in UI:
  /port-nimara/clients shows real names (John-michael Seelye, Reza
  Amjad, Etiennette Clamouze, …)
  /port-nimara/clients/<id> renders contacts (gmail.com addresses,
  E.164 phones), tab counts (Interests N, Yachts N), pipeline summary
  Dashboard: 245 clients, 266 active interests, $46.5M pipeline value
  Pipeline funnel chart now shows real distribution (180 Open, 45
  EOI Signed, dropoff through stages)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:05:11 +02:00
Matt Ciaccio
872c75f1a1 fix(safety): plug 3 EMAIL_REDIRECT_TO leaks + 10 unit tests + live smoke
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m10s
Build & Push Docker Images / build-and-push (push) Has been skipped
A pre-import audit caught three places where outbound comms could escape
even with EMAIL_REDIRECT_TO set. Plugged each, added unit tests so the
behavior can't silently regress, and shipped a live smoke script the
operator can run before any production data import.

Leak 1: email-compose.service.ts (per-account user composer)
  Built its own nodemailer transporter and called sendMail() directly,
  bypassing the centralized sendEmail()'s redirect. Now mirrors the same
  redirect: when EMAIL_REDIRECT_TO is set, "to" is rewritten, "cc" is
  dropped, and the subject is prefixed with "[redirected from <orig>]".

Leak 2: documenso-client.sendDocument()
  Tells Documenso to actually email the document. Recipient emails were
  rerouted at create-time (in pass-3) but a document created BEFORE the
  redirect was turned on could still trigger a real-client email. Now
  short-circuited when the redirect is set — returns the existing doc
  shape so downstream code doesn't see an unexpected null.

Leak 3: documenso-client.sendReminder()
  Same shape as sendDocument: emails a stored recipient address that may
  predate the redirect. Now short-circuits with a warn-level log.

Tests (tests/unit/comms-safety.test.ts):
  - createDocument rewrites recipients
  - generateDocumentFromTemplate rewrites both v1.13 formValues.*Email
    keys AND v2.x recipients[] arrays
  - sendDocument is short-circuited (no /send call)
  - sendReminder is short-circuited (no /remind call)
  - createDocument passes through unchanged when redirect unset
  - sendEmail rewrites to + subject for single recipient
  - sendEmail handles array of recipients (joined into subject prefix)
  - sendEmail passes through unchanged when redirect unset
  - Webhook worker reads process.env.EMAIL_REDIRECT_TO at dispatch time
    (no module-level caching that could miss a runtime flip)

Live smoke (scripts/smoke-test-redirect.ts):
  Monkey-patches nodemailer.createTransport, calls the real sendEmail()
  with a fake real-client address, verifies the captured outbound has
  the right "to" + subject. Run: `pnpm tsx scripts/smoke-test-redirect.ts`.
  Exits non-zero if the redirect failed for any reason — drop-in for a
  pre-deploy check.

Verification:
  pnpm exec tsc --noEmit       — 0 errors
  pnpm exec vitest run         — 936/936 (was 926, +10 new safety tests)
  pnpm tsx scripts/smoke-test-redirect.ts — PASS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:55:53 +02:00
Matt Ciaccio
c45aac551d feat(dedup): wire --apply path for NocoDB migration
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m12s
Build & Push Docker Images / build-and-push (push) Failing after 3m41s
Completes the migration script's apply phase, which was stubbed at
the P3 ship to defer until after the runtime surfaces (P2) and the
comms safety net were in place. Both prerequisites just landed on
main, so this unblocks the actual data import.

src/lib/dedup/migration-apply.ts (new):
  Idempotent apply driver. Walks the MigrationPlan, inserting clients,
  contacts, addresses, yacht stubs, and interests, threading every
  insert through the migration_source_links ledger so re-runs against
  the same data are safe. Per-entity transactions (not one giant
  transaction) so partial-failure resumption is just "run again."

  Per-entity behavior:
    - clients: idempotent on (source_system, source_id, target_type=client)
      across the entire dedup cluster — if any source row already maps
      to a client, reuse that record.
    - contacts: bulk insert, primary email + primary phone independent.
    - addresses: bulk insert, port_id required (schema enforces it),
      first address marked primary when multiple.
    - yachts: minimal stub when the legacy interest had a yachtName,
      currentOwnerType=client + currentOwnerId=migrated client. Linked
      via migration_source_links target_type=yacht.
    - interests: looks up berthId via mooring number, yachtId via the
      stub above. Carries Documenso ID forward when present.

  surnameToken from PlannedClient is dropped on insert (it's a dedup
  blocking-index artifact; runtime dedup re-derives from fullName).

scripts/migrate-from-nocodb.ts:
  - Removes the "not yet implemented" guard for --apply.
  - Adds EMAIL_REDIRECT_TO precondition gate: --apply errors out unless
    the env var is set, OR --unsafe-skip-redirect-check is also passed
    (production cutover only). Refers to docs/operations/outbound-comms-safety.md.
  - Re-fetches NocoDB at apply time (rather than reading a saved report
    dir) so the data is always fresh. Re-running is safe via the
    idempotency ledger.
  - Resolves target port via --port-slug (or first port if omitted).
  - Generates a UUID applyId tagged on every link, which pairs with a
    future --rollback flag.
  - Apply summary prints inserted/skipped counts per entity type plus
    the first 20 warnings.

Verification: 0 tsc errors, 926/926 vitest passing, lint clean.
The actual end-to-end run requires NOCODB_URL + NOCODB_TOKEN in .env
which aren't configured in this checkout; that's the operator's next
step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:53:04 +02:00
Matt Ciaccio
9ad1df85d2 fix(residential): mobile card list alongside the desktop table
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m12s
Build & Push Docker Images / build-and-push (push) Failing after 5m42s
Both the residential-clients and residential-interests pages rendered
plain HTML <table>s with 5–6 columns directly. At 390px viewport the
header columns clipped at the right edge — "Sour..." for the clients
page, no header for the interests page either.

Adds a parallel mobile card list:
  - <table> stays inside `hidden lg:block` (unchanged at lg+)
  - new card list inside `lg:hidden` mirrors the row data:
    - Clients: name + status pill on top, then email · phone ·
      residence · source as a wrap-friendly meta row.
    - Interests: stage label as headline, updated-at on the right,
      preferences (line-clamp-2) and notes (line-clamp-1) below,
      source small at the bottom.
  - Each card is a Link to the detail page (matching the row click
    target on desktop).
  - Empty + loading states render as a centered card on mobile.

This is the same `hidden lg:block` / `lg:hidden` pattern used for the
main /clients and /interests pages. Doesn't refactor to the full
DataView primitive (would mean rebuilding the residential data layer
on TanStack Table) — keeps the change tightly scoped to the visible
output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:24:58 +02:00
Matt Ciaccio
8e4d2fc5b4 feat(safety): EMAIL_REDIRECT_TO now also pauses Documenso + webhooks
Closes a gap exposed by the comms safety audit: the existing
EMAIL_REDIRECT_TO env var only redirected outbound SMTP via the
sendEmail() bottleneck. Two channels still leaked when set:

  1. Documenso e-signature recipients — Documenso's own server emails
     them on our behalf, so SMTP redirect doesn't help. We were sending
     real client emails to the Documenso REST API, which would then
     deliver to the real client.

  2. Outbound webhooks — fire from the BullMQ worker to user-configured
     URLs. SSRF guard blocks internal hosts but doesn't pause production
     endpoints.

Documenso (src/lib/services/documenso-client.ts):
  - createDocument: rewrite every recipient.email to EMAIL_REDIRECT_TO
    and prefix the recipient.name with the original email so the doc
    is traceable.
  - generateDocumentFromTemplate: same treatment for both v1.13
    formValues.*Email keys and v2.x recipients[]. The redirect happens
    BEFORE the API call, so even Documenso's own retry logic can't
    reach the original recipient.
  - Both paths log when they redirect so it's visible in dev.

Webhooks (src/lib/queue/workers/webhooks.ts):
  - When EMAIL_REDIRECT_TO is set, short-circuit the dispatch and write
    a `dead_letter` row with reason "Skipped: EMAIL_REDIRECT_TO is set,
    outbound comms paused." so the attempt is still visible in the
    deliveries listing.

Doc:
  docs/operations/outbound-comms-safety.md catalogs every outbound
  comms channel (email, Documenso, webhooks, WhatsApp/phone deep-links,
  SMS-not-implemented) and explains how each one respects the env flag.
  Includes a verification checklist to run before any production data
  import + cutover steps for going live.

Single env var EMAIL_REDIRECT_TO now reliably pauses ALL automated
outbound comms. Unset for production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:24:41 +02:00
Matt Ciaccio
78f2f46d41 fix(admin): stack settings rows vertically on phone widths
Inquiry Settings + Business Rules cards used a flex-row layout that
crushed the label column into a narrow vertical stripe at 390px ("Inquiry
/ Contact / Email" wrapping one word per line) while the input took the
right side.

Stack label + helper text above the input on phone widths; restore the
side-by-side row from sm up. Same pattern as the other detail-edit rows
that were fixed in pass-2/pass-3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:24:20 +02:00
Matt Ciaccio
3a9419fe10 chore(scripts): backfill client_contacts.value_e164 from value
One-shot script that walks every phone / whatsapp contact with `value`
set but `value_e164` null and runs the raw value through libphonenumber-js
to produce the canonical E.164 form. Matches the existing dedup
phone-parser shape (script-safe wrapper that loads metadata as raw JSON
to dodge the Node 25 + tsx interop bug).

Two output buckets:
  - parsed cleanly: e164 + country both resolved (33/36 in dev).
  - parsed e164 only: e164 came back but country didn't (3/36 — the
    UK +44 7700 900xxx fictional/reserved range that libphonenumber
    refuses to assign a country to but still returns a canonical e164
    for). Still safe to write — the e164 form is the canonical one.

Run dry-first, --apply to write:
  pnpm tsx scripts/backfill-phone-e164.ts
  pnpm tsx scripts/backfill-phone-e164.ts --apply

Applied to dev DB this session: 36 rows backfilled, 0 still missing.
Will need to be re-run after any future seed reload that introduces
unparsed phones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:24:08 +02:00
Matt Ciaccio
b703684285 fix(ux): pass-3 — yacht/company headers, reminder filters wrap, client tab counts
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m14s
Build & Push Docker Images / build-and-push (push) Failing after 4m51s
Five small fixes from the third audit pass on previously-unchecked surfaces:

Yacht detail header (mobile):
  - Stack the action cluster (Edit / Transfer / Archive) below the title
    block on phone widths. Previously the three buttons crowded the right
    side enough to truncate the status pill to "A..." and force the owner
    name to wrap to two lines. Same fix that landed for berth / client /
    company headers.

Company detail header (mobile):
  - Same mobile stacking fix; legal-name + Tax-ID metadata no longer
    wraps awkwardly.

Company detail Incorporation Date (all viewports):
  - Strip the time portion of the ISO timestamp before passing to the
    inline editor. Previously rendered the raw "2019-03-14T00:00:00.000Z"
    Postgres-serialized form. Now reads "2019-03-14" and round-trips
    through the YYYY-MM-DD inline editor cleanly.

Reminders list filter row:
  - Allow flex-wrap on the My/All tabs + status filter + priority filter
    cluster. At 390px, the priority filter dropdown was being pushed off
    the right edge of the screen.

Client detail tab counts:
  - Add interestCount + noteCount to getClientById response, surface as
    badges on the Interests + Notes tabs. Brings them into parity with
    Yachts/Companies/Reservations/Addresses which already showed counts;
    Files + Activity are still stubs and don't get a count yet.

Verification: 0 tsc errors, 926/926 vitest passing, lint clean.

Out of scope (deferred):
  - Residential clients / interests pages still render plain HTML tables
    on phone widths (header columns clip at the right edge). Needs the
    DataView card-on-mobile treatment that the main /clients and
    /interests pages already have. Substantial separate work.
  - Phone contacts in the legacy seed have value set but valueE164 NULL,
    so InlinePhoneField shows "—" even though metadata is technically
    populated. Fix is a one-time backfill via libphonenumber-js, not a
    UI change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:09:27 +02:00
Matt Ciaccio
a792d9a182 fix(ux): pass-2 audit fixes — admin grouping, Duplicates entry, header tooltips
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m11s
Build & Push Docker Images / build-and-push (push) Failing after 5m45s
Three small but high-leverage fixes from the second audit pass on main:

Admin index (src/app/(dashboard)/[portSlug]/admin/page.tsx):
  - Grouped 21 sections into 7 categories: Access, Configuration, Content,
    Data Quality, Operations, Tenancy, Integrations. Each group has a
    one-line description so first-time admins can orient themselves
    without reading every card.
  - Added the missing Duplicates entry (links to /admin/duplicates from
    the dedup-migration work) under Data Quality.

More sheet (mobile bottom-drawer nav):
  - "Email" -> "Inbox". The page that opens is an email-inbox surface
    (Inbox + Accounts tabs), not a generic email composer. The previous
    label was ambiguous.

Interest detail header (Won / Lost outcome buttons):
  - Added title="Mark as won" / "Close as lost" so the icon-only buttons
    on mobile have a tooltip on long-press / desktop hover.
  - Tightened mobile padding (px-2 vs px-2.5) so the full-text desktop
    labels still fit on sm+ without re-introducing a regression where a
    visible mobile "Won"/"Lost" inline label crowded the right cluster
    enough to push Email/Call/WhatsApp action chips into a vertical
    stack.

Verification: 0 tsc errors, 926/926 vitest passing, lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:35:32 +02:00
Matt Ciaccio
d7ec2a8507 Merge docs/dedup-migration-design: client dedup + NocoDB migration design doc
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m18s
Build & Push Docker Images / build-and-push (push) Failing after 3m57s
2026-05-03 16:24:30 +02:00
Matt Ciaccio
cb83b09b2d Merge feat/dedup-migration: client dedup library + NocoDB migration script + admin queue
# Conflicts:
#	.gitignore
#	src/lib/db/migrations/meta/_journal.json
2026-05-03 16:24:13 +02:00
Matt Ciaccio
7574c3b575 chore(migrations): renumber 0020/0021 -> 0021/0022 to avoid clash with berth-parity
berth-schema-parity branch already shipped its own migration 0020 (berth
schema parity: text -> numeric, +status_override_mode). Dedup's two
migrations need to land on top of that, not collide.

Renames:
  0020_unusual_azazel.sql       -> 0021_unusual_azazel.sql
  0021_magenta_madame_hydra.sql -> 0022_magenta_madame_hydra.sql
  meta/0020_snapshot.json       -> meta/0021_snapshot.json
  meta/0021_snapshot.json       -> meta/0022_snapshot.json

_journal.json idx + tag fields updated to match.

Snapshot CONTENTS remain dedup-branch state (no berths-numeric awareness).
A `pnpm drizzle-kit generate` after main merges the berth changes will
produce a consistent forward path; until then the snapshots are slightly
out-of-sync with the post-merge live schema, which is harmless because
the dev DB applies migrations forward, not from snapshots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:22:58 +02:00
Matt Ciaccio
bb105f5365 Merge feat/mobile-ux-polish: berth/header/tab/contacts mobile fixes
# Conflicts:
#	src/components/clients/contacts-editor.tsx
2026-05-03 16:20:12 +02:00
Matt Ciaccio
caafae15dd Merge feat/berth-schema-parity: NocoDB field parity, 117-berth seed, ports pruned to Port Nimara + Amador 2026-05-03 16:18:43 +02:00
Matt Ciaccio
46c7389930 Merge feat/mobile-foundation: 212 commits of mobile foundation, sales UX, audit fixes 2026-05-03 16:18:10 +02:00
Matt Ciaccio
80fc5932be chore: ignore tooling caches, scratch screenshots, sister website project
Three categories added to .gitignore:

  Tool caches / runtime state:
    .claude/                — Claude Code session state + lock files
                              (scheduled_tasks.lock untracked here)
    .serena/                — Serena MCP project cache
    ruvector.db             — RuVector AgentDB binary

  Scratch screenshots:
    /*.jpg                  — debug captures dropped at repo root
    /.audit-screenshots/    — UX audit run output (regenerable)

  Sister project:
    /website/               — separate Nuxt marketing site, kept on disk
                              for reference but not tracked in this repo

The single tracked file in .claude/ (scheduled_tasks.lock) is removed
from the index here; future dev sessions won't bring it back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:17:13 +02:00
Matt Ciaccio
b26b87b2fa chore(dev): react-grab viewport sync helper for in-page debug toolbar
Mounts a dev-only client component that syncs the react-grab debug
toolbar's pinned edge / collapsed state across viewport changes (so
the toolbar doesn't drift off-screen when resizing or rotating).

Render is gated by NODE_ENV === 'development' in src/app/layout.tsx;
production builds tree-shake the import out via process.env replacement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:15:47 +02:00
Matt Ciaccio
88f76b6b04 feat(mobile): rework bottom nav (Dashboard/Berths/Clients/Documents/More)
Old order  : Dashboard / Clients / Yachts  / Berths    / More
New order  : Dashboard / Berths  / Clients / Documents / More

Reasoning (also captured as in-file comments above each tab list):
  - Yachts is asset-record traffic — rarely browsed standalone, almost
    always reached from inside an interest or client. Pulled out of the
    bottom row, kept available in the More sheet.
  - Documents (signature tracking / EOI queue) earns a slot at the
    bottom because reps chase signers as a daily activity.
  - Interests is intentionally NOT in the bottom row: having both
    Clients and Interests as peer tabs created a Clients-vs-Interests
    confusion for sales reps. The new per-client Interests tab + the
    bottom-sheet drawer (see ClientInterestsTab) cover the day-to-day
    deal review without needing a dedicated bottom-nav peer.
  - Clients moves to the center: it's the primary mental anchor for
    "find this person", with everything else (yachts, companies,
    interests) reached as a tab on the client detail page.

More-sheet reorder mirrors the new priority: Interests / Yachts /
Companies first (most-likely overflow targets), then financial
(Invoices, Expenses), then Email / Alerts / Reports / Reminders /
Settings / Admin. Documents removed from the More sheet (now in
the bottom row).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:15:37 +02:00
Matt Ciaccio
a32f41b91d fix(dashboard): scope h-full to xl + tighter mobile sizing on KPIs
The Alerts rail and Reminders rail were using h-full unconditionally, which
worked at xl: where the dashboard grid pairs them with a sibling chart
column, but produced weirdly stretched empty cards in the single-column
mobile stack (no fixed-height context to fill).

  alert-rail / my-reminders-rail: h-full -> xl:h-full

KPI tiles + skeleton rendered the same desktop padding (p-5) and font sizes
on phone, leaving the value cramped against a wide white frame. Tighter
mobile defaults that scale up at sm:

  KPITile      p-3 sm:p-5, label text-[10px] sm:text-xs,
               value mt-1 text-lg sm:mt-2 sm:text-2xl, value truncates
  KpiTileSkeleton: matching p-3 sm:p-5 + smaller skeleton bars on mobile

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:15:20 +02:00
Matt Ciaccio
cf1c8b66db feat(client): phone-edit row dilation + mobile contacts layout
InlinePhoneField now lays the country picker + number on top, with Save +
Cancel buttons on a second line — the previous single-line cluster was
cramped at every viewport size and broke entirely below ~480px.

A new onEditingChange callback notifies the parent when the field enters
edit mode, so contact rows can react. ContactsEditor uses it to "dilate"
the row visually: lift out of the muted baseline with a soft primary
ring + slightly brighter surface + bumped padding. Single visual signal
replaces the need for any "now editing" label, and the dilation also
hides the noisy chip cluster (label / star / trash) that would otherwise
fight the editor for space.

Mobile improvements applied at the same time:
  - Each row stacks value editor on top, action cluster below at <sm
  - Action cluster ("Add tag" + Make-primary star + trash) uses
    justify-end on the new row so it doesn't collide with the picker
  - Trash icon stays opacity-0/group-hover on desktop but is always
    visible on touch (no hover state on touch) — sm:opacity-0 +
    sm:group-hover:opacity-100 instead of the prior unconditional fade
  - NewContactForm wraps onto multiple lines below sm (basis-full on
    the value field) so the channel picker, value, label, and buttons
    each get usable width

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:15:07 +02:00
Matt Ciaccio
596476280d feat(ui): inline-edit dropdowns auto-open + auto-exit on dismiss
When a user clicks an inline-edit affordance for country / timezone /
subdivision, the field flipped to its combobox trigger but the popover
didn't open — they had to click again. And if they dismissed the popover
without picking, the field stayed in edit mode showing a "Select country…"
trigger they couldn't get out of.

Combobox primitives (country / timezone / subdivision) now accept:
  - defaultOpen — open on first render
  - onOpenChange — fired on every open/close transition

InlineCountryField / InlineTimezoneField / and the country + subdivision
fields inside addresses-editor pass defaultOpen=true and use onOpenChange
to auto-exit edit mode when the popover closes without a selection. A
pickedRef gate prevents the close-handler from racing the commit() exit
when the user does pick a value.

Bonus: addresses-editor now renders a flag emoji next to the country name
in the read-only state (regional-indicator pair from the ISO code).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:14:51 +02:00
Matt Ciaccio
e9359fc431 feat(client): interests tab + pipeline summary panel + list-row counts
Promotes interests from a stub tab to a first-class surface on the client
detail page, and surfaces pipeline activity in two more places:

UI:
  - New ClientInterestsTab (475 lines) — table of every active interest
    for the client with stage-stepper visualization, lead category, source,
    last-activity timestamp, and a drawer-on-tap row preview.
  - New OverviewTab pipeline-summary panel above the existing 2-column
    layout, rendering ClientPipelineSummary (already on this branch) in
    its panel variant. Reps see the live pipeline at a glance without
    leaving Overview.
  - Removes "Preferred Language" inline field from the Overview tab and
    the create form — unused, and the field added noise without driving
    any downstream behavior.
  - Tab order: Overview / Interests / Yachts / Companies / ... (Interests
    moves up from the back of the list, where it was a stub anyway).

Data:
  - listClients now returns interestCount + latestInterest{stage, mooring}
    per row, joined from interests + berths in two parallel queries.
    ClientRow type updated to surface them; Client list views can now
    render "3 interests · last on D-02 (EOI Signed)" without a per-row
    fetch.
  - Contact rows in client detail now expose valueE164 + valueCountry to
    the UI (already returned by the API; just wasn't typed through the
    detail-page contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:14:37 +02:00
Matt Ciaccio
4767caec01 feat(client): redesign detail header with action chips + condensed meta
Replaces the inline "Source · email · phone" text strip with three primary
action chips and a smaller meta line:

  Mail / Call / WhatsApp action buttons surface the most-used outbound
  contacts on a single tap. WhatsApp deep-link strips the leading + from
  the canonical E.164 number (or falls back to digit-only of the value).

  Meta line now reads "Country · Added MMM d, yyyy" using nationalityIso
  resolved through getCountryName(); date-fns formats createdAt.

  Portal Invite + GDPR Export buttons remain available but only render
  on sm+; on mobile they're reachable through the More sheet.

  Archive / Restore is now a small icon button in the top-right corner
  rather than a labeled button competing with the primary action chips.
  Destructive intent stays out of the main action flow; hover swaps to
  destructive color for archive (and stays neutral for restore).

The previous source/preferred-contact-method/preferred-language/timezone
fields no longer render in the header — they live on the Overview tab via
the inline editor pattern (see client-tabs.tsx).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:14:19 +02:00
Matt Ciaccio
49d92234dd fix(test): align stage names with consolidated pipeline enum
Followup to 886119c (refactor(sales): consolidate pipeline stages) — the
runtime enum was renamed but a few test fixtures and PDF report templates
still referenced the legacy names, leaving them broken at the type level
(36 tsc errors before this fix).

Renames in this commit:
  visited        -> in_communication (alerts test) / removed (PDF reports)
  signed_eoi_nda -> eoi_signed
  contract       -> contract_signed (interests test) / contract_sent (factory)

Affected files: pipeline-report, revenue-report, makeCreateInterestInput
factory, alerts-engine, pipeline-transitions, interest-scoring.

Verification: tsc clean, 858/858 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:14:04 +02:00
Matt Ciaccio
cad55e3565 fix(mobile): clipping, dropdown-tabs and stale phone metadata
Five mobile-UX issues caught in the 2026-05-03 audit, fixed in one pass:

1. SpecRow on berth detail clipped at right edge on phone widths.
   "Length 49.21 ft / 15 r" (the "m" cut off). Mobile-first stack:
   label on top, value full-width below; flex row only from sm up.

2. ResponsiveTabs collapsed to a Select on phone widths, which read like
   a generic dropdown and obscured the existence of peer tabs. Replaced
   with a horizontally-scrollable strip that auto-scrolls the active
   trigger into view (so the user sees neighbors and gets a discovery
   cue that more exists beyond the edge). Removes the phone-only Select
   and unifies the tab UI across viewport sizes.

3. Documents page tab strip ("All / EOI queue / Awaiting them / ...")
   overflowed the 390px viewport because the wrapper was a fixed flex
   row. Same horizontal-scroll fix as (2); inherits because Documents
   uses ResponsiveTabs.

4. Berth detail header: "Change Status" + "Edit" buttons crowded the
   area subtitle on mobile, causing "North Pier" to wrap to two lines
   ("North" / "Pier"). Stacked vertically on phone widths; from sm up
   the buttons sit beside the title block as before.

5. Empty contact rows on client detail rendered a stale "Add tag · star"
   metadata strip even when the contact value was unset, which cluttered
   the row and offered no useful action. The metadata block now only
   shows when contact.value is non-empty; the trash icon stays visible
   so users can clean up the empty placeholder.

Verification:
- pnpm exec vitest run: 858/858 passing
- pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing
  on feat/mobile-foundation, none introduced)
- lint clean

Defers:
- Mobile More sheet last-row alignment / "Email" label specificity
- Admin index grouping (Access / System / Configuration / Content)
- Interest detail header icon labels (trophy/X discoverability)
- Pipeline funnel x-axis label abbreviations
- Reminders rail width allocation on dashboard

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:03:56 +02:00
Matt Ciaccio
21868ee5fc feat(berths,seed): polish detail display + prune ports to Port Nimara + Amador
Berth detail (src/components/berths/berth-tabs.tsx):
- Numeric display polish, exposed by the new NocoDB-sourced seed:
  - Power capacity now renders with kW unit (e.g. "330 kW")
  - Voltage now renders with V unit (e.g. "480 V")
  - All metric/imperial values rounded to <= 2 decimals
    (was: "62.999112 m" -> now: "62.99 m")
  - Nominal Boat Size shows full ft + m pair (was: ft only)

Seed ports (src/lib/db/seed.ts):
- Drop Marina Azzurra and Harbor Royale; install now seeds only:
  - Port Nimara  (the real install)
  - Port Amador  (secondary, for multi-tenant isolation tests / Panama
                  scaffolding)
- Existing dev DBs are not touched; this only affects fresh `pnpm db:seed`
  runs. Users wanting to migrate should drop existing rows in the obsolete
  ports manually before re-seeding.

Verification:
- lint clean, tsc unchanged from baseline (36 pre-existing errors), 858/858
  vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:59:36 +02:00
Matt Ciaccio
c7ab816c99 feat(seed): replace 12 hand-rolled berths with 117-row NocoDB snapshot
The old seed only had 12 berths with made-up area names ("North Pier",
"Central Basin", etc.) and placeholder dimensions. Devs now get the real
117 berths exported from the legacy NocoDB Berths table — every editable
column populated with real production values.

What's in the snapshot (src/lib/db/seed-data/berths.json):
- 117 berths total (61 available / 45 under_offer / 11 sold)
- Areas A through E (matches NocoDB single-select)
- All numeric fields filled: length / width / draft (ft + m), water depth,
  nominal boat size, power capacity (kW), voltage (V)
- All NocoDB single-selects filled where present: side pontoon,
  mooring type, cleat/bollard type+capacity, access
- Bow facing, status_override_mode, berth_approved carried forward as-is
- Status normalized to lowercase snake_case ("Under Offer" -> "under_offer")
- Mooring numbers reformatted A1 -> A-01 to keep the existing "Letter-NN"
  convention used elsewhere in the codebase

Pre-sorted to preserve seed semantics:
  idx 0..4   -> 5 available  (small)   -- "open" / "details_sent" interests
  idx 5..9   -> 5 under_offer (medium) -- "eoi_signed" / "deposit" / "contract"
  idx 10..11 -> 2 sold (large)         -- "completed" interests
This means existing interest/reservation seeds that index berthRows[0..11]
keep their semantic alignment without code changes.

End-to-end verified by clearing Marina Azzurra and re-seeding:
  Port "Marina Azzurra" -- 117 berths, 8 clients, 3 companies, 12 yachts,
                           15 interests, 8 reservations

Future devs running `pnpm db:seed` on a fresh DB will now get realistic
berth data automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:41:12 +02:00
Matt Ciaccio
e40b6c3d99 feat(berths): full NocoDB field parity, numeric types, sales edit access
Aligns the berths schema with the 117 production rows in NocoDB and exposes
every field for editing via the BerthForm sheet.

Schema (migration 0020):
- power_capacity / voltage / nominal_boat_size / nominal_boat_size_m: text -> numeric
  (NocoDB stores plain numbers; text was wrong shape and broke filter/sort)
- ADD status_override_mode text (1/117 legacy rows have a value; carried
  forward for parity but not yet wired into the UI)
- USING NULLIF(TRIM(...), '')::numeric so legacy whitespace and empty
  strings convert cleanly

Validator + service:
- updateBerthSchema / createBerthSchema use z.coerce.number() for the
  four numeric fields
- berths.service stringifies numeric values for Drizzle's numeric type

Form (src/components/berths/berth-form.tsx):
- adds: nominal boat size (ft/m), water depth (ft/m) + "is minimum" flag,
  side pontoon, cleat type/capacity, bollard type/capacity, bow facing
- converts to typed selects (with NocoDB option lists in src/lib/constants):
  area, side pontoon, mooring type, cleat type/capacity, bollard type/capacity,
  access
- power capacity / voltage become numeric inputs (with kW / V hints)

Permissions (seed.ts + dev DB):
- sales_manager and sales_agent: berths.edit false -> true
  ("sales will sometimes have to update these and I cannot be the only one")
- super_admin / director already had it; viewer stays read-only
- dev DB updated in-place via UPDATE roles ... jsonb_set

Verification:
- pnpm exec vitest run: 858/858 passing
- pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing
  on feat/mobile-foundation, none introduced)
- lint clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:30:32 +02:00
Matt Ciaccio
4bcc7f8be6 feat(dedup): runtime surfaces — merge service, at-create suggestion, admin queue (P2)
Adds the live dedup pipeline on top of the P1 library + P3 migration
script. The new `client/interest` model now actively prevents duplicate
client records at creation time and gives admins a queue to triage
the borderline pairs the at-create check missed.

Three layers, per design §7:

Layer 1 — At-create suggestion
==============================

`GET /api/v1/clients/match-candidates`
  Accepts free-text email / phone / name from the in-flight client
  form, normalizes them via the dedup library, and returns scored
  matches against the port's live client pool. Filters out
  low-confidence noise (the background scoring queue picks those up
  separately). Strict port scoping; never leaks across tenants.

`<DedupSuggestionPanel>` (`src/components/clients/dedup-suggestion-panel.tsx`)
  Debounced React Query hook. Renders nothing for short inputs or
  no useful match. On a high-confidence match it interrupts visually
  with an amber-tinted card and a "Use this client" primary button.
  Medium confidence falls back to a softer "possible match — check
  before creating" treatment.

`<ClientForm>`
  Renders the panel above the form (create path only — skipped on
  edit). New `onUseExistingClient` callback fires when the user
  picks the existing client; the form closes and the parent decides
  what to do (typically: navigate to that client's detail page or
  open the create-interest dialog pre-filled).

Layer 2 — Merge service
=======================

`mergeClients` (`src/lib/services/client-merge.service.ts`)
  The atomic merge primitive that everything else calls. Single
  transaction. Per §6 of the design:

  - Locks both rows (FOR UPDATE) so concurrent merges of the same
    loser fail with a clear error rather than racing.
  - Snapshots the full loser state (contacts / addresses / notes /
    tags / interest+reservation IDs / relationship rows) into the
    `client_merge_log.merge_details` JSONB column for the eventual
    undo flow.
  - Reattaches every loser-side row to the winner: interests,
    reservations, contacts (skipping duplicates by `(channel, value)`),
    addresses, notes, tags (deduped), relationships.
  - Optional `fieldChoices` — per-scalar overrides letting the user
    keep the loser's value for fullName / nationality / preferences /
    timezone / source.
  - Marks the loser archived with `mergedIntoClientId` set (a redirect
    pointer for stragglers; never hard-deleted within the undo window).
  - Resolves any matching `client_merge_candidates` row to status='merged'.
  - Writes audit log entry.

Schema additions:
  - `clients.merged_into_client_id` (nullable text, indexed) — the
    redirect pointer set on archive.

Tests: 6 cases against a real DB — happy path moves rows + writes log;
self-merge / cross-port / already-merged refused; duplicate-contact
deduped on reattach; fieldChoices copies loser values to winner.

Layer 3 — Admin review queue
============================

`GET /api/v1/admin/duplicates`
  Pending merge candidates (status='pending') for the current port,
  with both client summaries hydrated for side-by-side rendering.
  Skips pairs where one side is already archived/merged.

`POST /api/v1/admin/duplicates/[id]/merge`
  Confirms a candidate. Body picks the winner; the other side
  becomes the loser. Calls into `mergeClients` — the only path that
  writes `client_merge_log`.

`POST /api/v1/admin/duplicates/[id]/dismiss`
  Marks the candidate dismissed. Future scoring runs skip the same
  pair until a score change recreates the row.

`<DuplicatesReviewQueue>` (`/admin/duplicates`)
  Side-by-side card UI for each pending pair. Click a card to pick
  the winner; the other side is automatically the loser. Toolbar:
  "Merge into selected" + "Dismiss". No per-field merge editor in
  this PR — that's a future polish; the simple "pick the better row"
  flow handles ~80% of cases.

Test coverage
=============

11 new integration tests (76 added in this branch total):
  - 6 mergeClients (atomicity, refusal cases, contact dedup,
    fieldChoices)
  - 5 match-candidates API (shape, port scoping, confidence tiers,
    Pattern F false-positive guard)

Full vitest: 926/926 passing (was 858 before the dedup branch).
Lint: clean. tsc: clean for new files (only pre-existing errors in
unrelated `tests/integration/` files remain, same as before this PR).

Out of scope, deferred
======================

- Background scoring cron that populates `client_merge_candidates`
  (the queue is empty until this lands; manual seeding works for
  now via the at-create flow).
- Side-by-side per-field merge editor with checkboxes (the simple
  "pick the winner" UX shipped here covers ~80% of real cases).
- Admin settings UI for tuning the dedup thresholds. Defaults from
  the design (90 / 50) are baked in for now.
- `unmergeClients` (the snapshot is captured in client_merge_log;
  the undo endpoint just hasn't been wired yet).

These are all natural follow-up PRs that don't block shipping the
runtime UX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:59:04 +02:00
Matt Ciaccio
18e5c124b0 feat(dedup): NocoDB migration script + tables (P3 dry-run)
Lands the one-shot migration pipeline from the legacy NocoDB Interests
base into the new client/interest schema. Dry-run mode is fully
operational: pulls the live snapshot, runs the dedup library, and
writes a CSV + Markdown report under .migration/<timestamp>/. The
--apply phase is stubbed for a follow-up PR per the design's P3
implementation sequence.

Schema additions
================

- `client_merge_candidates` — pairs flagged by the background scoring
  job for the /admin/duplicates review queue. Status enum: pending /
  dismissed / merged. Unique-(portId, clientAId, clientBId) so the
  same pair can't surface twice. Empty until P2 lands the cron.
- `migration_source_links` — idempotency ledger. Maps source-system
  rows (NocoDB Interest #624 → new client UUID) so re-running --apply
  against the same dry-run report skips already-imported entities.

Both tables ship with the migration `0020_unusual_azazel.sql` —
already applied to the local dev DB during this commit's preparation.

Library
=======

src/lib/dedup/nocodb-source.ts
  Read-only adapter for the legacy NocoDB v2 API. xc-token auth,
  auto-paginates until isLastPage, captures the table IDs from the
  2026-05-03 audit. `fetchSnapshot()` pulls every relevant table in
  parallel into one in-memory object the transform layer consumes.

src/lib/dedup/migration-transform.ts
  Pure function: NocoDB snapshot in, MigrationPlan out. Per row:
    - normalizes name / email / phone / country via the dedup library
    - parses the legacy DD-MM-YYYY / DD/MM/YYYY / ISO date formats
    - maps the 8-stage `Sales Process Level` enum to the new 9-stage
      pipelineStage
    - filters yacht-name placeholders ('TBC', 'Na', etc.)
    - merges Internal Notes + Extra Comments + Berth Size Desired into
      a single notes blob
  Then runs `findClientMatches` pairwise (with blocking) and
  union-finds clusters of rows whose score crosses the auto-link
  threshold (90). Lower-scoring pairs (50–89) become 'needs review'.
  Each cluster's "lead" row is picked by completeness score with
  recency tie-break.

src/lib/dedup/migration-report.ts
  Writes three artifacts to .migration/<timestamp>/:
    - report.csv  — one row per planned op, RFC-4180 escaped
    - summary.md  — human-skimmable overview
    - plan.json   — full structured plan for the --apply phase
  CSV cells with comma / quote / newline are quoted; internal quotes
  are doubled. No external CSV dep.

src/lib/dedup/phone-parse.ts
  Script-safe wrapper around libphonenumber-js's `core` entry that
  loads `metadata.min.json` directly. The default `index.cjs.js`
  bundled by libphonenumber hits a metadata-shape interop bug under
  Node 25 + tsx (`{ default }` wrapping); core+JSON sidesteps it.
  The dedup `normalizePhone` and `find-matches` both use this wrapper
  now so the same code path runs in vitest, Next.js, and the migration
  CLI without surprises.

src/lib/dedup/normalize.ts
  Tightened country resolution: added Caribbean short-form aliases
  ('antigua' → AG, 'st kitts' → KN, etc.) and a city map covering the
  US locations seen in the NocoDB dump (Boston, Tampa, Fort
  Lauderdale, Port Jefferson, Nantucket). Also relaxed phone parsing
  to drop the `isValid()` strict check — the libphonenumber min build
  rejects many real NANP-territory numbers, and dedup only needs a
  canonical E.164 to compare.

CLI
===

scripts/migrate-from-nocodb.ts
  pnpm tsx scripts/migrate-from-nocodb.ts --dry-run
    → Pulls the live NocoDB base (NOCODB_URL + NOCODB_TOKEN env vars),
       runs the transform, writes report. No DB writes.
  pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<dir>/
    → Stubbed; exits with `not yet implemented` and a pointer to the
       design doc. Apply phase ships in a follow-up.

Tests
=====

tests/unit/dedup/migration-transform.test.ts (7 cases)
  Fixture-based regression. A frozen 12-row NocoDB snapshot covers
  every duplicate pattern in the design (§1.2). The test asserts:
    - 12 input rows → 7 unique clients (cluster math is right)
    - Patterns A / B / C / E auto-link
    - Pattern F (Etiennette Clamouze) does NOT auto-link
    - Every interest preserved as its own row even when clients merge
    - 8-stage → 9-stage enum mapping is correct per spec
    - Multi-yacht merge (Constanzo CALYPSO + Costanzo GEMINI under one
      client) — the design's signature win
    - Output is deterministic (run twice, identical)

Validation against real data
============================

Ran `pnpm tsx scripts/migrate-from-nocodb.ts --dry-run` against the
live NocoDB. Result on 252 Interests rows:
  - 237 clients (15 merged into 13 clusters)
  - 252 interests (one per source row)
  - 406 contacts, 52 addresses
  - 13 auto-linked clusters (every confirmed cluster from §1.2 audit)
  - 3 pairs flagged for review (Camazou, Zasso, one new)
  - 1 phone placeholder flagged

Total dedup test count: 57 (50 from P1 + 7 fixture tests).
Lint: clean. Tsc: clean for new files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:50:01 +02:00
Matt Ciaccio
8b077e1999 feat(dedup): normalization + match-finding library (P1)
The pure-logic spine of the client deduplication system spec'd in
docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md.
Two modules, JSX-free, vitest-tested against fixtures drawn directly
from real dirty values observed in the legacy NocoDB Interests audit.

src/lib/dedup/normalize.ts
- normalizeName: trims whitespace, replaces \r/\n/\t, intelligently
  title-cases ALL-CAPS surnames while keeping particles (van / de /
  dalla / etc.) lowercase mid-name. Preserves Irish O' surnames and
  the "slash-with-company" structure ("Daniel Wainstein / 7 Knots,
  LLC") seen in production. Returns a surnameToken (lowercased last
  non-particle token) for use as a dedup blocking key.
- normalizeEmail: trim + lowercase + zod email validation. Plus-aliases
  preserved; null on invalid.
- normalizePhone: pre-cleans the input (strips spreadsheet apostrophes,
  carriage returns, dots/dashes/parens, converts 00 prefix to +) then
  delegates to libphonenumber-js. Detects multi-number fields ("a/b",
  "a;b") and placeholder fakes (8+ consecutive zeros, e.g.
  +447000000000). Flags every quirk so the migration report and runtime
  audit log can surface it.
- resolveCountry: maps free-text country/region input to ISO-3166-1
  alpha-2 via alias → exact (vs. Intl-derived names) → city → fuzzy
  (Levenshtein ≤ 2). Fuzzy is gated by length so 4-char inputs ("Mars")
  don't false-positive against short country names.
- levenshtein: standard iterative implementation, exported for reuse
  by find-matches.

src/lib/dedup/find-matches.ts
- findClientMatches: builds three blocking indexes off the pool (email
  / phone / surname-token), gathers the comparison set via union, and
  scores each candidate via the rule set in design §4.2:
    Email match            +60
    Phone E.164 match      +50  (≥ 8 digits, excludes placeholder zeros)
    Name exact match       +20
    Surname + given fuzzy  +15  (Levenshtein ≤ 1)
    Negative: shared email but different phone country  −15
    Negative: name match but no shared contact          −20
  Score is clamped to [0,100]. Confidence tier ('high' / 'medium' /
  'low') is derived from configurable thresholds passed in by the
  caller — defaults are highScore=90, mediumScore=50.

tests/unit/dedup/normalize.test.ts (38 cases)
Every dirty-data pattern from design §1.3 has a fixture: carriage
returns in names, ALL-CAPS surnames, lowercase entries, particles,
slash-with-company, plus-aliases, capitalized email localparts,
spreadsheet-apostrophe phones, multi-number phones, placeholder
phones, 00-prefix phones, French/UK local-format phones,
Saint-Barthélemy diacritic variants, Kansas City fallback.

tests/unit/dedup/find-matches.test.ts (12 cases)
Each duplicate cluster from design §1.2 has a test:
- Pattern A (Deepak Ramchandani — pure double-submit) → high
- Pattern B (Howard Wiarda — phone format variance) → high
- Pattern C (Nicolas Ruiz — name capitalization) → high
- Pattern D (Chris/Christopher Allen — name shortening) → high
- Pattern E (Christopher Camazou — typo on resubmit) → high or medium
- Pattern E (Constanzo/Costanzo — surname typo, multi-yacht) → high
- Pattern F (Etiennette Clamouze — same name, different country) →
  must NOT auto-merge
- Pattern F (Bruno+Bruce — shared household contact) → no match
- Negative evidence (same email, different phone country) → medium
- Blocking (no shared keys → 0 matches)
- Sort order (high before low)
- Empty pool

Total: 50 new tests, all green. Zero changes to runtime behavior or
schema; unblocks P2 (runtime surfaces) and P3 (NocoDB migration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:28:59 +02:00
Matt Ciaccio
36b92eb827 docs(spec): client deduplication and NocoDB migration design
Captures the audit findings from a 2026-05-03 read-only NocoDB review
plus the algorithm and migration plan for porting the legacy data
into the new client / interest / contacts / addresses model.

Highlights:
- 252 NocoDB Interests rows ≈ ~190–200 unique humans (~20–25% dup
  rate). Six duplicate patterns documented from real data, including
  "same person, multiple yachts" — exactly the case the new
  client/interest split is designed to handle.
- Reuses the battle-tested `client-portal/server/utils/duplicate-
  detection.ts` algorithm (blocking + weighted rules) with additions:
  metaphone for non-English surnames, compounded confidence when
  multiple rules match, negative evidence for split-signal cases.
- Three runtime surfaces (at-create suggestion, interest-level
  same-berth guard, background scoring + admin review queue) plus a
  one-shot migration script with --dry-run / --apply / --rollback.
- Configurable thresholds via per-port system_settings so the merge
  policy can be tuned (defaults to "always confirm" — never
  auto-merges out of the box).
- Reversible: every merge writes a clientMergeLog row with the
  loser's full pre-state JSON, enabling 7-day undo without engineering.

Implementation decomposes into three plans (P1 library / P2 runtime /
P3 migration) sequenced after the mobile branch lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:10:08 +02:00
Matt Ciaccio
e2398099c4 test(audit-fixes): cover the new permission and webhook surfaces
Adds integration coverage for the routes / handlers shipped in the
preceding audit-fix commits, plus refactors two route files to expose
inner handlers from a sibling `handlers.ts` (the pattern used elsewhere
in `src/app/api/v1`) so tests can call them without the
`withAuth(withPermission(…))` wrapper.

New tests (18 cases across 4 files):
- `tests/integration/portal-auth.test.ts` (6) — verifyPortalToken
  rejects tokens missing `aud: 'portal'` or `iss: 'pn-crm'`, with the
  wrong audience (CRM-session-replay shape) or wrong issuer, plus a
  round-trip happy path. Locks in the portal-vs-CRM token isolation.
- `tests/integration/api/saved-views-ownership.test.ts` (6) — patch
  and delete handlers return 403 for a different user, 404 for an
  unknown id or cross-port id, and 200 for the owner. Ownership is
  enforced at the route layer regardless of the service's internal
  filtering.
- `tests/integration/api/berth-reservations-list.test.ts` (3) — the
  new global list returns rows for the current port only and honors
  pagination params. A reservation in a different port never leaks.
- `tests/integration/documents-expired-webhook.test.ts` (3) —
  handleDocumentExpired flips the document to `expired`, also flips
  the linked interest's `eoiStatus`, writes a `documentEvents` row,
  and is a no-op (not a throw) when the documensoId is unknown.

Refactors:
- `src/app/api/v1/saved-views/[id]/route.ts` extracts `patchHandler` /
  `deleteHandler` (and the shared `assertViewOwner`) into
  `handlers.ts`. The route file is now a 4-line `withAuth(handler)`
  wrapper.
- `src/app/api/v1/berth-reservations/route.ts` extracts `listHandler`
  similarly. Tests import directly from `handlers.ts`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:17:08 +02:00
Matt Ciaccio
d364b09885 fix(realtime): keep socket through reconnects, stop re-subscribe storm
Two correctness bugs in the real-time stack — both silent failures, both
session-wide once they trigger.

(1) `SocketProvider` was setting the React context to null on every
`disconnect` event. socket.io's built-in reconnection re-establishes the
underlying transport and replays handlers, but the React tree had
already lost its reference to the socket — so every `useSocket()`
consumer saw null until a session/port change forced a remount. Effect:
after the first transient drop (laptop sleep, wifi blip, server
restart), realtime invalidation and toasts went dead session-wide with
no user-visible signal.

Fix: keep the socket reference stable for the lifetime of the
session+port, and surface a separate `isConnected` boolean for any UI
that wants to render an offline indicator. Exposed as a new
`useIsSocketConnected()` hook; `useSocket()` signature is unchanged.

(2) `useRealtimeInvalidation` captured `eventMap` as a useEffect
dependency. Every caller passes a fresh `{ ... }` object literal on each
render, so the effect re-ran every render → `socket.off`/`socket.on`
storm on pages with many subscribed events.

Fix: extract the subscription logic into a pure helper
(`realtime-invalidation-core.ts`, JSX-free for vitest). The hook now
keeps the latest map in a ref and only re-subscribes when the SET of
event names changes (joined-keys signature, not object identity). The
handler reads `ref.current` at fire time, so callers still see fresh
queryKey lists without re-binding.

Helper is unit-tested with a stub socket: registration count,
fire-time map lookup, cleanup deregistration, missing-event safety.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:11:52 +02:00
Matt Ciaccio
57a099acc4 fix(ui): humanize enum labels, format dates, resolve actor names, loading skeleton
- Documents hub signer status now renders via a label map (`Pending`,
  `Signed`, `Declined`, …) instead of the raw lowercase enum value.
- Invoice detail formats `dueDate` and `paymentDate` as `MMM d, yyyy`
  via `date-fns` instead of leaking raw `2025-03-14` ISO strings, and
  swaps the "Payment Method" free-text input for a `Select` of labelled
  options (`Bank transfer`, `Credit card`, …) so we never store
  `bank_transfer` from a hand-typed field again.
- Interest tabs `MilestoneSection` status badge uses a `humanizeStatus`
  helper so values like `waiting_for_signatures` show as
  `Waiting For Signatures` (correctly title-cased) instead of being a
  lower-snake-case fragment inside an ALL-CAPS pill.
- `OUTCOME_BADGE` in the interest header now has a fall-through that
  renders any unknown outcome as a closed-state badge, preventing a
  closed interest from looking open just because its enum was added
  upstream without a matching label entry.
- Interest timeline route joins the `user` table and returns
  `userName` alongside `userId`; the client renders the resolved name
  instead of a 36-char UUID. Falls back to `'a teammate'` if the user
  row was deleted.
- Invoice "New / Step 3 — Review" replaces the truncated UUID display
  with a server-resolved client/company name via a small `useQuery`,
  so users can confirm they picked the right billing entity before
  submitting.
- New `loading.tsx` for client detail renders a header / tab strip /
  card skeleton during the server-component / initial-query window
  that previously flashed empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:01:35 +02:00
Matt Ciaccio
a391934b73 feat(marina): end-reservation UI + global list, yacht tabs, dashboard distinct count
- End-reservation: API handler existed but had no UI surface. Adds an
  "End reservation" button + date dialog on the reservation detail page,
  visible only when status is `active`.
- New port-scoped `GET /api/v1/berth-reservations` list endpoint and
  `[portSlug]/berth-reservations` page so users can see all reservations
  across all berths from one place (was 404).
- Berths "Edit" menu pushed `/berths/{id}?edit=true` but the detail page
  never read the param — it now auto-opens the edit sheet on mount and
  strips `edit` from the URL.
- Reservation detail no longer shows raw 8-char UUIDs for Berth / Yacht
  / Client; reuses the lazy-fetching link components from the list view.
- Yacht "Interests" and "Reservations" tabs replaced their "Coming soon"
  stubs with real lists fetched from the existing service routes.
- Dashboard "Pipeline Value" KPI used `select(berthId, price)` and
  summed per active interest, so a berth with three open interests was
  counted three times. Switched to `selectDistinct(berthId, price)`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:01:15 +02:00
Matt Ciaccio
e3e0e69c04 fix(documenso): expired event, real signer emails, query invalidation, double-fire
- Wire the `DOCUMENT_EXPIRED` webhook event to `handleDocumentExpired`.
  Previously the handler existed but was never called, leaving expired
  EOIs stuck in `sent` / `partially_signed` forever.
- `sendForSigning` now resolves real port-configured signer emails via
  `getPortEoiSigners(portId)` instead of fabricating
  `developer@{slug}.com` / `sales@{slug}.com`. The Documenso-template
  pathway was already using these; the upload-PDF pathway now matches.
- `handleRecipientSigned` logs a warning when the email match returns
  zero rows so a misconfigured signer isn't a silent no-op.
- `handleDocumentCompleted` skips berth-rule re-evaluation when the
  interest is already at or past `eoi_signed`, preventing a double-fire
  when `DOCUMENT_SIGNED` and `DOCUMENT_COMPLETED` arrive close together.
- EOI generate dialog now invalidates by predicate (any queryKey
  starting with `'documents'`) so the Documents tab and hub counts
  refresh after generation, instead of missing because the actual
  query key shape didn't match the targeted invalidation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:00:58 +02:00
Matt Ciaccio
6af2ac9680 fix(auth): harden admin gate, X-Port-Id, portal JWT, saved-views
- Add server-side `<admin>/layout.tsx` that redirects non-super-admins to
  `/[portSlug]/dashboard`. Closes the gap where any authed user could
  guess the URL and reach Users / Roles / Audit Log / Backup.
- `withAuth` super-admin branch now 404s when the requested portId does
  not match a real port row, preventing a compromised super-admin
  session from operating against a fabricated portId.
- Portal JWTs now carry `aud: 'portal'` + `iss: 'pn-crm'` claims and
  `verifyPortalToken` requires both, so a portal token can no longer be
  replayed against the CRM session path or vice versa. In-flight tokens
  (≤24h) will be invalidated once on deploy.
- `saved-views/[id]` PATCH and DELETE now do an explicit ownership
  check before the service call, returning 403 instead of relying on
  the service's internal userId filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:00:42 +02:00
Matt Ciaccio
a767652d74 feat(sales-ux): triage signals, reminders, realtime toasts, mobile FAB
Sales-CRM workflow batch — closes audit recommendations #2, #3, #4, #6,
#7, #8, #9, #10, #13, #15. Skips #11 (My-pipeline filter — needs a real
assignee column on interests, defer until ownership model lands) and #12
(keyboard shortcuts — explicit user call).

  Interest list (the rep's main triage surface):

    - Last activity column replaces Created (sortable by
      dateLastContact). Postgres NULLs-last on DESC means
      never-contacted leads sort to the bottom — exactly the right
      triage default.
    - Comment-icon next to client name when notesCount > 0, with a
      tooltip showing the count. Cheap, glanceable signal that the
      lead has correspondence to peek at.
    - Urgency badges under stage when criteria fire: "Silent Nd"
      for mid-funnel interests with no contact in 7+ days,
      "EOI Nd" for EOIs awaiting signature 14+ days, "Deposit Nd"
      for eoi_signed interests with no deposit after 21 days.
      Pure derived — no extra fetch, computed from the dates the
      row already returns.
    - Bulk select checkbox column with bulk-archive (existing
      DataTable.bulkActions API; just wired with a confirm-dialog
      and a Promise.all fan-out).
    - Mobile FAB (+) for new interest, anchored above the bottom-tab
      bar with safe-area inset awareness.

    All four signals mirrored on the mobile InterestCard (comment
    icon, urgency badges, last-activity footer).

  Interest detail:

    - Reminder bell badge in the header showing pending/snoozed
      reminder count linked to the interest. Surfaced via
      getInterestById's new `activeReminderCount`.
    - "Latest note" teaser on the Overview tab — truncated 3-line
      preview of the most recent threaded note + relative time +
      "View all" link to the Notes tab. Saves a click for the
      common "what was discussed last?" peek.
    - Color-block swatches in InlineStagePicker dropdown (rounded-sm
      mini-bars in the stage's progressive saturation color, replacing
      the previous tiny dots). Reads as a visual scan instead of a
      list.

  Dashboard:

    - MyRemindersRail on the right sidebar above the existing
      AlertRail. Shows pending+snoozed reminders for the current
      user (overdue first), each with priority pill, relative due
      time, and click-through to the linked interest/client/berth.

  Berth detail:

    - BerthInterestPulse card at the top of the Overview tab,
      replacing the old "buried in tab" pattern. Shows up to 5
      active interests with avatar, stage pill, urgency badges, and
      last-activity. Mirrors the old Nuxt CRM's beloved "Interested
      Parties" panel but with the new triage signals.

  Realtime toasts:

    - New <RealtimeToasts /> mounted inside SocketProvider in the
      dashboard layout. Subscribes to interest:stageChanged,
      document:completed, document:signer:signed, and
      interest:outcomeSet — fires sonner toasts so reps watching any
      page learn about pipeline events without refreshing.

  Service layer:

    - listInterests: notesCount per row (left join + count + groupBy).
    - getInterestById: clientPrimaryPhone + clientPrimaryPhoneE164
      (for the Email/Call/WhatsApp buttons added last commit; phone
      pieces were missing), notesCount, recentNote, activeReminderCount.
    - sortColumn switch handles 'dateLastContact' explicitly; default
      stays 'updatedAt'.

tsc clean. vitest 835/835 pass. ESLint clean on every file touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:09:51 +02:00
Matt Ciaccio
c824b2df12 feat(interests): Email / Call / WhatsApp deep-links on interest header
The interest detail is the rep's workbench — but until now, calling or
emailing the lead meant navigating away to the client page first. Surface
the same Email / Call / WhatsApp affordances that already live on the
client header right where the work is happening.

  - getInterestById: extended to also resolve the linked client's primary
    phone (display value + canonical E.164 form for wa.me).
    `clientPrimaryEmail` is the same column we surfaced earlier for the
    EOI prereq checklist; this commit just adds the phone columns
    alongside it.

  - InterestDetailHeader: new contact-actions row tucked under the meta
    line. Each button is asChild over a real <a href> so middle-click,
    Cmd-click, and screen-readers behave correctly. Renders only the
    buttons whose underlying contact channel is present (Email-only when
    no phone is on file, etc.). The whole row is hidden when the client
    has no contacts at all.

  - WhatsApp number prefers the E.164 form; falls back to digits-stripped
    display value when the canonical form is missing.

tsc clean. vitest 835/835 pass. ESLint clean on every file touched.

Closes audit recommendation #1 (top-of-list — biggest sales-workflow
win per click saved).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:33:13 +02:00
Matt Ciaccio
d197f8b321 feat(eoi): align prerequisites with EOI document structure
Match the gate to the actual EOI's structure (Section 2 vs Section 3) so
the rep can generate the document the moment they have what they need —
and not before.

  Required (Section 2 — top paragraph):
    - Client name
    - Client primary email
    - Client primary address

  Optional (Section 3 — left blank when absent):
    - Linked yacht (name, dimensions)
    - Linked berth (mooring number)

Previously the dialog blocked generation unless yacht AND berth were both
linked, which was overzealous — early-stage EOIs are routinely sent before
a specific berth is pinned down.

  - eoi-context.ts: yacht and berth are now nullable in the returned
    context. The hard ValidationError is now driven by the EOI's Section
    2 fields (name/email/address) rather than yacht/berth presence. The
    owner block falls back to the interest's client when no yacht is
    linked, so signing parties remain resolvable.

  - documenso-payload.ts + fill-eoi-form.ts: Section 3 form values
    render as empty strings when yacht or berth are absent, so the
    rendered PDF leaves those template inputs blank.

  - document-templates.ts: yacht.* and berth.* tokens fall back to
    empty strings; the legacy-fallback catch handler also recognises
    the new "missing required client details" error.

  - interests.service.ts: getInterestById now also returns
    `clientPrimaryEmail` and `clientHasAddress` so the Documents tab
    can compute the EOI prerequisites checklist client-side without an
    extra fetch.

  - eoi-generate-dialog.tsx: prereqs split into two groups visually —
    Required (with red ✗ when missing) and Optional (with grey – when
    absent). The Generate button only requires the Required block to
    pass. A small amber banner surfaces when Required is incomplete so
    the rep knows where to add the missing data.

Tests: 835/835 pass. Replaces the obsolete "throws on missing yacht/
berth" tests with parity coverage for the new behaviour ("builds a
valid context when yacht/berth missing", "throws when client email/
address missing"). Adds a payload test for the empty-Section-3 case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:11:14 +02:00
Matt Ciaccio
76a7387dcc fix(ux): batch UX audit fixes across spine pages
Comprehensive audit findings rolled up into one pass.

Bugs:

  - dialog.tsx — sm-breakpoint centering classes (sm:left-[50%] /
    sm:top-[50%]) were being silently stripped by tailwind-merge because
    the base inset-0 + sm:inset-auto pair counted as a conflict. Replaced
    with explicit per-side utilities (top-0 right-0 bottom-0 left-0 +
    sm:right-auto sm:bottom-auto). Every Dialog instance now centers
    correctly on desktop. (Affected 16 dialog consumers.)

  - interest-documents-tab.tsx — useQuery shared the queryKey
    ['interests', interestId] with the parent InterestDetail's query but
    returned a different shape ({ data: ... } envelope vs unwrapped).
    They clobbered each other's cache on tab mount, degenerating the
    parent header to "Unknown Client" / "Open" briefly. Unified the
    queryFn shape so the cache stays consistent.

  - interest-tabs.tsx — milestone steps now derive done-state from
    PIPELINE_STAGES.indexOf(currentStage) >= step.advanceStage_idx as
    well as from the date stamp. Stage truth > date truth. Seeded /
    imported interests that arrived past `open` without per-step dates
    now correctly show their milestone steps as checked.

  - interest-detail.tsx — wires useMobileChrome so the mobile topbar
    shows the client name instead of the interest UUID.

  - interest-documents-tab.tsx — empty state restructured to a centered
    "No documents yet — Generate EOI" CTA card instead of a small
    primary button floating in the corner.

  - timeline/route.ts — synthesizes a "Created at <stage>" event when
    no audit-log rows exist for the interest, so the Activity tab
    isn't empty for seeded interests.

  - lead-source-chart.tsx — pie radii switched from fixed 90px/50px
    to "70%"/"40%" so the pie scales with the container instead of
    being clipped at narrow widths; reserved 40px for the legend.

Visual / clarity:

  - interest-detail-header.tsx — Won/Lost rendered as branded text
    buttons on desktop ("Mark won", "Close as lost") and icon-only on
    mobile via `hidden sm:inline`. Edit/Archive stay icon-only. Reopen
    promoted to a labeled button when the interest is closed. Added
    "Last contact Xd ago" to the meta row.

  - detail-header-strip.tsx — py-4 → py-3 (tighter strip).

  - interest-tabs.tsx — milestone cards: the next pending milestone
    gets a brand-blue ring + "NEXT" pill so the user can see at a
    glance which lifecycle to act on. Its primary action gets the
    filled button variant.

  - interest-tabs.tsx — Deposit milestone: invoice flow promoted to
    primary CTA ("Create deposit invoice"), manual stage advance
    demoted to a small text link ("Mark received manually"). Reflects
    the actual recommended path now that recordPayment auto-advances
    on payment.

  - inline-editable-field.tsx — pencil affordance shown faintly
    (opacity-20) at rest so users discover that fields are editable
    without having to hover-test every label. Lifts to opacity-60 on
    hover.

  - constants.ts — STAGE_SHORT_LABELS map for cramped contexts;
    pipeline-chart.tsx + pipeline-funnel-chart.tsx use them on mobile
    via useIsMobile, so the rotated 9-stage axis isn't a wall of
    overlap on a 393px screen.

  - client-pipeline-summary.tsx — StageStepper rebuilt as a single
    segmented progress bar instead of 9 micro-dots + connectors that
    rendered inconsistently at tight widths. Each stage is an equal
    slice that lights up as the interest reaches it; tooltips on hover
    give the full stage name. Also dropped a pre-existing dead `br`
    variable.

  - dashboard empty states — Lead Source, Revenue Breakdown, Pipeline
    Funnel, and Recent Activity now have helpful descriptions explaining
    what populates them, instead of bare "No interests in range".

  - use-paginated-query.ts — reuses `&` when the endpoint already has
    `?`, so callers like the documents hub don't generate
    `…?tab=eoi_queue&signatureOnly=true?page=1&limit=25` (which the API
    rejected as 400). Caught while testing the now-removed EOI route
    but applies broadly.

tsc clean. vitest 832/832 pass. eslint 0 errors (down from 1
pre-existing) on every file touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:24:15 +02:00
Matt Ciaccio
868b1f40c0 fix(nav): drop dedicated EOI route + alerts sidebar entry, fix paginated-URL bug
Trimmed two surfaces that didn't earn their nav weight:

  - The /[port]/documents/eoi route added in the previous commit was
    redundant with the per-interest EOI status milestones already on
    the interest detail and the existing eoi_queue tab inside the
    Documents hub. Removed the route + the "EOI queue" sidebar entry.
  - The Alerts sidebar entry was promoting a mostly-empty page that
    duplicated the dashboard alert rail. Dropped the entry; the
    /[port]/alerts route stays accessible via the dashboard rail's
    "View all" link and the topbar bell, which is enough for the
    audit-trail use case.

While testing the EOI tab, found and fixed a real bug: usePaginatedQuery
was producing malformed URLs like `…?tab=eoi_queue&signatureOnly=true?page=1&limit=25`
(two `?` separators) when the endpoint string already carried query
params. The API rejected those with 400, so the EOI tab in the
documents hub was silently broken. The hook now uses `&` when the
endpoint already contains a `?`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:30:27 +02:00
Matt Ciaccio
dbbd03fd22 feat(sales): admin-configurable EOI signers + richer timeline events
1. Per-port EOI signer config

     - New `eoi_signers` system_settings key (JSON: { developer, approver },
       each `{ name, email }`). Settings UI exposes it under Admin → Settings.
     - getPortEoiSigners(portId) reads the setting with a typed validator;
       falls back to the legacy David Mizrahi / Abbie May defaults if the
       row is missing or malformed (so older ports keep working until an
       admin saves a value).
     - Both EOI generation pathways now read from the helper instead of
       hardcoded constants:
         * documenso-template path (generateAndSignViaDocumensoTemplate)
         * in-app PDF-fill path (generateAndSignViaInApp)

  2. Timeline upgrades

     The interest detail Activity tab now distinguishes the new automation
     events that arrived with sessions 1+2:

     - Stage auto-advances (userId='system') get a small "Auto" pill and
       carry their reason into the description (e.g. "Stage advanced to
       EOI Signed (auto-advanced — EOI signed via Documenso)").
     - outcome_set events show "Marked as Won" / "Marked as Lost — went
       to another marina" with optional reason; trophy/X icons.
     - outcome_cleared events show "Reopened to {stage}" with a refresh
       icon.
     - Document events humanized: "Document 'X' fully signed" instead
       of "Document X: completed".
     - Stage labels run through stageLabel() so the timeline shows the
       human label, not the enum key.
     - Timestamps switched to relative-time with full-date tooltip.
     - "by system" is rendered plainly (no longer the literal user-id).

tsc clean. vitest 832/832 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:19:55 +02:00
Matt Ciaccio
ba5fb6db5e feat(sales): EOI queue route + invoice→deposit auto-advance + won/lost outcomes
Three independent strengthenings of the sales spine that the prior coherence
sweep made it possible to do cleanly.

  1. EOI queue page

     - Sidebar entry under Documents → "EOI queue".
     - Route /[port]/documents/eoi renders DocumentsHub with the existing
       eoi_queue tab pre-selected (filters in-flight EOIs only).
     - .gitignore: tightened root-only `eoi/` ignore so the documents/eoi
       route is no longer silently excluded.

  2. Invoice ↔ deposit link

     - invoices.interestId (FK, ON DELETE SET NULL) + invoices.kind
       ('general' | 'deposit'). Indexed on (port_id, interest_id).
     - createInvoiceSchema requires interestId when kind === 'deposit';
       the service validates the linked interest belongs to the same port
       before insert.
     - recordPayment auto-advances pipelineStage to deposit_10pct (via
       advanceStageIfBehind) when a paid invoice is kind=deposit and has
       an interestId. No-op if the interest is already further along.
     - "Create deposit invoice" link added to the Deposit milestone on the
       interest detail. Links to /invoices/new?interestId=…&kind=deposit;
       the form prefills the billing entity from the linked interest's
       client and shows a context banner.

  3. Won / lost terminal outcomes

     - interests.outcome ('won' | 'lost_other_marina' | 'lost_unqualified'
       | 'lost_no_response' | 'cancelled') + outcomeReason text +
       outcomeAt timestamp. Indexed on (port_id, outcome).
     - setInterestOutcome / clearInterestOutcome services + POST/DELETE
       /api/v1/interests/:id/outcome endpoints (gated by change_stage
       permission). Setting an outcome moves the interest to `completed`
       in the same write; clearing reopens to `in_communication` (or a
       caller-specified stage).
     - Mark Won / Mark Lost icon buttons on the interest detail header,
       plus an outcome badge that replaces the stage pill once a terminal
       outcome is set, plus a Reopen button.
     - Funnel + dashboard math updated to exclude lost/cancelled outcomes
       from active calculations (KPIs.activeInterests, pipelineValueUsd,
       getPipelineCounts, computePipelineFunnel, getRevenueForecast).
       The funnel now also returns a `lost` summary so callers can
       surface leakage without polluting conversion percentages.

Schema changes shipped via 0019_lazy_vampiro.sql; applied to dev DB
manually via psql because drizzle-kit push hits a pre-existing zod
parsing issue on the companies index. Dev server may need a restart
to flush prepared-statement caches.

tsc clean. vitest 832/832 pass. ESLint clean on every file touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:01:33 +02:00
Matt Ciaccio
886119cbde refactor(sales): consolidate pipeline stages + wire EOI auto-advance
The 8→9 stage refresh from earlier today only updated constants.ts and the DB —
20 component/service files still hardcoded the old enum, leaving labels blank,
filter dropdowns wrong, kanban columns mismatched, and the analytics funnel
silently dropping new-stage rows. The platform also never advanced
pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus
but left the user-visible stage stuck.

This commit closes both gaps:

  1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS,
     STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus
     stageLabel / stageBadgeClass / stageDotClass / safeStage /
     canTransitionStage helpers. components/clients/pipeline-constants.ts
     becomes a re-export shim so existing imports keep working.

  2. 18 stale-enum surfaces migrated — interest list (table, card, filters,
     form, stage picker), pipeline board, client card, berth interests tab,
     portal client interests page, dashboard pipeline / funnel / revenue-
     forecast charts, settings pipeline_weights default, dashboard.service
     weights, analytics.service funnel stages, alert-rules stale-interest
     filter, interest-scoring stage rank.

  3. Documents tab wired into interest detail — replaced the placeholder in
     interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the
     EOI launcher is back where salespeople work.

  4. Auto-advance — new advanceStageIfBehind() in interests.service.ts
     (forward-only, no-op if interest is already past the target). Called
     from documents.service.ts on send (→ eoi_sent), Documenso completed
     webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed).

  5. Transition guard — canTransitionStage() blocks egregious skips
     (e.g. completed → open, open → contract_signed). Enforced in
     changeInterestStage before the DB write.

Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832,
ESLint clean on every file touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
Matt Ciaccio
0d357731ad chore(dev): install and wire react-grab
react-grab lets you point at any DOM element on the page and press
Cmd+C to copy the file name, React component, and HTML source — then
paste straight into a coding agent (Claude Code, Cursor, etc.) for
much higher-fidelity context.

Wiring (auto-detected by `npx grab@latest init --force`): a Next
<Script> tag in src/app/layout.tsx that loads the bundle from unpkg
in development only. Production builds skip the script entirely so
no extra weight ships to end users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:37:40 +02:00
Matt Ciaccio
a75d4f5d69 feat(mobile): redesign topbar + collapse cumbersome page-header on mobile
Topbar (mobile-topbar.tsx):
  - Bumped to 56px to match standard mobile-app proportions.
  - Deep-navy gradient surface (#1e2844 -> #171f35) with white type —
    matches the desktop sidebar identity, gives the app a premium
    finish instead of generic white-with-text.
  - Brand "PN" wordmark mark on the left when no back affordance is
    needed (rounded brand-blue square, inset highlight + drop shadow).
  - Soft glow shadow underneath for elevation depth instead of a hard
    bottom border.
  - White-on-navy back arrow with active-state translucent fill.

PageHeader (page-header.tsx):
  - On mobile, the gradient hero strip + duplicate title + description
    block now collapses entirely — the topbar already shows the title,
    so duplicating it in the body wasted a third of the viewport.
  - The actions slot remains rendered as a flush right-aligned row so
    primary buttons (date-range pickers, "+ New X") stay accessible.
  - Desktop rendering is untouched.

Mobile shell (mobile-layout.tsx):
  - Top buffer 16px below the topbar so content doesn't ride flush.
  - Bottom buffer 32px above the tab bar so the last card breathes.

CSS (globals.css):
  - Hide the react-query-devtools floating button below lg: — it was
    overlapping the bottom-tab bar's "More" affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:34:28 +02:00
Matt Ciaccio
0fb7920db5 fix(auth/mobile): support LAN-IP access in dev + edge-to-edge auth bg
- branded-auth-shell: split the background image into a separate
    fixed-positioned layer behind the layout. Previously the bg was on
    a min-h-screen container and iOS Safari left visible whitespace at
    the top/bottom when the URL bar showed/hid (the container's height
    didn't match the visual viewport). Now the bg pins to the actual
    visible viewport via `fixed inset-0`. min-h-[100dvh] also added
    so the layout layer matches.
  - auth client: derive baseURL from window.location.origin instead of
    NEXT_PUBLIC_APP_URL. Same dev build now works whether opened on
    localhost (Mac) or the LAN IP (iPhone on Wi-Fi).
  - auth server: dynamic trustedOrigins function that allows
    localhost / 127.x / 192.168.x / 10.x in dev (function form
    inspects the incoming request's Origin). Production stays locked
    to NEXT_PUBLIC_APP_URL.
  - new dev helper: scripts/dev-set-password.ts to set a user's
    better-auth password directly (bypasses the email-reset flow);
    used to bootstrap matt@letsbe.solutions for mobile testing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:21:59 +02:00
Matt Ciaccio
16ad61ce15 fix(mobile): hide duplicate detail-header title on mobile
The mobile topbar already shows the entity name pushed via
useMobileChrome, so the gradient detail-header strip was rendering it
a second time. Hides the inline h1 below sm: while keeping the source
/ email / phone meta and action buttons visible — the strip's
practical content (actions + meta) stays where users need it, and the
title responsibility moves cleanly to the topbar.

Affects: clients, yachts, companies, berths detail headers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:09:32 +02:00
Matt Ciaccio
d080bc52fa feat(mobile): touch up new-invoice + scan-receipt forms
- new invoice: push "New Invoice" to mobile topbar, hide the
    redundant inline back+title row on mobile.
  - scan receipt: dedicated "Take photo" primary button on mobile
    (uses input capture="environment" to open the camera directly)
    plus "Choose from library" secondary. Drop-zone framing kept on
    desktop. Push "Scan Receipt" title to mobile topbar.

Both forms now take their entity title from the topbar and free up
real-estate at the top for actual content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:06:09 +02:00
Matt Ciaccio
a653c8e039 fix(mobile): wrap detail-header actions on narrow viewports
Action buttons in entity detail headers (Invite/GDPR/Archive on
clients, similar sets elsewhere) overflowed off-screen at 393px
because the actions row was flex without flex-wrap. Adds flex-wrap
so buttons drop to a second/third row instead of clipping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:48:51 +02:00
Matt Ciaccio
7e8110b2ff feat(mobile): show entity name in mobile topbar on detail pages
Detail pages (clients, yachts, companies, berths, invoices, expenses)
now push their entity name + a back-button toggle to the mobile
topbar via useMobileChrome, replacing the URL UUID fallback that was
rendering before.

Supporting changes:
  - useMobileChrome() no longer throws when called outside the
    MobileLayoutProvider — desktop-tree consumers get a no-op
    setChrome so callers don't have to branch on shell type.
  - setChrome is now stable across renders (useCallback) so callers'
    useEffect dependency arrays don't infinite-loop.
  - DetailPageShell now also pushes its entityName + cleans up on
    unmount, and hides its desktop-only sticky header on mobile so it
    doesn't double up with the topbar (no current callers, prep for
    Phase 4 migration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:46:32 +02:00
Matt Ciaccio
9eadaf035e fix(mobile): widen ListCard href type to Route
Project has experimental.typedRoutes enabled; passing template-literal
URLs through the Link href prop requires the wider Route type. Cast
at the Link boundary inside ListCard so callers can keep the simpler
string-typed href API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:40:52 +02:00
Matt Ciaccio
bcea28cd71 feat(mobile): mobile cards for reminders, audit log, users
Three new <EntityCard> files using the shared <ListCard> shell, wired
into each list page's <DataTable> via cardRender.

  - ReminderCard:   Bell icon, related-entity subtitle (User/Anchor/
                    FileText icon by entity type), due-date meta with
                    past-due flag, accent bar (rose=past-due,
                    amber=pending, slate=snoozed, emerald=done).
                    Snooze/Complete/Edit/Delete in actions menu.
  - AuditLogCard:   Action icon (Plus/Pencil/Trash2/Eye), entity
                    title, "{verb} by {actor}" subtitle, timestamp
                    meta, optional changed-field chip line. Accent
                    bar by action (created=emerald, updated=blue,
                    deleted=rose). Immutable, no actions menu.
  - UserCard:       Initials avatar, displayName/email, role meta
                    (Shield icon), last-login distance, "Inactive"
                    pill when deactivated. Accent bar (violet=
                    super_admin, slate=inactive, none=active).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:39:06 +02:00
Matt Ciaccio
722491a9dd feat(mobile): mobile cards for yachts, companies, berths, invoices, expenses
Five new <EntityCard> files using the shared <ListCard> shell, wired
into each list page's <DataTable> via cardRender. Desktop view
(lg+) is unchanged.

  - YachtCard:    Ship icon, owner subtitle (User/Building2 icon by
                  ownerType), dimensions in meters preferred, hull #,
                  status pill. No accent bar (status is free-text).
  - CompanyCard:  Building2 icon, legalName subtitle, country (MapPin)
                  + tax id (Hash) meta, member/yacht count line.
  - BerthCard:    Anchor icon, area subtitle (MapPin), dimensions
                  meta, status pill. Status-encoded accent bar
                  (emerald=available, amber=under_offer, slate=sold).
  - InvoiceCard:  FileText icon, client subtitle, due date (Calendar)
                  meta, prominent currency-formatted amount. Status
                  accent bar (emerald=paid, orange=overdue, ...).
  - ExpenseCard:  Receipt icon, category subtitle, expense date meta,
                  prominent amount, payment-status pill, "Possible
                  duplicate" pill when duplicateOf is set. Accent bar
                  by paymentStatus, overridden to amber when flagged
                  as duplicate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:34:04 +02:00
Matt Ciaccio
6009ccb7de feat(mobile): mobile card view for clients + interests lists
Adds optional cardRender prop to <DataTable> that switches the layout
to a vertical card list below lg: while keeping the same TanStack
table instance powering both views (pagination, sort, selection).

New shared shell:
  - <ListCard>          rounded card with optional left status accent bar,
                        whole-card link to detail page, top-right actions
                        slot, and tactile hover/active states.
  - <ListCardAvatar>    40px brand-tinted circle (initials or domain icon).
  - <ListCardMeta>      inline icon + muted text segment.
  - deriveInitials()    shared helper that ignores numeric tokens (so
                        "Recovery Test 1777" -> "RT", not "R1").

Clients and interests pages now render mobile cards via cardRender
using this shell; desktop view (lg+) is unchanged. Interests cards
encode pipeline stage as a left-edge accent strip whose saturation
deepens with pipeline progression (open -> completed). Berths display
with an Anchor icon; null-berth interests fall back to a Compass +
"General interest" italic label. Hot leads get a discreet "Hot" pill.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:27:53 +02:00
Matt Ciaccio
71da6e8fdc feat(mobile): swap admin page headers to PageHeader
Mechanical sweep replacing the plain h1+p header markup with the
mobile-aware PageHeader primitive across 12 admin pages: index,
backup, branding, documenso, email, import, invitations, monitoring,
onboarding, reminders, reports, webhooks. Webhooks "Add Webhook"
button preserved via the actions slot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:57:52 +02:00
Matt Ciaccio
c405124bc3 feat(mobile): swap reports header to PageHeader
Plain h1 + p replaced with the mobile-aware PageHeader primitive so
the reports landing matches dashboard/settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:55:11 +02:00
Matt Ciaccio
53cbee1d3d fix(mobile): tighten Card padding on mobile (p-4 sm:p-6)
CardHeader/CardContent/CardFooter were uniformly p-6 (24px), which on
top of the mobile shell's 16px outer padding pushed form content 40px
inward — making cards feel content-shifted on a 393px viewport. Drops
to p-4 (16px) below sm and keeps p-6 from sm+ so desktop is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:55:09 +02:00
Matt Ciaccio
ac7f1db62c fix(mobile): add horizontal padding to mobile shell main
Content cards/lists were rendering edge-to-edge on mobile because the
mobile shell's <main> had no horizontal padding (only safe-area top/
bottom). Adds px-4 to match the breathing room desktop gets from p-6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:45:20 +02:00
Matt Ciaccio
5d44f3cfa4 fix(test): raise mobile-audit timeout to 30min for 4-viewport runs
Task 24 audit run hit the 10-minute test.setTimeout ceiling after capturing
2 of 4 viewport passes (iphone-se complete, iphone-16 complete-ish, 16-pro
partial, pro-max not started). 4 viewports × ~45 routes × slowMo: 200 needs
more headroom than 600s gave. 30min is comfortable headroom; the per-test
project timeout is matched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:15:26 +02:00
Matt Ciaccio
d0540dca55 fix(build): extract route.ts handlers to handlers.ts (CLAUDE.md convention)
8 API route files were exporting handler functions directly from route.ts,
which Next.js 15 rejects with "$NAME is not a valid Route export field".
Per CLAUDE.md convention, service-tested handler functions live in sibling
handlers.ts files and route.ts only re-exports the GET/POST/etc. wrapped
in withAuth(withPermission(...)).

Discovered during the mobile-foundation Task 24 build validation; the route
files predate this branch but the build was never re-run on data-model.

Files:
- berth-reservations/[id], companies/autocomplete, companies/[id]/members
  + nested mid/set-primary, yachts/autocomplete, yachts/[id]/transfer,
  yachts/[id]/ownership-history
- Integration tests updated to import from handlers.ts (companies,
  memberships, reservations, yachts-detail)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:14:40 +02:00
Matt Ciaccio
0e9c24e222 test(visual): add mobile shell snapshot baselines (dashboard + more sheet)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 14:47:26 +02:00
Matt Ciaccio
3aba2181dc feat(test): extract anchor iPhone device descriptors to shared fixture
Move the four iPhone viewport descriptors (SE, 15/16, 16/17 Pro, Pro Max)
into tests/e2e/fixtures/devices.ts so the upcoming visual spec (Task 23)
can share the same anchors. The mobile-audit spec now spreads each
descriptor and adds a slug `name` plus a human `label` for the run header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:31:51 +02:00
Matt Ciaccio
6237ad1567 feat(mobile): add FilterChips primitive (horizontal chip row with Add-filter trigger) 2026-04-29 14:28:33 +02:00
Matt Ciaccio
34916d855e feat(mobile): add DataView (TanStack table on lg+, card list below) with cardRender callback 2026-04-29 14:27:17 +02:00
Matt Ciaccio
41ae8a328f feat(mobile): add DetailPageShell with sticky header + mobile sticky-action shelf 2026-04-29 14:25:45 +02:00
Matt Ciaccio
1ff3160eac feat(mobile): add ActionRow with horizontal-scroll-snap on mobile, wrap on desktop 2026-04-29 14:24:43 +02:00
Matt Ciaccio
5698d742d3 feat(mobile): make PageHeader mobile-aware (stack below sm, hide description when actions present) 2026-04-29 14:23:40 +02:00
Matt Ciaccio
e6ce265be0 fix(mobile): drop positive display rule that overrode desktop shell's flex layout 2026-04-29 14:20:11 +02:00
Matt Ciaccio
19bc2f2a54 feat(mobile): mount MobileLayout alongside desktop shell, remove legacy sidebar mobile-drawer 2026-04-29 14:18:28 +02:00
Matt Ciaccio
b0a11f1785 feat(mobile): add MobileLayout shell composing topbar + content + bottom tabs + more sheet 2026-04-29 14:16:30 +02:00
Matt Ciaccio
3cbf2444fe feat(mobile): add MoreSheet (3-column grid of long-tail nav items in a bottom drawer) 2026-04-29 14:15:25 +02:00
Matt Ciaccio
0330be1312 feat(mobile): add Drawer (vaul wrapper) for native-feel bottom sheets 2026-04-29 14:14:18 +02:00
Matt Ciaccio
210360738d feat(mobile): add MobileBottomTabs with 5 fixed tabs (Dashboard/Clients/Yachts/Berths/More) 2026-04-29 14:13:09 +02:00
Matt Ciaccio
4df04e1a58 feat(mobile): add MobileTopbar with title, back-button, and primary-action slots 2026-04-29 14:12:15 +02:00
Matt Ciaccio
0c3baf04c5 feat(mobile): add MobileLayoutProvider context + useMobileChrome hook 2026-04-29 14:11:27 +02:00
Matt Ciaccio
79667b24da chore(pwa): add placeholder icons (icon-192/512/512-maskable, apple-touch-icon) 2026-04-29 14:10:14 +02:00
Matt Ciaccio
c4fdb29bbe feat(mobile): render Dialog full-screen below sm, centered modal at sm+ 2026-04-29 14:08:14 +02:00
Matt Ciaccio
38527d71fc feat(mobile): bump touch-target heights on Button/Input/Textarea, keep 16px to prevent iOS zoom 2026-04-29 14:06:59 +02:00
Matt Ciaccio
3fbfba6598 chore(deps): add vaul for native-feel bottom sheets 2026-04-29 14:05:10 +02:00
Matt Ciaccio
e3a835675b feat(mobile): add useIsMobile() hook backed by matchMedia (visual-test-only) 2026-04-29 14:04:02 +02:00
Matt Ciaccio
1b085f81ed feat(mobile): add CSS rules to switch shells based on data-form-factor + viewport 2026-04-29 14:00:49 +02:00
Matt Ciaccio
9f786fbcf3 feat(mobile): set data-form-factor body attr from User-Agent in root layout 2026-04-29 13:59:03 +02:00
Matt Ciaccio
906127a292 feat(mobile): add safe-area spacing utilities (pt-safe-top, pb-safe-bottom, etc.) 2026-04-29 13:56:53 +02:00
Matt Ciaccio
737b43589b feat(mobile): add viewport meta, theme-color, and PWA metadata to root layout 2026-04-29 13:55:37 +02:00
Matt Ciaccio
fbb1f1f366 scaffold(mobile): branch setup — audit harness, spec, plan, gitignore + client-portal cleanup
Pre-execution baseline for the mobile foundation PR:

- Mobile audit harness (tests/e2e/audit/mobile.spec.ts + mobile-audit Playwright project) — visits every page at four anchor iPhone viewports (375/393/402/440), screenshots full-page to .audit/mobile/, generates index.md
- Design spec (docs/superpowers/specs/2026-04-29-mobile-optimization-design.md) — adaptive shell + responsive content; full active-iPhone-range coverage; foundation + per-page migration phases
- Implementation plan (docs/superpowers/plans/2026-04-29-mobile-foundation.md) — 24 TDD tasks for the foundation PR
- .gitignore: ignore /client-portal/ (legacy nested Nuxt repo) and /.audit/ (regenerable screenshots)
- Remove phantom client-portal gitlink (mode 160000 with no .gitmodules)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:49:38 +02:00
Matt Ciaccio
ba89b61b3f fix(security): port-scope clientId/berthId/yachtId on interests + clientRelationships
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m17s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
Pass-6 findings — both MEDIUM cross-tenant FK injection.

- interests.service: createInterest/updateInterest/linkBerth accepted
  clientId/berthId/yachtId from the request body without verifying the
  referenced row belongs to the caller's port. getInterestById joins
  clients/berths/yachtTags on these FKs without a port filter, so a
  port-A caller could splice a foreign-port id and surface that
  tenant's clientName, mooringNumber, or yacht ownership on read.
  New assertInterestFksInPort helper guards all three surfaces.

- clients.service.createRelationship: accepted clientBId from the
  body without a port check; the relationship list endpoint joins
  clients without filtering by port, so the foreign client's name
  + email would render in the relationships tab. Now verifies
  clientBId belongs to portId and rejects self-relationships.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 04:14:09 +02:00
Matt Ciaccio
4eea19a85b sec: lock down 5 cross-tenant FK gaps from fifth-pass review
1. HIGH — reminders.create/updateReminder accepted clientId/interestId/
   berthId from the body and persisted them with no port check; getReminder
   then hydrated the row via Drizzle relations (no port filter on the
   join), so a port-A user with reminders:create could exfiltrate any
   port-B client/interest/berth row by guessing its UUID. New
   assertReminderFksInPort gates create + update.

2. HIGH — listRecommendations(interestId, _portId) discarded portId
   entirely; the route GET /api/v1/interests/[id]/recommendations
   forwarded the URL id straight through. A port-A user with
   interests:view could read any other tenant's recommended berths
   (mooring numbers, dimensions, status). Service now verifies the
   interest belongs to portId and joins berths filtered by port.

3. HIGH — Berth waiting list. The PATCH route did not pre-check that
   the berth belonged to ctx.portId — a port-A user with
   manage_waiting_list could reorder a port-B berth's queue. Separately,
   updateWaitingList accepted arbitrary entries[].clientId and inserted
   them without verifying tenancy, polluting the table with foreign-port
   FKs. Both gaps closed.

4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths)
   accepted any tagId and inserted into the join table. The tags table
   is per-port but the join only carries a single-column FK. The
   downstream getById join `tags ON join.tag_id = tags.id` has no port
   filter, so a foreign tag's name + color render in the requesting port.
   Helper now batch-validates tagIds belong to portId before insert.

5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission
   gate (any role, including viewer, could write) and didn't validate
   that the URL entityId pointed at a port-scoped entity of the field
   definition's entityType. Route now uses
   withPermission('clients','view'/'edit',…); service validates the
   entityId per resolved entityType (client/interest/berth/yacht/company)
   against portId.

Test mocks updated to cover the new entity-port-scope check.
818 vitest tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
Matt Ciaccio
47a1a51832 sec: webhook SSRF guard, IMAP-sync owner check, watcher port membership
Three findings from a fourth-pass review:

1. MEDIUM — webhook URL SSRF. The validator only enforced HTTPS+URL
   parse; it accepted private/loopback/link-local/.internal hosts. The
   delivery worker fetched arbitrary URLs and persisted up to 1KB of
   response body into webhook_deliveries.response_body, which is then
   surfaced via the deliveries listing endpoint — a port admin could
   register a webhook to an internal HTTPS endpoint, hit the test
   endpoint to force immediate dispatch, and read the response back.
   Validator now rejects RFC-1918/loopback/link-local/CGNAT/ULA IPs
   (v4 + v6) and .internal/.local/.localhost/.lan/.intranet/.corp
   suffixes; the worker re-resolves the hostname at dispatch time and
   blocks before fetch (DNS rebinding defense). 21-case unit test
   covers the matrix.

2. MEDIUM — POST /api/v1/email/accounts/[id]/sync had no owner check.
   Any user with email:view could enqueue an inbox-sync job for any
   accountId, which the worker would honour using the foreign user's
   decrypted IMAP credentials and advance the account's lastSyncAt
   (data-loss risk on the legitimate owner's next sync). Route now
   asserts account.userId === ctx.userId before enqueueing, matching
   the toggle/disconnect endpoints.

3. MEDIUM — addDocumentWatcher (and the wizard / upload watcher
   inserts) didn't validate the watcher's userId belonged to the
   document's port. notifyDocumentEvent then emitted a real-time
   socket toast + email containing the document title to the foreign
   user. New assertWatchersInPort helper verifies each candidate has
   a userPortRoles row for the port (super-admin bypass).

818 vitest tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:15:39 +02:00
Matt Ciaccio
9a5479c2c7 sec: lock down socket.io room subscription + crm-invite cross-tenant ops
1. HIGH — Socket.IO accepted client-supplied `auth.portId` in the
   handshake without verifying the user actually held a role in that
   port, then unconditionally joined the socket to `port:${portId}`.
   The `join:entity` handler also skipped authorization. This let any
   authenticated CRM user receive realtime events from any other
   tenant: invoice numbers + totals + client names, document signer
   emails, registration events with full client name + berth, file
   uploads, etc. Auth middleware now resolves the user's
   userPortRoles (or isSuperAdmin) before honouring portId, and
   join:entity verifies the entity's port matches a port the user
   has access to. Pre-existing pre-branch issue but fixed here given
   the explicit "all data is extremely sensitive" directive.

2. MEDIUM — listCrmInvites issued a global SELECT with no port
   scope. The crm_user_invites table has no portId column (invites
   mint global better-auth users, then port roles are assigned
   later). The previous gating on per-port admin.manage_users let
   any director enumerate every other tenant's pending invitee
   emails + isSuperAdmin flags — a phishing target list and a
   super-admin onboarding timing oracle. Restrict GET (list),
   DELETE (revoke), and POST resend to ctx.isSuperAdmin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:00:55 +02:00
Matt Ciaccio
e06fb9545b sec: lock down 5 cross-tenant IDORs uncovered in second-pass review
1. HIGH — /api/v1/admin/ports/[id] PATCH+GET let any port-admin
   (manage_settings) mutate any other tenant's port row by passing the
   foreign id in the path. Now non-super-admins must target their own
   ctx.portId; listPorts and createPort are super-admin only.

2. HIGH — Invoice create/update accepted arbitrary expenseIds and
   linked them into invoice_expenses with no port check; the GET
   response then re-emitted those foreign expense rows via the
   linkedExpenses join. assertExpensesInPort now validates each id
   belongs to the caller's portId before insert; getInvoiceById's
   join filters by expenses.portId as defense-in-depth.

3. HIGH — Document creation paths (createDocument, createFromWizard,
   createFromUpload) persisted user-supplied clientId/interestId/
   companyId/yachtId/reservationId without verifying those FKs were
   in-port. sendForSigning then loaded the foreign client/interest by
   id alone and pushed their PII into the Documenso payload. New
   assertSubjectFksInPort helper rejects out-of-port FKs at create
   time; sendForSigning's interest+client lookups now also filter by
   portId.

4. MEDIUM — calculateInterestScore read its redis cache before
   verifying portId, and the cache key was interestId-only — a
   foreign-port caller could observe a cached score breakdown.
   Cache key now includes portId, and the port-scope DB lookup runs
   before any cache.get.

5. MEDIUM — AI email-draft job results were retrievable by anyone who
   could guess the BullMQ jobId (default sequential integers). Job
   ids are now random UUIDs, requestEmailDraft validates interestId/
   clientId belong to ctx.portId before enqueueing, the worker's
   client lookup is port-scoped, and getEmailDraftResult requires
   the caller to match the original requester's userId+portId before
   returning the drafted subject/body.

The interest-scoring unit test that asserted "DB is bypassed on cache
hit" is updated to reflect the new (security-correct) ordering.
Two new regression test files cover the email-draft binding (5 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:48:43 +02:00
Matt Ciaccio
4c5334d471 sec: gate super-admin invite minting, OCR settings, and alert mutations
Three findings from the branch security review:

1. HIGH — Privilege escalation via super-admin invite. POST
   /api/v1/admin/invitations was gated only by manage_users (held by the
   port-scoped director role). The body schema accepted isSuperAdmin
   from the request, createCrmInvite persisted it verbatim, and
   consumeCrmInvite copied it into userProfiles.isSuperAdmin — granting
   the new account cross-tenant access. Now the route rejects
   isSuperAdmin=true unless ctx.isSuperAdmin, and createCrmInvite
   requires invitedBy.isSuperAdmin as defense-in-depth.

2. HIGH — Receipt-image exfiltration via OCR settings. The route
   /api/v1/admin/ocr-settings (and the sibling /test) were wrapped only
   in withAuth — any port role including viewer could PUT a swapped
   provider apiKey + flip aiEnabled, redirecting every subsequent
   receipt scan to attacker infrastructure. Both are now wrapped in
   withPermission('admin','manage_settings',…) matching the sibling
   admin routes (ai-budget, settings).

3. MEDIUM — Cross-tenant alert IDOR. dismissAlert / acknowledgeAlert
   issued UPDATE … WHERE id=? with no portId predicate. Any
   authenticated user with a foreign alert UUID could mutate it. Both
   service functions now require portId and add it to the WHERE; the
   route handlers pass ctx.portId.

The dev-trigger-crm-invite script passes a synthetic super-admin caller
identity since it runs out-of-band.

The two public-form tests randomize their IP prefix per run so a fresh
test process doesn't collide with leftover redis sliding-window entries
from a prior run (publicForm limiter pexpires after 1h).

Two new regression test files cover the fixes (6 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:27:01 +02:00
Matt Ciaccio
61e40b5e76 chore(ops): split /api/health (liveness) from /api/ready (readiness)
Previously /api/health did deep dependency probes (postgres + redis +
minio) and 503'd on any failure. That's readiness behavior, not
liveness — a transient Redis/MinIO blip would tell the orchestrator to
restart the pod when it should only be dropped from the load balancer.

Make /api/health a thin liveness check (returns 200 unconditionally if
the process is responding) and move the deep checks to a new
/api/ready endpoint with the canonical Kubernetes-style 200/503
contract. Docker-compose healthchecks keep pointing at /api/health,
which is now more conservative (no false-positive container restarts).

Documenso/SMTP are intentionally not probed in /api/ready: each tenant
configures its own credentials and a tenant misconfiguration shouldn't
deadline the entire shared CRM.

Also tighten the gdpr-bundle-builder casts: replace the scattered
`as unknown as Record<string, unknown>` double-casts with a small
`toJsonRow<T>()` helper that does the widen narrow→wide in one place
with one cast hop instead of two.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:03:10 +02:00
Matt Ciaccio
7f9d90ad05 fix(gdpr): cap export-bundle size at 50MB before upload
Article-15 bundles are JSON+HTML only (no receipts/contracts), so even
heavy clients land at <1 MB. Anything larger almost certainly indicates
an unbounded relation we forgot to cap. Fail the worker job before
uploading rather than push a runaway blob to MinIO + email the client a
download link of mystery size.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:00:16 +02:00
Matt Ciaccio
5d29bfc153 refactor(services): centralize AuditMeta + transactional setEntityTags helper
The same `interface AuditMeta { userId; portId; ipAddress; userAgent }`
was duplicated in 26 service files. Move the canonical definition into
`@/lib/audit` next to the related types and update every service to
import it. `ServiceAuditMeta` (the alias used in invoices.ts and
expenses.ts) collapses into the same name.

Tag CRUD across clients/companies/yachts/interests/berths followed an
identical wipe-then-rewrite recipe with two latent issues: the delete
and insert weren't wrapped in a transaction (a partial failure left
the entity with zero tags) and the audit-log payload shape diverged
(`newValue: { tagIds }` for clients/yachts/companies but
`metadata: { type: 'tags_updated', tagIds }` for interests/berths).

Extract `setEntityTags` in `entity-tags.helper.ts` that performs the
delete+insert inside a single transaction, normalizes the audit payload
to `newValue: { tagIds }`, and dispatches the per-entity socket event
through a switch so `ServerToClientEvents` typing stays intact.

The five `setXTags(...)` service functions now do parent-row tenant
verification and delegate the join-table work + side effects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:58:42 +02:00
Matt Ciaccio
43f68ca093 chore(hardening): maintenance jobs, defense-in-depth, redis-backed public rate limit
- maintenance worker now expires GDPR export bundles (db row + MinIO object)
  on the gdpr_exports.expires_at boundary, plus 90-day retention sweep on
  ai_usage_ledger; both jobs scheduled daily.
- portId scoping added to listClientRelationships and listClientExports
  (defense-in-depth — parent-resource gates already prevent cross-tenant
  reads, but service layer should enforce on its own).
- SELECT FOR UPDATE on parent client/company row inside add/update address
  transactions to serialize concurrent isPrimary toggles.
- public /interests + /residential-inquiries endpoints swap their
  in-memory ipHits maps for the redis sliding-window limiter via the
  new rateLimiters.publicForm config (5/hr/IP), so the cap survives
  restarts and is shared across worker processes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:52:41 +02:00
Matt Ciaccio
d9557edfc5 docs(spec): GWS inbox-triage exploratory design (not approved for build)
Surveys what it actually takes to ship the AI inbox-triage feature
gated on Google Workspace integration. Walks through three deployment
models with their real costs:

- Model A (Marketplace OAuth app): 4-6 months calendar, $15k-$75k for
  the required CASA security assessment, recurring re-verification
- Model B (per-customer Internal OAuth app): ~5 weeks engineering, $0
  Google-side, scoped to one workspace per customer
- Model C (forward-to-CRM mailbox): ~1 week, receive-only, no reply
  drafts possible

Recommends Model B for the current customer profile, with B → A
promotion only if 3+ customers ask unprompted.

Documents what's already scaffolded (email_accounts/threads/messages
tables, syncInbox stub, BullMQ email queue, ai_usage_ledger, per-port
aiEnabled flag, withRateLimit('ai')) vs what's new (OAuth flow, Pub/
Sub push receiver, gws_user_tokens + email_triage tables, /inbox UI).

End-to-end flow, schema additions, AI cost interaction with the
Phase 3b token budgets, 5-phase build plan (G1-G5), and 5 open
decisions to resolve before scheduling the build. Explicitly out of
scope: M365, sentiment analysis, smart-drafts, cross-staff triage
queue.

No code changes — this is a design doc to drive a go/no-go decision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:18:15 +02:00
Matt Ciaccio
6eb0d3dc92 docs(ops): backup/restore + email deliverability runbooks
Two new runbooks under docs/runbooks/ plus the automation scripts the
backup runbook references. Both are written so an operator who has only
the off-site backup credentials and the runbook can recover the system
unaided.

Backup/restore (Phase 4a):
- docs/runbooks/backup-and-restore.md — covers what gets backed up
  (Postgres / MinIO / .env+ENCRYPTION_KEY), schedule (hourly DB +
  hourly MinIO mirror, 7-day hourly + 30-day daily retention),
  cold-restore procedure with row-count verification, weekly drill
- scripts/backup/pg-backup.sh — pg_dump → gzip → optional GPG → mc
  upload, fails loud
- scripts/backup/minio-mirror.sh — incremental mc mirror, no --remove
  flag so accidental deletes on the live bucket can't cascade
- scripts/backup/restore.sh — interactive prod restore + --drill mode
  that runs against a sandbox DB and diffs row counts

Email deliverability (Phase 4b):
- docs/runbooks/email-deliverability.md — what the CRM sends, DNS
  records (SPF/DKIM/DMARC/MX), per-port override implications,
  diagnosis flow ("didn't arrive" → 4-step checklist starting with
  EMAIL_REDIRECT_TO), provider migration plan, realapi suite as the
  end-to-end probe

Tests still 778/778 vitest, tsc/lint clean — these phases are docs +
shell scripts, no code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:10:30 +02:00
Matt Ciaccio
a3305a94f3 feat(gdpr): staff-triggered client-data export bundle (Article 15)
Adds a full GDPR Article 15 (right of access) workflow. Staff trigger
an export from the client detail; a BullMQ worker assembles every row
keyed to that client (profile, contacts, addresses, notes, tags,
yachts, company memberships, interests, reservations, invoices,
documents, last 500 audit events) into JSON + a self-contained HTML
report, ZIPs them, uploads to MinIO, and optionally emails the client
a 7-day signed download link.

- New table gdpr_exports tracks lifecycle (pending → building → ready
  → sent / failed) with a 30-day cleanup target
- Bundle builder (gdpr-bundle-builder.ts) — pure read-side, tenant-
  scoped, with HTML escaping to block injection from rogue field values
- Worker hook in export queue dispatches on job name 'gdpr-export'
- New audit actions: 'request_gdpr_export', 'send_gdpr_export'
- API: POST/GET /api/v1/clients/:id/gdpr-export (admin-gated, exports
  rate-limit, Article-15 audit on POST); GET /:exportId returns a
  fresh signed URL
- UI: <GdprExportButton> dialog on client detail header — admin-only,
  shows recent exports, supports email-to-client + override recipient,
  polls every 5s while open
- Validation: refuses email-to-client when no primary email + no
  override (rather than silently dropping the send)

Tests: 778/778 vitest (was 771) — +7 covering builder happy path,
HTML escaping, tenant isolation, empty client, request-flow validation,
and audit / queue interaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:06:31 +02:00
Matt Ciaccio
9dfa04094b feat(rate-limit): per-user limiters for OCR, AI, and exports
Adds three named rate limiters to the existing Redis sliding-window
catalog and a withRateLimit wrapper that composes inside withAuth.
Wires the OCR limiter into the receipt-scan endpoint so a runaway
client can't burn through the AI budget in a tight loop.

- ocr: 10/min/user
- ai: 60/min/user (reserved for future server-side AI surfaces)
- exports: 30/hour/user (reserved for GDPR bundle, PDF, CSV exports)

429 responses include X-RateLimit-* headers and a Retry-After hint.

Tests: 771/771 vitest (was 766) — +5 rate-limit tests covering catalog
shape, sliding window, cross-prefix isolation, cross-user isolation,
and resetAt timestamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:56:01 +02:00
Matt Ciaccio
e7d23b254c feat(ai): per-port token budgets + usage ledger for AI features
Adds a token-denominated guardrail in front of every server-side AI call
so a misconfigured port can't run up an unbounded bill. Soft caps surface
a banner; hard caps refuse new requests until the period rolls over.
Usage flows into a feature-typed ledger so future AI surfaces (summary,
embeddings, reply-draft) can drop in without schema changes.

- New table ai_usage_ledger (port, user, feature, provider, model,
  input/output/total tokens, request id) with two indexes for rollup
- New service ai-budget.service.ts: getAiBudget/setAiBudget,
  checkBudget (pre-flight gate), recordAiUsage, currentPeriodTokens,
  periodBreakdown — all token-based, period boundaries in UTC
- runOcr now returns provider usage so the route can record the actual
  spend instead of estimating
- Scan-receipt route gates on checkBudget before invoking AI; returns
  source: manual / reason: budget-exceeded when blocked, surfaces
  softCapWarning on the success path
- Admin UI: new AiBudgetCard on the OCR settings page — shows current
  spend, per-feature breakdown, soft/hard cap inputs, period selector
- Permission: admin.manage_settings on both routes

Tests: 766/766 vitest (was 756) — +10 budget tests covering enforce/
disabled/cap-exceed/estimate-exceed/soft-warn/period boundaries/
cross-port isolation/silent ledger failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:53:09 +02:00
Matt Ciaccio
2cf1bd9754 feat(ocr): Tesseract.js as default scanner, AI as opt-in per port
The mobile receipt scanner now runs Tesseract.js in-browser by default —
on-device, free, and image bytes never leave the device. AI providers
(OpenAI / Claude) become a per-port opt-in for higher accuracy on
hard-to-read receipts.

- Lazy-load Tesseract WASM in src/lib/ocr/tesseract-client.ts (5 MB
  bundle dynamic-imports on first scan, not in main chunk)
- Heuristic parser src/lib/ocr/parse-receipt-text.ts extracts vendor,
  date, amount, currency, and line items from raw OCR text
- New port-scoped aiEnabled flag on OcrConfig (defaults false). Resolved
  flag never inherits from the global row — each port admin opts in
  independently
- Scan endpoint short-circuits to manual-mode when aiEnabled=false so
  the AI provider is never invoked unless the admin has flipped the
  switch
- Scan UI runs Tesseract first, then asks the server whether AI is
  enabled — uses the AI result only when its confidence beats Tesseract;
  network failures degrade gracefully to the local parse
- Admin OCR-settings form gains the per-port aiEnabled checkbox

Tests: 756/756 vitest (was 747) — +7 parser unit tests, +2 aiEnabled
config tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:46:29 +02:00
Matt Ciaccio
46937bbcb9 feat(addresses): full CRUD UI for client + company multi-address
Client and company detail pages each gain an Addresses tab with click-to-edit
fields wired to the existing CountryCombobox/SubdivisionCombobox primitives.
Adds a primary toggle that demotes the previous primary inside one transaction
so the partial unique index never trips.

- New service helpers: list/add/update/remove ClientAddress + CompanyAddress
- New routes: /api/v1/clients/[id]/addresses[/addressId], same under companies/
- New shared component: <AddressesEditor> reused by both detail surfaces
- Integration tests cover happy path, primary demotion, and tenant scoping

Tests: 747/747 vitest (was 741, +6 address tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:38:43 +02:00
Matt Ciaccio
27cdbcc695 chore(i18n): drop legacy free-text country/nationality columns
Test-data only — no production migration needed (per earlier decision).
Schema is now ISO-only; readers convert ISO codes to localized names where
human-readable output is required (EOI documents, invoices, portal).

Migration 0016 drops:
  - clients.nationality
  - companies.incorporation_country
  - client_addresses.{state_province, country}
  - company_addresses.{state_province, country}

Code paths that previously read free-text values now read the ISO column
and pass through `getCountryName()` / `getSubdivisionName()` for rendering.
Document templates ({{client.nationality}}), portal client view, EOI/
reservation-agreement contexts, and invoice billing addresses all updated.

Public yacht-interest endpoint (/api/public/interests) drops the legacy
fields from its insert path and writes ISO codes only. The Zod validators
no longer accept the legacy fields — older website builds posting raw
'incorporationCountry' / 'country' / 'stateProvince' will get 400s.
Server-side phone normalization is unchanged.

Seed data updated to use ISO codes (GB/FR/ES/GR/SE/IT/GH/MC/PA), spread
across continents to keep test fixtures realistic.

Test assertions updated to match the new render shape (e.g.
'United States' not 'US', 'California' not 'CA').

Vitest: 741 -> 741 (unchanged count; assertions updated, no new tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:00:57 +02:00
Matt Ciaccio
31fa3d08ec chore(cleanup): Phase 1 — gap closure across audit, alerts, soft-delete, perms
Multi-area cleanup pass closing partial-implementation gaps surfaced by the
post-i18n audit. No behavior changes for happy-path users; closes real
correctness/security holes.

PR1a Public yacht-interest endpoint i18n. /api/public/interests now accepts
     phoneE164/phoneCountry, nationalityIso, address.{countryIso, subdivisionIso},
     and company.{incorporationCountryIso, incorporationSubdivisionIso}.
     Server-side parsePhone() fallback for legacy raw phone strings.

PR1b Alert rule registry trim. Two rule slots ('document.expiring_soon',
     'audit.suspicious_login') were registered but evaluators returned [].
     Both required schema/instrumentation that hadn't landed. Removed from
     the registry; comments record the dependencies needed to revive them.
     Effective rule count: 8 active.

PR1c vi.mock hoist + flake fix. Hoisted vi.mock calls to top-level in 5
     integration test files; webhook-delivery uses vi.hoisted for the
     queue-add ref. Vitest no longer warns about non-top-level mocks.
     Deflaked the 'short value' assertion in security-encryption.test.ts
     by switching plaintext from 'ab' to 'XY' (non-hex chars). 5/5 runs green.

PR1d Soft-delete reference audit. listClientOptions and listYachtsForOwner
     now filter by isNull(archivedAt). Berths use status (no archivedAt).

PR1e Permission-matrix audit script + report. scripts/audit-permissions.ts
     walks every src/app/api/v1/**/route.ts and reports handlers without a
     withPermission() wrapper. Initial run found 33 violations.
     - Allow-listed 17 with explicit reasons (self-data, admin, alerts,
       search, currency, ai, custom-fields — some marked TODO).
     - Wrapped 7 routes with concrete permissions: clients/options
       (clients:view), berths/options (berths:view), dashboard/*
       (reports:view_dashboard), analytics (reports:view_analytics).
     Audit report at docs/runbooks/permission-audit.md. Script exits
     non-zero on any unallow-listed violation so it can become a CI gate.

Vitest: 741 -> 741 (no new tests; existing suite covers the changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:48:22 +02:00
Matt Ciaccio
16d98d630e feat(i18n): country/phone/timezone/subdivision primitives + form wiring
Cross-cutting i18n polish for forms across the marina + residential + company
domains. Introduces a single source of truth for country/phone/timezone/
subdivision data and replaces every nationality-as-free-text and timezone-
as-string Input with a dedicated combobox.

PR1  Countries — ALL_COUNTRY_CODES (~250 ISO-3166-1 alpha-2), Intl.DisplayNames
     for localized labels, detectDefaultCountry() with navigator-region
     fallback to US, CountryCombobox with regional-indicator flag glyphs +
     compact mode for inline use.
PR2  Phone — libphonenumber-js wrapper (parsePhone / formatAsYouType /
     callingCodeFor), PhoneInput with flag dropdown + national-format
     AsYouType + paste-detect that flips the country dropdown for pasted
     international strings.
PR3  Timezones — country->IANA map (250 entries, multi-zone for AU/BR/CA/CD/
     ID/KZ/MN/MX/RU/US), formatTimezoneLabel ("Europe/London (UTC+1)"),
     TimezoneCombobox with Suggested/All grouping driven by countryHint.
PR4  Subdivisions — wraps the iso-3166-2 npm package (~5000 ISO 3166-2
     codes for every country), per-country cache, SubdivisionCombobox with
     "Pick a country first" / "No regions available" empty states.
PR5  Schema deltas (migration 0015) — clients.nationality_iso, clientContacts
     {value_e164, value_country}, clientAddresses {country_iso, subdivision_iso},
     residentialClients {phone_e164, phone_country, nationality_iso, timezone,
     place_of_residence_country_iso, subdivision_iso}, companies {incorporation_
     country_iso, incorporation_subdivision_iso}, companyAddresses {country_iso,
     subdivision_iso}. Plus shared zod validators (validators/i18n.ts) used
     by every entity validator + route handler.
PR6  ClientForm + ClientDetail — CountryCombobox replaces nationality Input,
     TimezoneCombobox replaces timezone Input (driven by nationalityIso hint),
     PhoneInput conditionally rendered for phone/whatsapp contacts. Inline
     editors (InlineCountryField / InlineTimezoneField / InlinePhoneField)
     for the detail-page overview rows + ContactsEditor.
PR7  Residential client form + detail — phone -> PhoneInput, nationality/
     timezone/place-of-residence-country/subdivision rows in both create
     sheet and inline-editable detail view. Subdivision wipes when country
     flips since codes are country-scoped.
PR8  Company form + detail — incorporation country -> CountryCombobox,
     incorporation region -> SubdivisionCombobox in both modes.
PR9  Public inquiry endpoint — accepts pre-normalized phoneE164/phoneCountry
     and i18n fields from newer website builds, server-side parsePhone()
     fallback for legacy raw-international submissions. Old Nuxt builds
     keep working unchanged.

Tests: 4 unit suites for the primitives (25 tests), 1 integration spec for
the public phone-normalization path (3 tests), 1 smoke spec asserting the
combobox triggers render in all three create sheets.

Test totals: vitest 713 -> 741 (+28).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:13:08 +02:00
Matt Ciaccio
f52d21df83 feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.

PR4  Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
     date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5  Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
     right-rail, three-tab page (active/dismissed/resolved), socket-driven
     invalidation. Bell lazy-loads list on popover open to keep cold pages
     fast in non-dashboard routes.
PR6  EOI queue tab on documents hub — filters to in-flight EOIs, count
     surfaces in tab label.
PR7  Interests-by-berth tab on berth detail — replaces the stub.
PR8  Expense duplicate detection — BullMQ job runs scan on create, yellow
     banner on detail w/ Merge / Not-a-duplicate, transactional merge
     consolidates receipts and archives the source.
PR9  Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
     its own (scanner) group with no dashboard chrome, dynamic per-port
     manifest, OpenAI + Claude provider abstraction, admin OCR settings
     page (port-level + super-admin global default w/ opt-in fallback),
     test-connection endpoint, manual-entry fallback when no key is
     configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
     existing GIN index, cursor pagination, filters for entity/action/user
     /date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
     real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
     socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
     cleanly without their gate envs so CI stays green.

Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
Matt Ciaccio
2fa70f4582 merge: PR3 — analytics snapshot service + refresh job (Phase B)
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m1s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:54:48 +02:00
Matt Ciaccio
01b201e1a2 feat(analytics): real computations + 15-min snapshot refresh job
PR3 of Phase B. Replaces the no-op stubs in analytics.service.ts with
working drizzle queries and adds the recurring BullMQ job that warms
the cache.

Computations:
- computePipelineFunnel: groups interests by pipeline_stage filtered by
  port + range + not archived; emits 8-row stages array with conversion
  pct relative to 'open' as the funnel top.
- computeOccupancyTimeline: per day in range, counts berths covered by
  an active reservation (start_date ≤ day, end_date IS NULL OR ≥ day);
  emits {date, occupied, total, occupancyPct}.
- computeRevenueBreakdown: sums invoices.total grouped by status +
  currency; filters out archived rows.
- computeLeadSourceAttribution: counts interests by source descending;
  null source bucketed as 'unspecified'.

Public API (getPipelineFunnel, getOccupancyTimeline, etc.) reads
analytics_snapshots first; falls back to compute + writeSnapshot. TTL
15 minutes (matches the cron interval).

Cron:
- queue/scheduler.ts registers 'analytics-refresh' on maintenance with
  pattern '*/15 * * * *'.
- queue/workers/maintenance.ts dispatches to refreshSnapshotsForPort
  for every port; per-port try/catch so one bad port doesn't kill the
  sweep.

Tests: tests/integration/analytics-service.test.ts (9 cases). Pipeline
funnel math (incl. zero state), occupancy timeline shape/percentages
with seeded reservations, revenue grouped by status + currency, lead
source attribution incl. null bucketing, cache hit (mutate snapshot
directly → next read returns mutated value), refreshSnapshotsForPort
warms every metric×range combo.

Vitest 690/690 (+9). tsc + lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:54:46 +02:00
Matt Ciaccio
94f049c8b8 merge: PR2 — alert rules engine + cron + socket (Phase B)
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m3s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:50:57 +02:00
Matt Ciaccio
df495133b7 feat(alerts): rule engine, recurring evaluator, socket fanout
PR2 of Phase B. Wires the alert framework end-to-end:

- alert-rules.ts: 10 rule evaluators implemented as pure async fns over
  the existing schema. reservation.no_agreement, interest.stale,
  document.signer_overdue, berth.under_offer_stalled, expense.duplicate,
  expense.unscanned, interest.high_value_silent, eoi.unsigned_long,
  audit.suspicious_login fire against real conditions.
  document.expiring_soon stays inert until the documents schema gets an
  expires_at column. audit.suspicious_login also stays inert until the
  auth layer logs 'login.failed' rows (TODO noted in the rule body).

- alert-engine.ts: runAlertEngine() walks every port × every rule and
  calls reconcileAlertsForPort. Errors per (port, rule) are collected
  in the summary, not thrown — one bad evaluator can't stop the sweep.

- alerts.service.ts: reconcileAlertsForPort now emits 'alert:created'
  socket events on insert and 'alert:resolved' on auto-resolve;
  dismissAlert emits 'alert:dismissed'. All scoped to port:{portId}
  rooms.

- socket/events.ts: adds the three Server→Client alert event types.

- queue/scheduler.ts: registers 'alerts-evaluate' on the maintenance
  queue with cron */5 * * * * (every 5 min, per spec risk register).

- queue/workers/maintenance.ts: dispatches 'alerts-evaluate' to
  runAlertEngine; logs sweep summary.

Tests:
- tests/integration/alerts-engine.test.ts (6 cases): seeds reservation
  → fires, runs twice → no dupe, adds agreement → auto-resolves; seeds
  stale interest → fires; hot lead silent → critical; engine summary
  shape on no-data port. Socket emit module is vi.mocked.

Vitest 681/681 (was 675; +6). tsc clean. Lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:50:55 +02:00
Matt Ciaccio
639025ebf9 merge: PR1 — Phase B schema + service skeletons (Phase B)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:43:03 +02:00
Matt Ciaccio
e77d55ac50 feat(insights): Phase B schema + service skeletons
PR1 of Phase B per docs/superpowers/specs/2026-04-28-phase-b-insights-alerts-design.md.
Lays the foundation that PRs 2-10 will fill in with behaviour.

Schema (migration 0014):
- alerts table with rule-engine fields (rule_id, severity, link,
  entity_type/id, fingerprint, fired/dismissed/acknowledged/resolved
  timestamps, jsonb metadata). Partial-unique fingerprint index keeps
  one open row per (port, rule, entity); separate indexes power
  severity-filtered and time-ordered queries.
- analytics_snapshots (port_id, metric_id) -> jsonb cache + computedAt
  for the 15-min recurring refresh.
- expenses: duplicate_of self-FK, dedup_scanned_at, ocr_status/raw/
  confidence; partial index on (port, vendor, amount, date) where
  duplicate_of IS NULL drives the dedup heuristic.
- audit_logs.search_text: GENERATED ALWAYS tsvector over
  action+entity_type+entity_id+user_id, GIN-indexed (drizzle can't
  model GENERATED ALWAYS in TS yet, so the migration appends manual
  ALTER + the GIN index).

Service skeletons in src/lib/services/:
- alerts.service.ts: fingerprintFor, reconcileAlertsForPort (upsert +
  auto-resolve), dismiss, acknowledge, listAlertsForPort.
- alert-rules.ts: RULE_REGISTRY of 10 rule evaluators (currently no-op);
  PR2 fills in the bodies.
- analytics.service.ts: readSnapshot/writeSnapshot with 15-min TTL +
  no-op compute* stubs for the four chart series; PR3 fills behavior.
- expense-dedup.service.ts: scanForDuplicates + markBestDuplicate
  using the partial dedup index. PR8 wires the BullMQ trigger.
- expense-ocr.service.ts: OcrResult/OcrLineItem types + ocrReceipt
  stub. PR9 wires Claude Vision (Haiku 4.5 + ephemeral system-prompt
  cache).
- audit-search.service.ts: tsvector @@ plainto_tsquery + cursor
  pagination on (createdAt, id). PR10 wires the admin UI.

tsc clean, lint clean, vitest 675/675 (one unrelated AES random-output
flake passes solo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:43:01 +02:00
948 changed files with 310974 additions and 8016 deletions

View File

@@ -1 +0,0 @@
{"sessionId":"fd05cbd7-d695-4a70-9223-4b25f3369829","pid":88534,"acquiredAt":1776866083076}

28
.gitignore vendored
View File

@@ -20,10 +20,36 @@ tsconfig.tsbuildinfo
docker-compose.override.yml docker-compose.override.yml
.remember/ .remember/
.DS_Store .DS_Store
eoi/ # Root-only ad-hoc EOI scratch dir; routes under src/app/.../eoi/ must NOT match.
/eoi/
# Brainstorming companion mockup files # Brainstorming companion mockup files
.superpowers/ .superpowers/
# Ad-hoc screenshots / scratch artifacts at repo root # Ad-hoc screenshots / scratch artifacts at repo root
/*.png /*.png
/*.jpg
# Legacy Nuxt portal — kept on disk for reference, not tracked here
/client-portal/
# Sister marketing site — separate Nuxt project, not part of CRM tracking
/website/
# Mobile audit screenshots — generated locally, regenerable
/.audit/
/.audit-screenshots/
# Migration script output (CSV reports, transcripts)
.migration/
# Tool caches / runtime state
/.claude/
/.serena/
/ruvector.db
# Filesystem storage backend root (FilesystemBackend default location)
/storage/
# Local berth-PDF + brochure samples used as upload fixtures during dev.
/berth_pdf_example/

View File

@@ -95,6 +95,16 @@ src/
- **Inline editing pattern:** detail pages (clients, yachts, companies, interests, residential clients/interests) use `<InlineEditableField>` (`src/components/shared/inline-editable-field.tsx`) for click-to-edit text/select/textarea fields and `<InlineTagEditor>` (`src/components/shared/inline-tag-editor.tsx`) for tag chips. Each entity exposes a `PUT /api/v1/<entity>/[id]/tags` endpoint backed by a `set<Entity>Tags` service helper that wipes-and-rewrites the join table inside a single transaction. There are no separate "Edit" modal forms on detail pages — the entire overview tab is editable in place. - **Inline editing pattern:** detail pages (clients, yachts, companies, interests, residential clients/interests) use `<InlineEditableField>` (`src/components/shared/inline-editable-field.tsx`) for click-to-edit text/select/textarea fields and `<InlineTagEditor>` (`src/components/shared/inline-tag-editor.tsx`) for tag chips. Each entity exposes a `PUT /api/v1/<entity>/[id]/tags` endpoint backed by a `set<Entity>Tags` service helper that wipes-and-rewrites the join table inside a single transaction. There are no separate "Edit" modal forms on detail pages — the entire overview tab is editable in place.
- **Notes (polymorphic across entity types):** `notes.service.ts` dispatches across `clientNotes`, `interestNotes`, `yachtNotes`, `companyNotes` based on an `entityType` discriminator. `<NotesList entityType="…" />` works for all four. `companyNotes` lacks an `updatedAt` column — the service substitutes `createdAt` so callers get a uniform shape. - **Notes (polymorphic across entity types):** `notes.service.ts` dispatches across `clientNotes`, `interestNotes`, `yachtNotes`, `companyNotes` based on an `entityType` discriminator. `<NotesList entityType="…" />` works for all four. `companyNotes` lacks an `updatedAt` column — the service substitutes `createdAt` so callers get a uniform shape.
- **Route handler exports:** Next.js App Router `route.ts` files only allow specific named exports (`GET|POST|…`). Service-tested handler functions live in sibling `handlers.ts` files (e.g. `src/app/api/v1/yachts/[id]/handlers.ts`) and are imported by the colocated `route.ts` for `withAuth(withPermission(...))` wrapping. Integration tests import from `handlers.ts` directly to bypass auth/permission middleware. - **Route handler exports:** Next.js App Router `route.ts` files only allow specific named exports (`GET|POST|…`). Service-tested handler functions live in sibling `handlers.ts` files (e.g. `src/app/api/v1/yachts/[id]/handlers.ts`) and are imported by the colocated `route.ts` for `withAuth(withPermission(...))` wrapping. Integration tests import from `handlers.ts` directly to bypass auth/permission middleware.
- **Multi-berth interest model:** `interest_berths` is the source of truth for which berths an interest is linked to; `interests.berth_id` does not exist (dropped in migration 0029). Three role flags: `is_primary` (≤1 row per interest, enforced by partial unique index — surfaces as "the berth for this deal" in templates / forms / list views), `is_specific_interest` (true → berth shows as "Under Offer" on the public map; false → legal/EOI-only link), `is_in_eoi_bundle` (covered by the interest's EOI signature). Read/write through `src/lib/services/interest-berths.service.ts` helpers (`getPrimaryBerth`, `getPrimaryBerthsForInterests`, `upsertInterestBerth`, `setPrimaryBerth`, `removeInterestBerth`); never query `interest_berths` from outside that service.
- **Mooring number canonical format:** `^[A-Z]+\d+$` (e.g. `A1`, `B12`, `E18`) — no hyphen, no leading zeros. Stored, displayed, URL-encoded, and rendered in EOIs in this exact form. Phase 0 normalized the entire CRM dataset; the mooring-pattern regex gates the public `/api/public/berths/[mooringNumber]` route before any DB hit.
- **Public berths API:** `/api/public/berths` (list) and `/api/public/berths/[mooringNumber]` (single) are the public-facing data feed for the marketing website. Output shape mirrors the legacy NocoDB Berths shape verbatim (`"Mooring Number"`, `"Side Pontoon"`, etc.) — see `src/lib/services/public-berths.ts`. Cache headers: `s-maxage=300, stale-while-revalidate=60`. Status mapping: `"Sold"` (berth.status=sold) > `"Under Offer"` (status=under_offer OR has any active `interest_berths.is_specific_interest=true` link with `interests.outcome IS NULL`) > `"Available"`. The companion `/api/public/health` endpoint returns `{env, appUrl}` so the website refuses to start when its `CRM_PUBLIC_URL` points at a different deployment env.
- **Berth recommender:** Pure SQL ranking (no AI). Lives in `src/lib/services/berth-recommender.service.ts`. Tier ladder A/B/C/D classifies each feasible berth based on its `interest_berths` aggregates. Heat scoring (recency / furthest stage / interest count / EOI count) only fires for tier B (lost/cancelled-only history); per-port admin tunes weights via `system_settings` keys (`heat_weight_*`, `recommender_max_oversize_pct`, `recommender_top_n_default`, `fallthrough_policy`, `fallthrough_cooldown_days`, `tier_ladder_hide_late_stage`). The recommender enforces multi-port isolation both at the entry point (rejects cross-port interest lookups) AND inside the SQL aggregates CTE (defense-in-depth `i.port_id` filter).
- **EOI bundle / range formatter:** Multi-berth EOIs render the in-bundle berth set as a compact range string ("A1-A3, B5-B7") via `formatBerthRange()` in `src/lib/templates/berth-range.ts`. Used only inside the Documenso `Berth Range` form field — CRM UI always shows berths as individual chips. The `{{eoi.berthRange}}` token is in `VALID_MERGE_TOKENS`.
- **Pluggable storage backend:** Code never imports MinIO/S3 directly. All file I/O goes through `getStorageBackend()` from `src/lib/storage/`. Configured via `system_settings.storage_backend` ('s3' | 'filesystem'). Switching backends is a settings change + `pnpm tsx scripts/migrate-storage.ts` run. **Filesystem backend is single-node only**: refuses to start when `MULTI_NODE_DEPLOYMENT=true`. Multi-node deployments must use the s3-compatible backend.
- **Per-berth PDFs:** Versioned via `berth_pdf_versions`; `berths.current_pdf_version_id` always points to the latest active version. Storage key is UUID-based per upload (not version-numbered) so concurrent uploads can't collide on blob paths; `pg_advisory_xact_lock` per berth_id serializes the version-number allocation. 3-tier parser: AcroForm → OCR (Tesseract.js with positional heuristics) → optional AI (rep clicks "AI parse" only when OCR confidence is low). Magic-byte (`%PDF-`) check enforced on BOTH the in-server upload path AND the presigned-PUT path (the post-upload service streams the first 5 bytes via the storage backend). Mooring-number mismatch between PDF and target berth surfaces as a service-level `ConflictError` unless the apply call passes `confirmMooringMismatch: true`.
- **Brochures:** Per-port; default brochure marked via `is_default` (enforced by partial unique index on `(port_id) WHERE is_default=true AND archived_at IS NULL`). Archived brochures retain version history. Same upload flow as berth PDFs (presign + magic-byte verification on the post-upload register endpoint).
- **Send-from accounts (sales send-outs):** Configurable via `system_settings`; defaults to `sales@portnimara.com` for human-touch and `noreply@portnimara.com` for automation. SMTP/IMAP passwords are AES-256-GCM encrypted at rest; the API never returns decrypted secrets — only `*PassIsSet` boolean markers. Send-out audit goes to `document_sends` (separate from `audit_logs` because of volume + binary refs). Body markdown is XSS-safe via `renderEmailBody()` (escape-then-allowlist; tested against the standard XSS vector list). Rate limit: 50 sends/user/hour individual. Pre-send size threshold: files > `email_attach_threshold_mb` ship as a 24h signed-URL link rather than an attachment (avoids the duplicate-send race from async bounces). The download-link fallback HTML-escapes the filename to prevent injection from admin-supplied brochure names. Bounce monitoring requires IMAP credentials in addition to SMTP — without them, the size-rejection banner stays disabled.
- **NocoDB berth import:** `pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug port-nimara` re-imports from the legacy NocoDB Berths table. Idempotent: rows where `updated_at > last_imported_at` (the "human edited this since last import" guard) are skipped unless `--force`. Adds `--update-snapshot` to also rewrite `src/lib/db/seed-data/berths.json`. Uses `pg_advisory_xact_lock` so two simultaneous runs serialize. Pure helpers in `src/lib/services/berth-import.ts` are unit-tested.
- **Routes:** Multi-tenant via `[portSlug]` dynamic segment. Typed routes enabled. - **Routes:** Multi-tenant via `[portSlug]` dynamic segment. Typed routes enabled.
- **Pre-commit:** Husky + lint-staged runs ESLint fix + Prettier on staged `.ts`/`.tsx` files. The hook also blocks `.env*` files (including `.env.example`) from being committed; pass them via a separate workflow if needed. - **Pre-commit:** Husky + lint-staged runs ESLint fix + Prettier on staged `.ts`/`.tsx` files. The hook also blocks `.env*` files (including `.env.example`) from being committed; pass them via a separate workflow if needed.
@@ -139,6 +149,14 @@ Domain-specific references:
- `docs/eoi-documenso-field-mapping.md` — canonical mapping from `EoiContext` - `docs/eoi-documenso-field-mapping.md` — canonical mapping from `EoiContext`
paths to the Documenso template's `formValues` keys, with the matching paths to the Documenso template's `formValues` keys, with the matching
AcroForm field names used by the in-app pathway. AcroForm field names used by the in-app pathway. **Note:** the multi-
berth EOI bundle adds a new `Berth Range` form field populated by
`formatBerthRange()` from `src/lib/templates/berth-range.ts` — the live
Documenso template needs the field added before multi-berth EOIs render
with the compact range string instead of just the primary mooring.
- `assets/README.md` — what the in-app EOI source PDF must contain and how - `assets/README.md` — what the in-app EOI source PDF must contain and how
to override its path in dev/test. to override its path in dev/test.
- `docs/berth-recommender-and-pdf-plan.md` — the comprehensive plan for the
Phase 08 berth-recommender + PDF + send-outs work bundle. Single source
of truth for the multi-berth interest model, recommender tier ladder,
pluggable storage, per-berth PDF parser, and sales send-out flows.

View File

@@ -1,13 +1,13 @@
# Stage 1: Install dependencies # Stage 1: Install dependencies
FROM node:20-alpine AS deps FROM node:20-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false RUN pnpm install --frozen-lockfile --prod=false
# Stage 2: Build the application # Stage 2: Build the application
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .

View File

@@ -1,5 +1,5 @@
FROM node:20-alpine FROM node:20-alpine
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile

View File

@@ -1,26 +1,40 @@
# Stage 1: Install dependencies (dev deps needed for esbuild) # Stage 1: Install dependencies (dev deps needed for esbuild)
FROM node:20-alpine AS deps FROM node:20-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false RUN pnpm install --frozen-lockfile --prod=false
# Stage 2: Build the worker bundle # Stage 2: Build the worker bundle
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ENV SKIP_ENV_VALIDATION=1 ENV SKIP_ENV_VALIDATION=1
RUN pnpm build:worker RUN pnpm build:worker
# Stage 3: Production runner (prod deps only) # Stage 3: Production runner (prod deps only).
#
# Critical ordering: create the worker user FIRST and chown the workdir
# BEFORE pnpm install, so node_modules + lazy-cache directories
# (tesseract.js, sharp) are owned by the worker user. Without this, the
# previous layout had pnpm install run as root → node_modules root-owned
# → tesseract.js / sharp wrote to node_modules/.cache and EACCES'd at
# first PDF parse in prod (auditor-K §39).
FROM node:20-alpine AS runner FROM node:20-alpine AS runner
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 worker RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 worker
COPY --from=builder --chown=worker:nodejs /app/dist/worker.js ./worker.js WORKDIR /app
RUN chown -R worker:nodejs /app
USER worker USER worker
COPY --chown=worker:nodejs package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
COPY --from=builder --chown=worker:nodejs /app/dist/worker.js ./worker.js
# Healthcheck — pings Redis from inside the worker container. Without
# this, a worker whose Redis connection has silently dropped (BullMQ
# rejects new jobs but the Node process is alive) is invisible to
# compose / swarm and jobs queue indefinitely (auditor-K §40).
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "const Redis=require('ioredis');const r=new Redis(process.env.REDIS_URL,{maxRetriesPerRequest:1,connectTimeout:3000,lazyConnect:true});r.connect().then(()=>r.ping()).then(()=>{r.disconnect();process.exit(0)}).catch(()=>process.exit(1))" || exit 1
CMD ["node", "worker.js"] CMD ["node", "worker.js"]

Submodule client-portal deleted from 84f89f9409

View File

@@ -46,6 +46,10 @@ services:
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 3 retries: 3
# Give the SIGTERM handler in src/server.ts time to drain in-flight
# HTTP requests, close Socket.io, and disconnect Redis before Docker
# SIGKILLs the process. The internal hard timeout is 25s.
stop_grace_period: 30s
restart: unless-stopped restart: unless-stopped
networks: networks:
- internal - internal
@@ -58,6 +62,9 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
# Match the app: BullMQ jobs need time to finish or be released back
# to the queue when worker.ts handles SIGTERM.
stop_grace_period: 30s
restart: unless-stopped restart: unless-stopped
networks: networks:
- internal - internal

196
docs/admin-ux-backlog.md Normal file
View File

@@ -0,0 +1,196 @@
# Admin / settings UX backlog — STATUS
Living tracker for the admin/UX backlog. Items are marked DONE or
REMAINING based on what landed in the autonomous-push session.
---
## DONE in the autonomous push
### Foundations
- **Currency API verified end-to-end**. `scripts/test-currency-api.ts`
fetches live Frankfurter rates → upserts → reads back → converts.
Inverse-rate drift confirmed at ≤0.001.
- **Storage abstraction audit complete**. Every byte path
(signed EOIs, contracts, brochures, berth PDFs, files, avatars,
branding logos) goes through `getStorageBackend()`. `/api/ready`
and the system-monitoring health probe now check the active
backend (S3 or filesystem) instead of always probing MinIO.
### User settings
- Country + Timezone selectors with cross-defaulting + auto-detect
banner ("Looks like you're in Europe/Paris — Update?")
- Email change with verification flow (`user_email_changes` table,
`/api/v1/me/email/confirm/<token>`, `/api/v1/me/email/cancel/<token>`)
- Password reset triggered via better-auth `requestPasswordReset`
- Profile photo upload + crop (square 256×256) via shared
`<ImageCropperDialog>` + `/api/v1/me/avatar`
### Branding
- Logo upload + crop modal in admin/branding (uses the same shared
cropper, persists via `/api/v1/admin/settings/image` → storage backend)
- Email header/footer HTML defaults injectable via "Insert default" button
- Brand colour picker, app-name field, logo URL all in one card
### Storage admin
- New layout: S3 config form FIRST, swap action SECOND
- Test connection button before any switch
- Two-button switch: "Switch + migrate" vs "Switch only" with warning modal
- `runMigration()` honours `skipMigration` flag
### Backup management
- Real `/admin/backup` page driven by new `backup_jobs` table
- `runBackup()` service spawns `pg_dump --format=custom`, streams to
active storage backend, records size + path
- Download button presigns the .dump for offline restore
- Super-admin gated
### AI admin panel
- Dedicated `/admin/ai` page consolidating master switch +
monthly token cap + provider credentials
- Per-feature settings (OCR, berth-PDF parser, recommender)
linked from the same page
### Onboarding
- Real `/admin/onboarding` page with auto-checked steps
- Reads each setting key + lists endpoint (roles / users / tags) to
decide completion
- Manual checkboxes for steps without an auto-detect signal
- Progress bar + "Mark done"/"Mark incomplete" buttons
- State persisted in `system_settings.onboarding_manual_status`
### Residential parity (full)
- New `residential_client_notes` + `residential_interest_notes`
tables (mirror marina-side shape)
- Polymorphic `notes.service.ts` extended with two new entity types
through verifyParent + listForEntity + create + update + delete
- New `<NotesList>` accepts `residential_clients` /
`residential_interests` entity types
- Activity endpoints: `/api/v1/residential/clients/[id]/activity` +
`/api/v1/residential/interests/[id]/activity`
- Notes endpoints: 4 new routes covering GET/POST/PATCH/DELETE
- `residential-client-tabs.tsx` + `residential-interest-tabs.tsx`
built using the marina-side `DetailLayout` pattern (Overview +
Notes + Activity tabs, Interests tab on the client)
- Detail header components mirror the marina-side strip
- `useBreadcrumbHint` wired into both detail components
### Residential pipeline stages — configurable
- New `residential-stages.service.ts` with list/save + orphan-check
- `/api/v1/residential/stages` GET/PUT
- `/admin/residential-stages` admin UI with reassign-on-remove
modal (select new stage per affected interest before save)
- Validators relaxed from `z.enum(...)` to `z.string()` so any
admin-defined stage id round-trips
### Documenso Phase 1 (EOI generate flow polish)
- Schema migrations applied:
`document_signers.invited_at / opened_at / last_reminder_sent_at / signing_token`,
`documents.completion_cc_emails / auto_reminder_interval_days`
- `transformSigningUrl()` now maps SignerRole → URL segment correctly
(approver→cc, witness→witness) so emails don't land on `/sign/error`
- New `POST /api/v1/documents/[id]/send-invitation` endpoint with
next-pending-signer auto-pick
- Per-port settings added: `documenso_developer_label`,
`documenso_approver_label`, `documenso_developer_user_id`,
`documenso_approver_user_id` (Phase 7 RBAC binding fields)
### Misc UI/UX
- Sidebar collapse removed (always expanded)
- Audit log filter inputs sized + dates widened
- Custom Settings section got a long-form description
- Reminder digest timezone uses `TimezoneCombobox`
- Port form: currency dropdown + timezone combobox + brand color
- Permissions count badge opens a modal with granted/denied
- Role names display-normalized via `prettifyRoleName`
- Sales email config: token list + tooltips on threshold + body fields
- Custom Fields page: amber heads-up about non-integration with
search / recommender / audit / merge tokens
- Tag form: native `<input type="color">`
- FilterBar Select crash fixed (no empty-string item values)
---
## REMAINING — large pieces that didn't fit this push
### 1. Documenso Phase 2 — Webhook handler enhancement (~3-4 hours)
Cascading "your turn" emails when each signer completes; on-completion
PDF distribution; token-based recipient matching; idempotency lock.
File to extend: `src/app/api/webhooks/documenso/route.ts`. The
schema columns are already in place (Phase 1).
### 2. Documenso Phase 3 — Custom doc upload-to-Documenso (~6-8 hours)
Backend service `custom-document-upload.service.ts` + endpoint
`POST /api/v1/interests/[id]/upload-for-signing`. Accepts a PDF +
recipient list + field-placement JSON, calls `createDocument`
`placeFields``sendDocument` on the per-port Documenso client.
Persists a row in `documents` table.
### 3. Documenso Phase 4 — Field placement UI (~10-14 hours)
The biggest piece. Needs:
- 4a: Recipient configurator dialog (~2-3h)
- 4b: PDF rendering with `react-pdf` (~3-4h)
- 4c: Auto-detect anchor scanner via `pdfjs-dist.getTextContent` (~4-6h)
- 4d: Drag-drop overlay using `dnd-kit` (~3-4h)
- 4e: Send button → calls Phase 3 endpoint (~1h)
Plan locked in `docs/documenso-build-plan.md` Phase 4 — the
field-detector regexes, the anchor patterns, and the type-to-bbox
sizing table are all spelled out.
### 4. Documenso Phase 5 — Embedded signing URL emission verification (~1-2 hours)
Verify the website's `/sign/<type>/<token>` page handles every signer
role + every documentType combination. Update website's
`signerMessages` map keyed on `(documentType, role)`. Apply the
nginx CORS block from `docs/documenso-integration-audit.md`.
### 5. Documenso Phase 6 — Polish items (deferred)
Auto-send delay, audit-log additions, per-document customisation,
document expiration, reminder rate-limit display, failed-webhook
recovery UI. Each ~2-3 hours; all deferred until Phases 1-4 ship.
### 6. Project Director — UI binding for the developer-user fields
Schema + setting keys are now in place
(`documenso_developer_user_id`, `documenso_approver_user_id` +
`documenso_developer_label` / `_approver_label`). The remaining
work is: add a "Linked to CRM user" dropdown in
`/admin/documenso/page.tsx` that lists port users; when bound,
auto-fill name/email from the user profile and mark name/email
fields read-only. Webhook handler can then match against the
linked user's email for in-CRM signing-status updates.
### 7. Custom-fields hardening (~ongoing)
Remediation paths for the heads-up banner concerns:
- **Search index**: extend the GIN tsvector to include
customFieldValues content
- **Audit diff**: extend `diffEntity` to walk the
customFieldValues blob
- **Merge tokens**: add `{{custom.<fieldName>}}` handling at
template-render time, plus surface them in the merge-tokens UI
### 8. Documenso v2 webhook payload audit (small)
Risk #4 from `docs/documenso-build-plan.md` — confirm v2 payload
shape (`payload.documentId` vs `payload.id`, recipient.token vs
`recipient.recipientId`) against a live v2 instance before relying
on Phase 2 cascading emails.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,753 @@
# Comprehensive Audit — 2026-05-06
Conducted directly after the smart-archive / hard-delete / bulk-wizard /
audit-overhaul / synthetic-seed batches landed (commits `d07f1ed`
through `9890d06`). Prior comprehensive audit:
`docs/audit-comprehensive-2026-05-05.md`.
Findings are sorted by severity. Each has a concrete file:line, a
scenario, and a fix recommendation.
---
## CRITICAL
### C1. 5 of 10 BullMQ workers are never imported (production + dev)
**Files:** `src/worker.ts:13-17`, `src/server.ts:72-76`
`src/worker.ts` (production) and `src/server.ts` (dev fallback) both
import only:
- `emailWorker`
- `documentsWorker`
- `notificationsWorker`
- `importWorker`
- `exportWorker`
**Missing:** `aiWorker`, `bulkWorker`, `maintenanceWorker`, `reportsWorker`, `webhooksWorker`.
Because BullMQ workers are constructed at the top of each worker
module and only "start" when the module is imported, never importing
them means:
- **Webhooks never deliver.** `webhooksWorker` is what processes the
`webhooks` queue; the admin "Replay" button we just shipped enqueues
jobs that pile up in `pending` forever.
- **All maintenance crons silently no-op.** `maintenanceWorker` handles
`database-backup`, `backup-cleanup`, `session-cleanup`,
`currency-refresh`, `gdpr-export-cleanup`, `ai-usage-retention`,
`error-events-retention`, `website-submissions-retention`,
`alerts-evaluate`, `analytics-refresh`, `calendar-sync`,
`temp-file-cleanup`, `form-expiry-check` — none run.
- **Scheduled reports never generate.** `reportsWorker` handles
`report-scheduler` (every minute).
- **Bulk jobs never process** (the synchronous bulk endpoints work, but
any deferred-bulk path is dead).
- **AI usage features never run.**
**Impact:** Production CRM has been silently shedding webhook
deliveries, never running retention/cleanup, never sending scheduled
reports.
**Fix:**
```ts
// Append to src/worker.ts AND the inline section of src/server.ts:
import { aiWorker } from '@/lib/queue/workers/ai';
import { bulkWorker } from '@/lib/queue/workers/bulk';
import { maintenanceWorker } from '@/lib/queue/workers/maintenance';
import { reportsWorker } from '@/lib/queue/workers/reports';
import { webhooksWorker } from '@/lib/queue/workers/webhooks';
const workers = [
emailWorker,
documentsWorker,
notificationsWorker,
importWorker,
exportWorker,
aiWorker,
bulkWorker,
maintenanceWorker,
reportsWorker,
webhooksWorker,
];
```
After fix, run `pnpm dev` and watch `/admin/webhooks/{id}` deliveries
go from `pending``success` to confirm.
---
## HIGH
### H1. Hard-delete request endpoints have zero rate limiting
**Files:**
- `src/app/api/v1/clients/[id]/hard-delete-request/route.ts:1-37`
- `src/app/api/v1/clients/bulk-hard-delete-request/route.ts:1-32`
Each call writes a fresh code to Redis and emails it to the operator's
address. No `withRateLimit(...)`. An attacker who has compromised an
admin account (or even just the new `permanently_delete_clients`
permission) can:
1. Email-bomb the admin's own inbox (every request → email).
2. Probe whether arbitrary client IDs exist (200 + `sentToMaskedEmail`
vs 404 `client not found` is a UID oracle).
3. Burn SMTP quota.
**Fix:** add `withRateLimit('auth', ...)` or a new dedicated bucket
(e.g. 5 per hour per user). Pattern is already in
`src/app/api/v1/clients/[id]/gdpr-export/route.ts`.
### H2. Audit-page view fires on every paginated reload (log spam)
**File:** `src/app/api/v1/admin/audit/route.ts:48-72`
I added a "watch the watchers" `view` audit row for first-page audit
fetches. That's the right idea, but the page also re-fires the request
on every filter change (severity, source, action, date range, search).
A diligent admin filtering through the inspector for an investigation
will write dozens of `view` audit rows per minute — making it harder to
find the actual events they're looking for.
**Fix:** dedupe in Redis with a 60-second per-user TTL key, only emit
if the key didn't exist. Or only fire when no filters are active.
### H3. Hard-delete error messages distinguish "no code" vs "wrong code"
**File:** `src/lib/services/client-hard-delete.service.ts:166-174`
```ts
if (!stored) throw new ValidationError('Confirmation code expired or not requested');
if (!safeEqualStr(stored, args.code.trim())) {
throw new ValidationError('Confirmation code is incorrect');
}
```
The two messages let an attacker distinguish "you've never requested a
code" (so spam the request endpoint to open the window) from "wrong
code" (so brute-force more codes). 4-digit space is only 10,000 — with
distinguishable feedback an attacker can confirm code validity in
≤5,000 attempts on average.
**Fix:** collapse to a single `'Invalid or expired code'` message; the
operator already has the email open and knows what they typed.
### H4. Synthetic seed leaves `super_admin` linked-port-roles empty
**File:** `src/lib/db/seed-bootstrap.ts:147-160`
The bootstrap creates the `userProfiles` row with
`isSuperAdmin: true` for `super-admin-matt-portnimara`, but doesn't
create `userPortRoles` rows. The actual real `user` rows (admin@,
agent@, viewer@) are only created via the Playwright global-setup.
Anyone running `pnpm db:seed:synthetic` then `pnpm dev` and trying to
log in via the UI hits an unauthenticated state until they also run
playwright setup or sign up via better-auth manually.
**Fix:** either document this in `CLAUDE.md` Quick Reference, or add a
`pnpm db:seed:dev-users` companion script that signs up the three
test users + links roles. Today's synthetic-seed flow felt clean
because the playwright setup was still applied; in a fresh clone it
will surprise.
### H5. Documenso bad-secret 200 response is correct, but enables enum oracle
**File:** `src/app/api/webhooks/documenso/route.ts:67-86`
The route returns `200 ok=false error=Invalid secret` for a wrong
secret. That's webhook best-practice (don't leak signal to attackers),
but combined with the new audit row that captures
`metadata.providedLen`, an attacker can probe secret-length over time
without being detected (just a "warning" row per attempt). On an admin
inspector with 1000s of rows, a slow-rate probe is invisible.
**Fix:** add per-IP rate limit (5/min) to `/api/webhooks/documenso/`
when secret check fails. Don't block real Documenso traffic — it
shouldn't fail the secret check.
### H6. The audit-log inspector page itself isn't backed by a real "view" gate beyond `admin.view_audit_log`
**File:** `src/app/api/v1/admin/audit/route.ts:31`
Audit log has the most sensitive cross-cutting data in the system
(every login attempt with attempted email, every secret-regenerate,
every hard-delete). It's gated only by `admin.view_audit_log`. The
seed grants this to `director` AND `super_admin`. Consider:
- making the page super-admin-only for production, OR
- adding a secondary confirmation when viewing rows that contain
attempted emails / IP ranges (PII).
**Fix:** change `withPermission('admin', 'view_audit_log', ...)` to
add `if (!ctx.isSuperAdmin) check sensitive_audit_view`. Or accept
the current model but document it in the role docs.
### H7. Three "coming soon" stubs in production UI
**Files:**
- `src/components/clients/client-tabs.tsx:276` — "File attachments coming soon."
- `src/components/clients/client-reservations-tab.tsx:41` — "History is coming soon."
- `src/components/berths/berth-tabs.tsx:327` — "{label} coming soon"
Visible to every user on every client / berth detail page. Either ship
the feature or hide the tab.
**Fix:** for `client-tabs.tsx` line 276 (Files), the `files` table
already exists and supports clientId — ship a list view.
For `berth-tabs.tsx` line 327 — find the calling tab labels and
either implement or remove from the tabs array.
For `client-reservations-tab.tsx` line 41 — query past reservations
when the user toggles a "show history" filter.
---
## MEDIUM
### M1. `attachWorkerAudit` recurring job names list duplicates scheduler.ts (drift risk)
**File:** `src/lib/queue/audit-helpers.ts:23-46`
The 20 recurring job names are hardcoded in the audit helper; the
scheduler also has its own list. If someone adds a new cron without
updating both, the cron_run audit row never fires for that job.
**Fix:** export the list from `scheduler.ts` and import it in
`audit-helpers.ts`. Single source of truth.
### M2. `client-merge-log.surviving_client_id` deleted by hard-delete (history loss)
**File:** `src/lib/services/client-hard-delete.service.ts:200-202`
Hard-delete drops every `client_merge_log` row whose surviving id
matches. Those rows are the audit trail of WHO was merged INTO this
client. Once deleted, you've lost evidence of the prior merge.
**Fix:** replace `delete` with a column nullification, or move the row
to a `client_merge_log_archive` table. Audit trail per GDPR Article 5
should outlive the data.
### M3. Bulk hard-delete loops one-shot codes through Redis (5x writes)
**File:** `src/lib/services/client-hard-delete.service.ts:382-396`
For a 100-client bulk delete, the function writes 100 single-client
codes to Redis just to satisfy `hardDeleteClient`'s expectation. Each
write is a round-trip; on a Redis hiccup mid-loop, you can end up
with a half-deleted batch.
**Fix:** refactor `hardDeleteClient` so the inner deletion can be called
without the per-client code check (extract `_doHardDelete()` private
helper used by both single and bulk paths). Keeps Redis clean.
### M4. Smart-restore wizard has dead reversal applier for `berth_released`
**File:** `src/lib/services/client-restore.service.ts:360-372`
The `applyReversal` switch case for `'berth_released'` does nothing —
it just leaves the berth available. The wizard surfaces this as
"auto-reversible" if the berth is still free, but the actual restore
doesn't re-attach the berth to any interest. Operator clicks Restore
expecting their berth back; nothing changes on the berth.
**Fix:** either (a) at archive time, persist the original interestId
in the decision metadata so we can re-link, or (b) update the wizard
copy to make clear the berth is "available for re-attach" rather than
"will be re-attached."
### M5. Several services use `void createAuditLog(...)` without `.catch()`
**Files:** widespread; e.g. `src/lib/services/client-hard-delete.service.ts:127-136, 230-240`,
`src/lib/services/portal-auth.service.ts:269-276`
`createAuditLog` is documented as never-throwing (catches internally),
but defense-in-depth: a `void` Promise that throws produces an
unhandled rejection event. Most paths are fine because the helper
catches; if anyone refactors `createAuditLog` and removes the catch,
this becomes a process-killer.
**Fix:** convention rule: every `void someAsync()` must have a `.catch()`.
Codify with a custom ESLint rule, or wrap at call sites:
`void createAuditLog({...}).catch(() => undefined);`
### M6. Hard-delete audit metadata leaks client `fullName`
**File:** `src/lib/services/client-hard-delete.service.ts:241-247`
After the hard-delete the audit row carries
`metadata: { fullName: client.fullName }`. The client record itself is
gone but their name lives on in the audit log. For a GDPR data subject
who exercised their right-to-erasure, this is technically a retention
of personal data in audit history. Not necessarily wrong (audit logs
have a legitimate-interest basis), but should be conscious.
**Fix:** decide policy: either (a) keep as-is and document, (b) replace
with a hash of the name, or (c) substitute a tombstone identifier.
### M7. Webhook delivery DLQ admin-replay can re-trigger downstream side-effects
**File:** `src/lib/services/webhooks.service.ts:282-326`
Replaying a successful webhook (operator presses Replay on a delivery
that already had `status: 'success'`) re-fires the same payload to the
recipient. If the recipient's idempotency check is weak, you've just
caused a duplicate. The replay payload includes `retried_from` /
`retried_at` markers, which is good — but most recipients won't honor
them.
**Fix:** disable the Replay button when `status === 'success'`. The UI
already gates on `'failed' || 'dead_letter'` — verify it stays that
way (`webhook-delivery-log.tsx:118-131` looks correct; double-check
no regressions).
### M8. `audit_logs` table has no DELETE permission gate
**Files:** schema and routes
There's no admin endpoint to delete audit rows (good). But there's no
DB-level guard either. A super_admin who runs `db:reset` wipes audit
history. Audit retention should be enforced at the schema level so
even a misconfigured operator can't blow away the trail.
**Fix:** create a `audit_logs_no_delete_role` postgres role that lacks
DELETE on the table; document that the app's DB user should not have
DELETE on `audit_logs` in production deployments.
### M9. Documenso void worker uses dynamic import every time
**File:** `src/lib/queue/workers/documents.ts:25`
```ts
const { voidDocument } = await import('@/lib/services/documenso-client');
```
Dynamic import inside a hot per-job path is fine the first time but
slows every subsequent call slightly. Move to top-of-file import
unless there's a deliberate reason (circular dep?).
**Fix:** test moving to top-level import; if it works (no circular
deps), keep it there.
### M10. Bulk archive wizard "blocked" reason copy truncates at first line
**File:** `src/components/clients/bulk-archive-wizard.tsx:153-163`
The wizard shows `b.blockers[0]` for blocked clients. If the dossier
has multiple blockers, only the first is shown. Operators may fix the
first one, retry, and discover a second.
**Fix:** show all blockers (joined with `·`) or a "+N more" badge
with click-to-expand.
---
## LOW
### L1. `next-in-line-notify.service.ts` could double-fire on archive retry
**File:** `src/app/api/v1/clients/[id]/archive/route.ts:114-135`
If the smart-archive request succeeds at the DB transaction level but
the response upload-side fails (network blip, browser closes), the
operator may retry. Each retry re-fires the next-in-line notification
to all sales recipients. The `dedupeKey: berth-released:{berthId}`
inside the notification helper deduplicates within a cooldown window —
so this is mitigated, but worth verifying the cooldown is set and
not 0.
### L2. `interests.berth_id` reference in `seed-data.ts` (legacy seed)
**File:** `src/lib/db/seed-data.ts:973`
The realistic seed inserts `berthId: ...` on the interests table. Per
`CLAUDE.md`, that column was dropped in migration 0029 and replaced
with `interest_berths` junction. The synthetic seed uses the junction
correctly. The realistic seed will FAIL at insert time if anyone
tries to run it on a freshly-migrated DB.
**Fix:** rewrite `seed-data.ts:969-982` to insert into `interests`
without `berthId`, then insert the junction rows separately (mirror
the synthetic seed's pattern).
### L3. Audit log entry for failed login uses `entityId = attemptedEmail` (unbounded)
**File:** `src/app/api/auth/[...all]/route.ts:53-68`
If the entityId is very long (a 500-char "email"), it goes into the
DB column. The column is `text` (unbounded) so no DB error, but FTS
search-text may bloat.
**Fix:** truncate attempted email to 256 chars before using as
entityId.
### L4. The "watch the watchers" audit fires for filtered queries too
**File:** `src/app/api/v1/admin/audit/route.ts:48-72`
(See H2 above for the page-spam variant.) Even on a single search,
an audit row containing the search term is written. If the search
term itself is sensitive (e.g. an admin searches for a specific
client's name in audit logs), it's now in the audit log of audit-log
viewing. Acceptable but worth documenting.
### L5. Import worker is a stub
**File:** `src/lib/queue/workers/import.ts:13`
`// TODO(L2): implement import job handlers` — the worker is wired
into the queue and registered, but does nothing. If anyone enqueues
an `import:*` job, it returns immediately. Either ship the feature
or remove the queue.
### L6. `interest-form.tsx` two TODOs about company-yacht filter + add-yacht inline
**File:** `src/components/interests/interest-form.tsx:332-333`
Real product gaps. When creating an interest for a client who's a
member of a company, you can't pick a yacht owned by that company.
And there's no inline "Add yacht" shortcut in the form.
### L7. `berth-spec-template.ts` defaults to `'Price: TBD'` when price is null
**File:** `src/lib/pdf/templates/berth-spec-template.ts:128`
Generated berth-spec PDFs say "Price: TBD" for any berth without a
price. Cosmetic — verify whether sales considers this an acceptable
fallback or wants to suppress the line entirely.
---
## Things checked and found OK (so we don't re-audit)
- Tenant isolation on hard-delete (`portId` filter on every query and
inside the tx).
- `withPermission` gates on every new route (bulk-archive-preflight,
hard-delete-_, bulk-hard-delete-_, redeliver).
- Audit log: no public DELETE endpoint, no PATCH endpoint.
- Sidebar nav properly gates marina sections from `residential_partner`
via `hasMarinaAccess`.
- Auth wrapper rebuilds the request body correctly so the upstream
better-auth handler can re-read it (no body-already-consumed bug).
- Webhook outbound SSRF guard with DNS rebinding protection still
intact.
- 1175/1175 vitest suite passing as of last run.
---
## Recommended fix order (ROUND 1 + 2 combined — see below for Round 2)
See **"Triage list" at the end** of this document — combined ranking
across both audit rounds.
---
## Round 2 — focused agents (added 2026-05-06 evening)
After the original synthesis above, four scoped agents (smaller blast
radius, hard finding caps) successfully audited their domains and
produced dedicated docs. Findings are linked here with `R2-`-prefixed
IDs. Detail in:
- [audit-reliability-2026-05-06.md](audit-reliability-2026-05-06.md) — 11 findings
- [audit-frontend-2026-05-06.md](audit-frontend-2026-05-06.md) — 12 findings
- [audit-permissions-2026-05-06.md](audit-permissions-2026-05-06.md) — 9 findings
- [audit-missing-features-2026-05-06.md](audit-missing-features-2026-05-06.md) — 12 findings
### Round 2 — CRITICAL
**R2-C1. Bulk archive discards post-commit side effects** ([reliability C1](audit-reliability-2026-05-06.md))
- File: `src/app/api/v1/clients/bulk/route.ts:68-134`
- The bulk wizard's `runBulk` callback discards the return value from
`archiveClientWithDecisions`. **Documenso envelopes marked
`void_documenso` are never queued for void; "next-in-line" sales
notifications never fire**. The CRM ends up showing `documents.status='cancelled'`
while the live envelope is still out for signature — a signer can
legally complete a doc the CRM thinks is voided.
- Same severity tier as the original C1 (worker-imports).
**R2-C2. Frontend: Restore icon hovers destructive-red on archived clients** ([frontend C1](audit-frontend-2026-05-06.md))
- File: `src/components/clients/client-detail-header.tsx:174-186`
- Conditional `hover:text-destructive` is overridden by an unconditional
`hover:text-foreground` earlier in the class string. Result: the
Restore button on archived clients hovers blood-red, signalling
"destructive" on a fully reversible action. Users hesitate to click.
Promoted to "critical UX" because it's directly misleading on every
archived client view.
### Round 2 — HIGH
**R2-H1. Smart-restore wizard's `berth_released` reversal is a no-op but the audit log claims success**
([reliability H1](audit-reliability-2026-05-06.md))
- File: `src/lib/services/client-restore.service.ts:359-372`
- Already noted as M4 in the original synthesis. Round-2 reliability
agent escalated to HIGH because the wizard counter increments and
the audit log records "1 auto-reversed" — operator believes the berth
was re-attached when nothing happened. Same fix path: persist the
original `interestId` in the decision detail and re-link on restore.
**R2-H2. Smart-archive berth status update has TOCTOU race**
([reliability H2](audit-reliability-2026-05-06.md))
- File: `src/lib/services/client-archive.service.ts:191-207`
- Berth row read outside tx, mutated inside tx without `for update`
lock. Concurrent archive + sale of the same berth can race: the
archive flow flips a freshly-sold berth back to `available`. Add
`select … for update` on `berths` before the status flip.
**R2-H3. Bulk archive can pick the wrong interest for berth release**
([reliability H3](audit-reliability-2026-05-06.md))
- File: `src/app/api/v1/clients/bulk/route.ts:95-103`
- Lookup by `primaryBerthMooring` falls back to `dossier.interests[0]?.interestId ?? ''`.
Empty-string `interestId` reaches the delete and silently matches
zero rows; the link is silently retained while the audit log claims
it was removed.
**R2-H4. External EOI runs five operations outside a transaction**
([reliability H4](audit-reliability-2026-05-06.md))
- File: `src/lib/services/external-eoi.service.ts:67-155`
- Storage upload + 4 DB writes are independent. Mid-flight failure
leaves orphan PDFs in S3/MinIO and partial DB state.
**R2-H5. Bulk wizard double-submit treats `ConflictError('already archived')` as a per-row error**
([reliability H5](audit-reliability-2026-05-06.md))
- File: `src/app/api/v1/clients/bulk/route.ts:68-120`
- No idempotency key on the bulk endpoint. A double-submit (network
retry, double click) makes the second response look like all rows
failed even though the first succeeded.
**R2-H6. Webhook replay button has no UI permission gate (403 toast spam)**
([permissions H1](audit-permissions-2026-05-06.md))
- File: `src/components/admin/webhooks/webhook-delivery-log.tsx:118-131`
- Replay button renders for any user who can load the page. Server gates
on `admin.manage_webhooks`. Non-admins see enabled buttons; clicking
surfaces a generic 403 toast.
**R2-H7. Bulk Archive bulk action exposed to roles without `clients.delete`**
([permissions H2](audit-permissions-2026-05-06.md))
- File: `src/components/clients/client-list.tsx:182-190`
- `sales_agent` and `viewer` see the Archive bulk action; clicking
surfaces a 403 from preflight. Mirror the `canHardDelete` pattern:
`const canBulkArchive = can('clients', 'delete');`
**R2-H8. Bulk add_tag / remove_tag exposed to viewer**
([permissions H3](audit-permissions-2026-05-06.md))
- File: `src/components/clients/client-list.tsx:165-181`
- Same pattern as R2-H7 — no UI gate; server gates on `clients.edit`.
**R2-H9. Bulk hard-delete silently skips rows that vanish between preflight and execute**
([permissions H4](audit-permissions-2026-05-06.md))
- File: `src/lib/services/client-hard-delete.service.ts:377`
- `if (!c) continue;` swallows any client that was archived/restored/
deleted by another operator between preflight and execute. Operator
sees a `deletedCount` lower than requested and no signal which IDs
were skipped.
**R2-H10. Frontend: `webhook-delivery-log` and `audit-log-list` swallow fetch errors silently**
([frontend H3, H4](audit-frontend-2026-05-06.md))
- Files: `src/components/admin/webhooks/webhook-delivery-log.tsx:61-74`,
`src/components/admin/audit/audit-log-list.tsx:150-175`
- Both wrap fetches in `try/finally` with no `catch`. Failed loads show
spinner forever or stale data; user has no signal that anything
failed. Surface via `toast.error` + inline retry banner.
**R2-H11. Frontend: `audit-log-card` renders as `<a href="#">` — page-jumps on mobile tap**
([frontend H5](audit-frontend-2026-05-06.md))
- File: `src/components/admin/audit/audit-log-card.tsx:96`
- Card view rows on mobile insert `#` in URL on tap (back-button trap).
Render as button or div, or link to a useful destination.
**R2-H12. Frontend: `smart-archive-dialog` doesn't invalidate the dossier or single-client query**
([frontend H6](audit-frontend-2026-05-06.md))
- File: `src/components/clients/smart-archive-dialog.tsx:197-212`
- Detail page header keeps showing client as un-archived after a
successful archive until hard reload. Add
`qc.invalidateQueries({queryKey: ['clients', clientId]})` and
`qc.removeQueries({queryKey: ['client-archive-dossier', clientId]})`.
**R2-H13. Frontend: bulk tag mutation uses `alert()` and lacks `onError`**
([frontend H2](audit-frontend-2026-05-06.md))
- File: `src/components/clients/client-list.tsx:88-106`
- Native `alert()` blocks the page on partial failure; pure network
failure shows nothing. Replace with `toast.warning` / `toast.error`.
**R2-H14. Email-template subject overrides are no-ops for 6 of 8 templates**
([missing-features V1](audit-missing-features-2026-05-06.md))
- Files: `src/components/admin/email-templates-admin.tsx:24-72` (UI),
`src/lib/services/portal-auth.service.ts:120,332` (only consumers)
- Admin sees an "Overridden" badge after saving a custom subject for
CRM invite, inquiry confirmation, residential templates, etc. — but
the senders ship the hardcoded subject regardless. Wire
`loadSubjectOverride(portId, key)` into the 6 missing senders.
**R2-H15. Branding admin saves 5 settings that nothing reads**
([missing-features V2](audit-missing-features-2026-05-06.md))
- Files: `src/app/(dashboard)/[portSlug]/admin/branding/page.tsx`,
`src/lib/services/port-config.ts:240-272`
- Logo URL, app name, primary color, header HTML, footer HTML all
dead-end. `getPortBrandingConfig` has zero callers. **Multi-tenant
promise broken — every port's emails ship Port Nimara's branding.**
**R2-H16. Reminder admin saves digest defaults that no scheduler applies**
([missing-features V3](audit-missing-features-2026-05-06.md))
- Files: `src/app/(dashboard)/[portSlug]/admin/reminders/page.tsx`,
`src/lib/services/port-config.ts:284-306`
- Sales reps think they configured a daily digest at 09:00 in their
TZ; they get fire-as-they-hit notifications instead. The digest
scheduler doesn't exist.
### Round 2 — MEDIUM (selected highlights)
**R2-M1. Portal "My Memberships" tile is a dead-end** ([missing-features V4](audit-missing-features-2026-05-06.md))
- Tile on `/portal/dashboard` has no `href`; route doesn't exist. Either
ship `/portal/memberships` or remove the tile.
**R2-M2. Company detail Documents tab is a "Coming soon" stub** ([missing-features V5](audit-missing-features-2026-05-06.md))
- `src/components/companies/company-tabs.tsx:230-234`. Same problem
as the three already-noted "coming soon" stubs but on a different
entity.
**R2-M3. Onboarding page is a static checklist not the wizard it advertises** ([missing-features V6](audit-missing-features-2026-05-06.md))
- The page literally says "what this page will become". Either build
the wizard or relabel the landing card.
**R2-M4. Backup admin page is a docs page despite landing copy promising "on-demand exports"** ([missing-features V7](audit-missing-features-2026-05-06.md))
- Once C1 (worker imports) is fixed, the existing `database-backup`
job is reachable; small lift to wire a "Take backup now" button.
**R2-M5. Inquiry inbox has zero triage actions** ([missing-features V8](audit-missing-features-2026-05-06.md))
- No "Convert to client", no "Resolve", no "Assign". `website_submissions`
table is permanent; sales has to copy-paste emails into client forms.
**R2-M6. external-eoi grants only `documents.upload_signed` but mutates interest state** ([permissions M1](audit-permissions-2026-05-06.md))
- A custom role with `documents.upload_signed:true` + `interests.edit:false`
can flip an interest to "signed" via the external-EOI route.
**R2-M7. `InlineStagePicker` never sends `override:true` — `override_stage` permission unreachable from the most-used UI path** ([permissions M2](audit-permissions-2026-05-06.md))
- Users with the perm have to fall back to the modal `InterestStagePicker`
to actually use it.
**R2-M8. `sales_agent` granted `interests.override_stage:true` — likely copy-paste from sales_manager** ([permissions M3](audit-permissions-2026-05-06.md))
- All other trust-elevated flags are stripped from sales_agent. Needs a
product decision; either flip to false or document intent.
**R2-M9. `bulk-archive-preflight` leaks dossier-loader error text in `blockers`** ([permissions M4](audit-permissions-2026-05-06.md))
- An attacker enumerating UUIDs can distinguish "doesn't exist" vs
"exists but you can't see it". Replace with generic "Could not load
dossier".
**R2-M10. Documenso void worker has no max-retry alert hook** ([reliability M2](audit-reliability-2026-05-06.md))
- A persistent 401/403 retries forever. On exhaustion, write back to
`documents` (`cancellation_failed=true`) and notify admin.
**R2-M11. Mobile More-sheet missing residential, notifications, berth-reservations, website-analytics** ([missing-features V9](audit-missing-features-2026-05-06.md))
- Mobile users have zero path to entire feature domains. Add to
`MORE_ITEMS`.
**R2-M12. Portal has no profile / change-password surface** ([missing-features V10](audit-missing-features-2026-05-06.md))
- Forces every portal user to use the forgot-password flow even when
they remember their old password. Ship `/portal/profile`.
**R2-M13. Portal invoices show amounts but no PDF download** ([missing-features V11](audit-missing-features-2026-05-06.md))
- Documents page does have downloads; mirror the pattern.
(Plus several more medium/low items in the dedicated docs; see those
for the full set.)
---
## TRIAGE LIST (combined Round 1 + Round 2)
### Ship now — CRITICAL
1. **C1** — wire the 5 missing BullMQ workers (`worker.ts`, `server.ts`)
— 5-line fix; every webhook + cron flow is currently dead.
2. **R2-C1** — make bulk archive enqueue Documenso voids + next-in-line
notifications (return value plumbing in `bulk/route.ts`).
3. **R2-C2** — fix the destructive-red hover on the Restore button
(`client-detail-header.tsx`). Trivial CSS fix.
### Ship this week — HIGH (security/UX with concrete user impact)
4. **H1** — rate-limit the hard-delete-request endpoints.
5. **H3** — collapse "no code" vs "wrong code" into one error message.
6. **H7** — three "coming soon" stubs in client/berth tabs.
7. **R2-H1** — fix smart-restore's silent `berth_released` no-op (or
reclassify as `reversibleWithPrompt`).
8. **R2-H2** — add `for update` lock on the smart-archive berth status
flip (TOCTOU race).
9. **R2-H3** — bulk-archive's wrong-interest fallback — empty-string
interestId silently no-ops.
10. **R2-H6, R2-H7, R2-H8** — three permission UI-gate misses on
bulk actions and the webhook-replay button. ~30 lines total.
11. **R2-H10, R2-H12, R2-H13** — frontend swallowed errors + missing
invalidation + alert() instead of toast. Small fixes, immediate UX
win.
12. **R2-H11**`audit-log-card` `href="#"` mobile back-button trap.
13. **R2-H14** — wire 6 missing email-subject overrides through their
senders.
### Next sprint — HIGH/MEDIUM (operational + multi-tenant correctness)
14. **R2-H4** — wrap external-EOI in a transaction.
15. **R2-H5** — bulk-archive idempotency key + treat already-archived as
success in bulk.
16. **R2-H9** — bulk hard-delete should return `skipped: string[]`.
17. **R2-H15, R2-H16** — branding + reminder admin pages save settings
nothing reads (silently broken multi-tenancy).
18. **H2** — audit-page-view de-dupe (don't spam on every filter change).
19. **H4** — synthetic seed needs documented dev-user setup or its own
bootstrap script.
20. **H5** — Documenso bad-secret rate-limit per IP.
21. **R2-M1 through R2-M5** — portal memberships dead-end, company
Documents stub, onboarding wizard, backup page, inquiry inbox triage.
### Backlog — MEDIUM/LOW + remaining items
22. The remaining MEDIUM/LOW from both rounds — see the dedicated docs.
---
## Headline numbers (combined)
- **3 CRITICAL** (worker imports, bulk-archive side-effects, restore-button hover)
- **22 HIGH** (security + UX with concrete impact)
- **~15 MEDIUM** (operational hygiene, multi-tenancy gaps, unfinished features)
- **~10 LOW** (cleanup, defensive)
Round 1 was a manual synthesis after agent-pool stalls; Round 2 was
four focused agents with hard finding caps that all completed inside
the watchdog window. Every finding is grounded in code references.

View File

@@ -0,0 +1,278 @@
# Final audit deferred findings
> **Status update (audit-v3 round)**: most of the v2 deferred items have
> now landed. Items struck through below are completed. The remaining
> open items are bigger refactors (custom-fields per-entity routes,
> systemSettings PK reconciliation, Documenso v2 voidDocument verification,
> partial-vs-composite archived index conversion, storage-proxy port_id
> claim, Documenso webhook port_id enforcement, response-shape
> standardization, berths.current_pdf_version_id Drizzle FK).
The pre-merge audit on `feat/berth-recommender` produced ~30 findings. The
critical + high-severity items were fixed in-branch. The items below are
medium / low severity and deferred to follow-up issues so the merge isn't
held up. Each entry is self-contained — pick one off and ship it.
## Cross-cutting integration
- **EOI in-app pathway silently swallows missing `Berth Range` AcroForm field**
`src/lib/pdf/fill-eoi-form.ts:93`. `setText(form, 'Berth Range', ...)`
is wrapped in a try/catch that succeeds silently when the field is
absent. CLAUDE.md already warns ops about needing to add the field to
the live Documenso template; this code change would make the deployment
gap observable. Fix: when `context.eoiBerthRange` is non-empty AND the
field is absent, log at warn level + surface a structured response field.
- **Email body merge expansion happens after token validation** —
`src/lib/services/document-sends.service.ts:399-403`. If a merge value
contains a `{{token}}` substring (e.g. a client name like
`"Acme {{discount}} Inc."`), the expanded body will contain a token
the unresolved-check missed and ships with literal braces. Fix: HTML-
escape merge values before expansion, OR run a second
`findUnresolvedTokens` against the expanded body.
- **Filesystem dev-fallback HMAC secret can drift across processes** —
`src/lib/storage/filesystem.ts:328-331`. The dev-only fallback derives
the HMAC secret from `BETTER_AUTH_SECRET`. Two CRM processes running
with different secrets (web vs worker) reject each other's tokens.
Fix: assert `BETTER_AUTH_SECRET` is set when filesystem backend is
active in non-prod, or document the requirement loudly.
- **Berth PDF apply path: numeric column nulling silently drops** —
`src/lib/services/berth-pdf.service.ts:473-475`. When
`Number.isFinite(n)` is false the apply loop `continue`s without
pushing to `applied` and without warning. Combined with the
"no appliable fields supplied" check (only fires when ALL drop), partial
silent drops are invisible. Fix: collect dropped keys and surface them.
## Multi-tenant isolation hardening
- **document_sends row stores `interestId` without verifying port match** —
`src/lib/services/document-sends.service.ts:422`. Audit-log pollution
rather than data exposure (the recipient lookup is port-checked already).
Fix: when `recipient.interestId` is set, fetch with
`and(eq(interests.id, ...), eq(interests.portId, input.portId))` and
throw if missing.
- **Storage proxy token does not bind to port_id** —
`src/lib/storage/filesystem.ts:73-84`. ProxyTokenPayload is `{k, e, n,
f?, c?}` with a global HMAC. The current "issuer always checks port
first" relies on every issuer being correct in perpetuity. Fix: add a
`p` (portId) claim and have the proxy route resolve key→owner row +
assert `owner.portId === payload.p` before streaming.
- **Documenso webhook does not enforce port_id on document lookups** —
`src/app/api/webhooks/documenso/route.ts:96-148`. Handlers dispatch by
global `documensoId`. If two ports' documents were ever issued the
same Documenso ID (replay across staging/prod, forwarded webhook from
a foreign instance), the wrong port's interest could be mutated. The
per-body `signatureHash` dedup is partial mitigation. Fix: either
(a) include the originating Documenso instance/team in the lookup, or
(b) verify `documents(documenso_id)` has a unique index port-wide.
## Recent expense work polish
- **renderReceiptHeader cursor math drifts after multi-step writes** —
`src/lib/services/expense-pdf.service.ts:854`. After
`doc.text(...)` with auto-flow, `doc.y` advances. Using `doc.y -
headerH + 10` after the rect+stroke block computes against the
post-rect position; works only because pdfkit's text-after-rect
hasn't moved y yet. Headers may misalign on the first receipt page
after a soft page break. Fix: capture `const baseY = doc.y` before
drawing the rect and compute all subsequent offsets relative to it.
## Settings parsing
- **`loadRecommenderSettings` rejects string-shaped JSONB booleans** —
`src/lib/services/berth-recommender.service.ts:116`. Postgres returns
JSONB `true/false` as JS booleans, but if an admin saves `"true"`
via a UI that wraps the value as a string, `asBool` returns null and
the per-port override silently falls through to defaults. Not a
security bug; a tuning footgun. Fix: accept `"true"`/`"false"` string
forms in `asBool`.
# Audit-final v2 (post-merge platform-wide pass) deferred findings
A second comprehensive audit (security, routes, DB, integrations, UI/UX)
ran after the merge. The high-impact items landed in commit
`fix(audit-final-v2): platform-wide hardening` (or similar). Items below
are deferred follow-ups.
## Routes / API
- **Saved-views routes lack `withPermission`** —
`src/app/api/v1/saved-views/[id]/route.ts:4-5` and
`src/app/api/v1/saved-views/route.ts:24`. Convention is
`withAuth(withPermission(...))`. Verify the service applies
`(ctx.userId, ctx.portId)` ownership filtering, then add either an
explicit owner-only comment or wrap with a benign permission gate.
- **Custom-fields permission resource hardcoded to `clients`** —
`src/app/api/v1/custom-fields/[entityId]/route.ts:15,29`. Custom fields
attach to client / yacht / interest / berth / company, but the route
always checks `clients.view` / `clients.edit`. A user with
`companies.view` can read confidential company custom-field values via
this endpoint (the service-level `customFieldDefinitions.portId` filter
prevents cross-tenant access but not cross-resource within a tenant).
Fix: split into per-entity routes, OR resolve `entityType` and gate on
the matching permission inline.
- **`alerts/[id]/acknowledge|dismiss` ungated** —
`src/app/api/v1/alerts/[id]/acknowledge/route.ts:6` etc. only `withAuth`,
no `withPermission`. Verify the service requires user ownership; if
not, gate on `reports.view_dashboard` or similar.
- **Public POST routes bypass service layer** —
`src/app/api/public/interests/route.ts`, `…/website-inquiries/route.ts`,
`…/residential-inquiries/route.ts`. These do extensive `tx.insert(...)`
with hand-rolled audit logs (`userId: null as unknown as string`).
Extract a `publicInterestService.create(...)` so the same code path is
unit-testable and port-id discipline is uniform. Verify
`audit_logs.user_id` is nullable (the cast pattern signals it is, but
enforce in schema if not).
- **Inconsistent response shapes** — most endpoints return `{ data: ... }`,
but `notifications/[notificationId]` returns `{ success: true }`,
`website-inquiries` returns `{ id, deduped }`. Document a convention in
CLAUDE.md and migrate.
- **`req.json()` without `parseBody` helper** — admin custom-fields
routes use `await req.json(); schema.parse(body)` directly instead of
the project's `parseBody(req, schema)` helper. Migrate for uniform
400 error shapes.
## Documenso integration
- **v2 voidDocument endpoint may not match real API** —
`src/lib/services/documenso-client.ts:450-466`. The audit flagged that
Documenso 2.x exposes envelope deletion as
`POST /api/v2/envelope/delete` with `{ envelopeId }` body, not
`DELETE /api/v2/envelope/{id}`. The unit test mocks fetch so it can't
catch the real shape. Verify against a live Documenso 2.x instance
(`pnpm exec playwright test --project=realapi`) before flipping any
port to v2.
- **Webhook dedup vs per-recipient signed events** —
`src/app/api/webhooks/documenso/route.ts:103-110`. The top-level
`signatureHash` (sha256 of raw body) blocks exact replays, but a
duplicate webhook delivery for a multi-recipient document with a
re-encoded body will go through the per-recipient loop. Make
`documentEvents.signatureHash` unique cover the suffixed values OR add
a composite unique index `(documensoDocumentId, recipientEmail, eventType)`.
- **v1 `placeFields` per-field POST has no retry** —
`src/lib/services/documenso-client.ts:374-398`. A single transient 500
mid-loop leaves the document with a partial field set. Add 3-attempt
exponential backoff on 5xx + voidDocument on final failure.
## Storage
- **S3 backend has no startup bucket-exists check** —
`src/lib/storage/s3.ts:100-111`. A typo'd bucket name surfaces as a
500 inside a user-facing request rather than at boot. Add
`await client.bucketExists(bucket)` in `S3Backend.create` with a clear
error message.
- **Storage cache fingerprint includes encrypted secret** —
`src/lib/storage/index.ts:158-159`. After a key rotation the old
cached client survives until `resetStorageBackendCache()` is called
(already called via the settings-write hook). Document the
invariant or fingerprint on a content-hash that excludes encrypted
material.
- **Filesystem dev HMAC silent fallback** —
`src/lib/storage/filesystem.ts:309-332`. Two dev nodes started with
different `BETTER_AUTH_SECRET` derive different secrets and reject
each other's tokens. Log a one-line warn at backend boot in non-prod.
## DB schema
- **`berths.current_pdf_version_id` lacks Drizzle FK** —
`src/lib/db/schema/berths.ts:83`. The FK exists in migration 0030
but not in the schema source-of-truth, so `pnpm db:push` against an
empty DB skips the constraint. Either add the FK with a deferred
declaration or document that `db:push` is unsupported.
- **Missing indexes on FK columns** — `berthReservations.interestId`,
`berthReservations.contractFileId`, `documents.fileId`,
`documents.signedFileId`, `documentEvents.signerId`,
`documentTemplates.sourceFileId`, `formSubmissions.formTemplateId`,
`formSubmissions.clientId`, `documentSends.brochureId`,
`documentSends.brochureVersionId`, `documentSends.sentByUserId`. Add
`index(...)` declarations to avoid full-scan FK checks on parent
delete.
- **`systemSettings` PK / unique-index drift** —
`src/lib/db/schema/system.ts:119-133`. Schema declares only a
`uniqueIndex` on `(key, port_id)` but the migration uses `key` as PK.
`port_id` is nullable so `(key, port_id)` cannot serve as a PK with
default NULLs-not-equal semantics. Reconcile: declare
`primaryKey({ columns: [table.key, table.portId] })` (after making
`portId` non-null with a sentinel) OR use partial unique indexes for
global + per-port settings.
- **Composite vs partial archived indexes** — many tables use
`index('idx_*_archived').on(portId, archivedAt)` when the dominant
query is `WHERE port_id = ? AND archived_at IS NULL`. Convert to
`index(...).on(portId).where(sql\`archived_at IS NULL\`)` partial
indexes for smaller storage + faster planner choice.
- **`documentSends.sentByUserId` ungated FK** —
`src/lib/db/schema/brochures.ts:118` is `notNull()` but has no FK
reference. If a user is hard-deleted (rare; we soft-delete), an
orphan id remains. Add `.references(() => users.id, { onDelete: 'set null' })`
and make the column nullable. Same audit-trail rationale as the
other documentSends FK fixes (commit 0035).
## UI/UX
- **Storage admin migration mutation lacks toasts** —
`src/components/admin/storage-admin-panel.tsx:61-72`. Add `onSuccess`
toast with row count + `onError` toast.
- **Invoice detail send/payment mutations lack error feedback + gates** —
`src/components/invoices/invoice-detail.tsx:93-99,152-167`. Add
`onError: (e) => toast.error(...)` and wrap mutating buttons in
`<PermissionGate resource="invoices" action="send">` /
`record_payment`.
- **Admin user list edit button ungated** —
`src/components/admin/users/user-list.tsx:114`. Wrap in
`<PermissionGate resource="admin" action="manage_users">`.
- **Email threads list missing skeleton** —
`src/components/email/email-threads-list.tsx:29-45`. Use `<Skeleton>`
rows during load + `<EmptyState>` for the empty case.
- **Scan page mutations swallow OCR errors** —
`src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx:67-87`. Add an
inline error state for `scanMutation.isError` (the upload-side
already does this).
- **Invoice detail uses `any` for query data** — strict-mode escape
hatch. Define a proper response type matching the API contract.
## Security defense-in-depth
- **Storage proxy token does not bind to port_id** —
`src/lib/storage/filesystem.ts:73-84`. Token's HMAC is global. Fix:
add `p` (portId) claim and have the proxy resolve key→owner row +
assert `owner.portId === payload.p`.
- **Documenso webhook does not enforce port_id** —
`src/app/api/webhooks/documenso/route.ts:96-148`. Handlers dispatch
by global `documensoId`. Verify `documents(documenso_id)` is unique
port-wide OR include the originating instance/team in the lookup.
- **EOI in-app pathway silently swallows missing `Berth Range` field** —
`src/lib/pdf/fill-eoi-form.ts:93`. Log warn when
`context.eoiBerthRange` is non-empty AND the field is absent so the
Documenso template deployment gap is observable.
- **AI worker has no cost-tracking ledger write** —
`src/lib/queue/workers/ai.ts:122-177`. Persist token usage to the
`ai_usage` ledger after every call.
- **Logger redact paths miss nested credentials** —
`src/lib/logger.ts:5-19`. Extend redact list to cover
`*.headers.authorization`, `**.token`, `secretKeyEncrypted`, etc.

View File

@@ -0,0 +1,223 @@
# Frontend audit — 2026-05-06
Scope: new archive/restore/hard-delete dialogs, bulk archive wizard, client
detail header, audit log inspector, webhook delivery log, client list bulk
section. Companion to `docs/audit-comprehensive-2026-05-06.md` (does NOT
re-flag the Files-tab / reservations / berth-tab "coming soon" stubs already
covered there).
---
## Critical
### C1 — `client-detail-header` opens restore dialog from the Archive icon for archived clients
**File:** `src/components/clients/client-detail-header.tsx:174-186`
**Scenario:** On an archived client the icon button still renders `<Archive>`
when `isArchived` is true (`isArchived ? <RotateCcw /> : <Archive />` is
correct), BUT both states use the same `setArchiveOpen(true)` handler and
the conditional below routes `<SmartRestoreDialog>` vs `<SmartArchiveDialog>`
off of `isArchived`. That part is fine. The real problem: the destructive
hover colour `hover:text-destructive` is applied via
`isArchived ? 'hover:text-foreground' : 'hover:text-destructive'` — but the
preceding class string already sets `hover:text-foreground` unconditionally,
so the conditional is dead and the restore button hovers red the same as
archive. Misleading colour signal on a reversible action; users hesitate to
click it.
**Fix:** Drop the always-applied `hover:text-foreground` from the base class
list and let the conditional own the hover colour, or just colour the
restore icon emerald to differentiate.
---
## High
### H1 — `bulk-archive-wizard` lets users skip the reasons step by clicking Continue while preflight is loading then Cancel/reopen
**File:** `src/components/clients/bulk-archive-wizard.tsx:253-267, 80-107`
**Scenario:** In the `preflight` stage the Continue button is only disabled
when `archivable.length === 0 || preflight.isLoading`. But `archivable` is
derived from `items = preflight.data ?? []`. While loading, `archivable` is
`[]` so Continue is disabled — good. After load with all-blocked selection,
`archivable.length === 0` so still disabled — good. However, the
`reasonsByClientId: reasons` payload is sent verbatim, so a user who advances
to "reasons", types into one client's box, then uses the carousel back arrow
and edits another, can submit reasons for clients NOT in `archivable` (e.g.
if the preflight is refetched on stale-time). Reasons for blocked or removed
client IDs are forwarded to the API. Minor data-quality issue.
**Fix:** Filter `reasons` to `archivable` IDs before mutating:
`reasonsByClientId: Object.fromEntries(Object.entries(reasons).filter(([id]) => archivable.some(a => a.clientId === id)))`.
### H2 — `client-list` bulk tag mutation uses `alert()` for partial failures and has no `onError`
**File:** `src/components/clients/client-list.tsx:88-106`
**Scenario:** User bulk-adds a tag to 50 clients; backend returns 200 with
`{succeeded: 30, failed: 20}` → user sees a native browser `alert()` blocking
the page. If the request itself errors (network drop, 500), there is no
`onError` so the dialog closes via `onSettled` and the user sees nothing —
silent failure. Inconsistent UX vs. every other mutation in this audit which
uses `toast`.
**Fix:** Replace `alert(...)` with `toast.warning(...)`, add an
`onError: (err) => toast.error(...)` branch matching the pattern used in
`bulk-archive-wizard.tsx` and `bulk-hard-delete-dialog.tsx`.
### H3 — `webhook-delivery-log` swallows fetch errors silently
**File:** `src/components/admin/webhooks/webhook-delivery-log.tsx:61-74`
**Scenario:** Admin opens a webhook detail page while the API is down or the
webhook was just deleted. `load()` catches and discards the error
(`} catch { /* ignore */ }`). UI shows "Loading deliveries…" forever on the
first load, or stays on the last successful page on subsequent loads, with
no indication that anything failed. No error state, no toast, no retry.
**Fix:** Surface errors via `toast.error` and show an inline error state
("Couldn't load deliveries — Retry") instead of swallowing.
### H4 — `audit-log-list` first-page fetch swallows errors and shows no error state
**File:** `src/components/admin/audit/audit-log-list.tsx:150-175`
**Scenario:** Filter form is fully interactive, user changes a date — request
fires, server 500s. The `try/finally` has no `catch`, so the rejected promise
becomes an unhandled rejection. The list shows whatever was previously
loaded (or empty state), and the user has no idea their filter didn't apply.
Same applies to `loadMore`.
**Fix:** Add `catch` blocks that set an error state and render an inline
error banner above the table, with a Retry button.
### H5 — `audit-log-card` renders as a link to `href="#"` — clicking jumps the page
**File:** `src/components/admin/audit/audit-log-card.tsx:96`
**Scenario:** On mobile / card view the audit log entries become clickable
cards with `href="#"`. Tapping any card scrolls the page to top and inserts
`#` in the URL (back-button trap). There's no detail view to navigate to.
**Fix:** Either render a non-link wrapper (button or div) when no detail
target exists, or link to a useful destination like
`/{portSlug}/{entityType}/{entityId}` when the entity is resolvable.
### H6 — `smart-archive-dialog` `archiveMutation` doesn't invalidate the dossier or single-client query
**File:** `src/components/clients/smart-archive-dialog.tsx:197-212`
**Scenario:** User archives a client successfully. The dialog invalidates
`['clients']`, `['berths']`, `['interests']` but NOT
`['client-archive-dossier', clientId]` nor `['clients', clientId]`. If the
parent screen (e.g. detail page) keeps the client query mounted, the
detail header continues to show the client as un-archived until a hard
reload. The Restore icon won't appear.
**Fix:** Add `qc.invalidateQueries({queryKey: ['clients', clientId]})` and
`qc.removeQueries({queryKey: ['client-archive-dossier', clientId]})` so a
re-open re-fetches a fresh dossier (e.g. if user re-archives after restoring
in the same session).
---
## Medium
### M1 — `smart-archive-dialog` derives `interestId` from a name match against `primaryBerthMooring` — wrong key
**File:** `src/components/clients/smart-archive-dialog.tsx:158-167`
**Scenario:** When building per-berth decisions the code does
`dossier.interests.find((i) => i.primaryBerthMooring === b.mooringNumber)?.interestId`.
Multiple interests can share the same primary mooring (rare, but possible
historically), and worse, when no interest has this berth as primary it
falls back to `dossier.interests[0]?.interestId` regardless of which berth
is being decided. The wrong interest gets credited with the release, which
is then audit-logged.
**Fix:** Have the dossier API return `interestId` per berth row (it already
joins `interest_berths`), or look up by membership not by primary flag.
### M2 — `hard-delete-dialog` doesn't reset state when switching from intent → confirm if request fails midway
**File:** `src/components/clients/hard-delete-dialog.tsx:39-46, 64-79`
**Scenario:** User submits hard delete with wrong code → backend returns 400
→ toast fires, but the dialog stays on `confirm` stage with the bad code
still in the input and no clear cue. If the user then closes (X) and
reopens, the `useEffect` resets correctly. But if the email code expired
(10 min) and they request a fresh one, there's no "Resend code" button —
they must cancel and start over from intent. Minor.
**Fix:** Add a "Send a new code" link in the confirm stage that calls
`requestCode.mutate()` again and clears `code`.
### M3 — `bulk-hard-delete-dialog` doesn't refetch / invalidate after partial failure shows totals
**File:** `src/components/clients/bulk-hard-delete-dialog.tsx:64-85`
**Scenario:** Bulk delete returns `{deletedCount: 7}` for 10 selected; toast
warns but `qc.invalidateQueries({queryKey: ['clients']})` is invalidated
unconditionally — fine. However, the dialog closes immediately
(`onOpenChange(false)`), so the user can't see WHICH 3 failed. The toast
just says "see audit log". For a destructive bulk op this is too sparse;
users will repeat the action thinking it didn't work.
**Fix:** Stay open on partial failure and render a list of failed IDs (the
API likely already returns per-item results — if not, return them).
### M4 — `audit-log-list` doesn't validate that `dateFrom <= dateTo`
**File:** `src/components/admin/audit/audit-log-list.tsx:142-146`
**Scenario:** User picks From=2026-06-01, To=2026-05-01. Query fires with an
empty result range; user sees "No audit log entries found" and assumes
their data isn't there. No client-side validation hint.
**Fix:** Show an inline warning "From date must be before To date" and skip
the request when invalid.
### M5 — `bulk-archive-wizard` `Cancel` during `archiveMutation.isPending` discards mutation tracking
**File:** `src/components/clients/bulk-archive-wizard.tsx:248-251, 293-307`
**Scenario:** User clicks "Archive 50" → mutation in flight (10s) → user
clicks Cancel. The dialog closes; the mutation continues server-side and
its onSuccess fires later, showing a toast for an action the user thought
they cancelled. Worse, the dialog is gone so they can't tell which clients
got archived.
**Fix:** Disable Cancel while `archiveMutation.isPending`, or relabel to
"Cancel (won't stop in-progress)" and keep the mutation visible.
---
## Low
### L1 — `audit-log-list` filter row overflows on narrow viewports
**File:** `src/components/admin/audit/audit-log-list.tsx:321-467`
**Scenario:** 8 filter controls (`Search` 288px, `Entity` 144px, `Action`
176px, `Severity` 128px, `Source` 128px, `User id` 176px, `From` 144px,
`To` 144px, total ~1330px) sit in a single `flex-wrap` row. At <1280px
viewports they wrap onto multiple lines pushing the table down 200+px;
at <640px (mobile) each control wraps onto its own line and the "Clear"
button (`ml-auto`) lands on the wrong row.
**Fix:** Collapse rarely-used filters (User id / Severity / Source) into a
"More filters" Popover for sm: viewports.
### L2 — `audit-log-card` action map missing entries silently fall back to grey "Activity" icon and grey badge
**File:** `src/components/admin/audit/audit-log-card.tsx:27-44, 46-52`
**Scenario:** New webhook/cron/job actions are in `audit-log-list.tsx`
ACTION_COLORS but absent from `audit-log-card.tsx` ACTION_BADGE_COLORS and
ACTION_ACCENT. Card view of these entries looks identical to a generic
"unknown" entry — visual loss vs. table view.
**Fix:** Sync the two maps; consider extracting to a shared module so they
can't drift.

View File

@@ -0,0 +1,405 @@
# Missing-Features Audit — 2026-05-06
Focused pass on **features that look done in the UI but aren't fully
wired through the service layer**, plus **admin settings exposed to
users that no code reads**. Companion to
`docs/audit-comprehensive-2026-05-06.md` — the three "coming soon" stubs
already documented there (client Files tab, client reservations history,
berth tabs), the import-worker stub, the two interest-form TODOs, and
the EOI "Price: TBD" finding are NOT re-flagged here.
Hard cap: 12 findings. Severity tiers below.
---
## VISIBLE-BROKEN (admin sees a control, click is a no-op or wrong)
### V1. 6 of 8 admin-editable email subject overrides are silently ignored at send time
**Files:**
- `src/components/admin/email-templates-admin.tsx:24-72` (UI)
- `src/lib/email/template-catalog.ts:16-25` (catalog of 8 keys)
- `src/lib/services/portal-auth.service.ts:120-127, 332-339` (the only
consumers of `loadSubjectOverride`)
The `/admin/email-templates` page lets an admin override the subject
line on **eight** transactional templates:
`portal_activation`, `portal_reset`, `portal_invite_resend`,
`crm_invite`, `inquiry_client_confirmation`,
`inquiry_sales_notification`, `residential_inquiry_client_confirmation`,
`residential_inquiry_sales_alert`. The save endpoint persists each one
to `system_settings` (`email_template_<key>_subject`).
Only **two** of those eight are ever read at send time —
`portal_activation` and `portal_reset` in `portal-auth.service.ts`.
A repo-wide search for `loadSubjectOverride` / `settingKeyForSubject`
returns no other consumers. The other six templates use their hardcoded
subject regardless of the admin override.
**Impact:** sales/ops teams will customize an inquiry confirmation
subject, hit Save, see the "Overridden" badge, and silently ship the
default subject to every prospect.
**Fix:** small per template — call `loadSubjectOverride(portId, key)`
in each sender (`crm-invite.service.ts`, the inquiry sender, the
residential inquiry sender, the portal-invite-resend path) and pass the
result through as the email subject.
**Scope:** small (5 callsites + tests).
---
### V2. Branding admin (logo / app name / primary color / email header & footer HTML) saves to settings but no code reads them
**Files:**
- `src/app/(dashboard)/[portSlug]/admin/branding/page.tsx:7-46` — UI
with five fields.
- `src/lib/services/port-config.ts:240-272``getPortBrandingConfig()`
resolves the five `branding_*` settings into a typed config.
- Repo-wide: `getPortBrandingConfig` has **zero callers** outside its
declaration. The five `SETTING_KEYS.branding*` constants are only
read inside `getPortBrandingConfig` itself.
The admin panel is functional end-to-end (write hits the settings API,
"Reset to default" works), and the email-templates module hardcodes
`s3.portnimara.com/...` for the logo URL plus a fixed table layout.
None of the email-rendering helpers (`renderEmail`, the template
modules in `src/lib/email/templates/`) call `getPortBrandingConfig`,
and the `<BrandedAuthShell>` component sources its logo + colors from
constants too.
**Impact:** every multi-tenant assumption made about branding is
broken. A second port wired into this CRM will see Port Nimara's logo
- colors in every transactional email and on the auth pages, even
after their admin "configures branding" successfully.
**Fix:** plumb `getPortBrandingConfig(portId)` through the email
renderer (header/footer HTML + primary button color), and through
`<BrandedAuthShell>` via a server-fetched prop.
**Scope:** medium (touches every transactional email + auth shell).
---
### V3. Reminder admin page configures defaults that no service applies
**Files:**
- `src/app/(dashboard)/[portSlug]/admin/reminders/page.tsx:7-50` — UI
for default-enabled, default-days, digest-enabled, digest-time,
digest-timezone.
- `src/lib/services/port-config.ts:284-306`
`getPortReminderConfig()` defines the schema.
- Repo-wide: the keys (`reminder_default_*`, `reminder_digest_*`) and
`getPortReminderConfig` have **zero callers**.
Same pattern as V2. The admin sets "enable reminders by default on new
interests" → toggles to true → save succeeds → newly-created interests
still default to `reminderEnabled=false`. The digest-time +
timezone fields go nowhere — there is no scheduler that batches
pending reminders into a daily digest.
**Impact:** the entire reminder UX is decorative. Sales reps think
they configured a daily digest at 09:00 Europe/Warsaw, get
fire-as-they-hit notifications instead.
**Fix:** wire `getPortReminderConfig` into (a) the interest-create
service (defaults), (b) the maintenance/notifications worker that
fires reminders (digest batching + delivery window). The `digest`
behavior didn't exist before this audit — needs a new scheduled job.
**Scope:** medium (defaults are small, digest job is new code).
---
### V4. Portal dashboard "My Memberships" tile has no link, no destination page, and isn't reachable from nav
**Files:**
- `src/app/(portal)/portal/dashboard/page.tsx:58-63` — `<PortalCard
title="My Memberships" ... icon={Building2} />` — note no `href`
prop.
- `src/components/portal/portal-nav.tsx:8-15` — six nav entries, no
memberships.
- Filesystem: `src/app/(portal)/portal/memberships/` does not exist.
The dashboard shows a count of "memberships" (companies the portal
user belongs to) but the tile is non-clickable and there is no
`/portal/memberships` route. A user with 3 memberships sees the tile,
clicks → nothing happens.
**Impact:** dead-end on the portal home for any client tied to a
company (the residential and yacht-ownership use-cases).
**Fix:** ship `/portal/memberships/page.tsx` listing the companies
returned by the existing `companyMemberships` query (already
aggregated in `getPortalDashboard`), and add it to `PortalNav`. Or
pull the tile if memberships isn't a portal feature.
**Scope:** small.
---
### V5. Company detail page Documents tab is a "Coming soon" stub
**File:** `src/components/companies/company-tabs.tsx:230-234`
```ts
{
id: 'documents',
label: 'Documents',
content: <EmptyState title="Documents" description="Coming soon" />,
},
```
Visible alongside the working Notes / Activity / Addresses / Members
tabs on every company detail page. NOT covered by the existing audit
doc's H7 (which lists clients, client reservations, and berths).
**Impact:** the same UX problem H7 calls out for clients.
**Fix:** mirror what client-Files-tab needs — query `documents` joined
to a polymorphic billing-entity = company link, render a list, ship a
download button. Or hide the tab.
**Scope:** small to medium.
---
## HALF-WIRED (the page works but the surrounding promise overstates it)
### V6. "Onboarding" admin page is a static checklist, not the wizard the page itself promises
**File:** `src/app/(dashboard)/[portSlug]/admin/onboarding/page.tsx`
The page renders 8 stepwise links and explicitly says (lines 71-72,
98-110): "The future onboarding wizard will track progress per port…",
"What this page will become", "The wizard will record completion per
port in `system_settings`, gate the public marketing-site cutover…".
The admin landing card describes it as the "Initial-setup wizard for
fresh ports" — admins clicking through expect a wizard, get a static
table of contents.
**Impact:** the only "fresh port" workflow doesn't exist; cutover
gating logic mentioned in the page body is also unimplemented.
**Fix:** either (a) build the wizard with progress in `system_settings`
- banner integration, or (b) re-label both this page and the admin
landing card to "Setup checklist" so expectations match reality.
**Scope:** large for the wizard; tiny for the relabel.
---
### V7. Backup & Restore admin page is informational only — admin landing card promises actions
**Files:**
- `src/app/(dashboard)/[portSlug]/admin/backup/page.tsx`
- `src/app/(dashboard)/[portSlug]/admin/page.tsx:148` — landing card
description: "Database snapshots and on-demand exports."
The landing card sells "on-demand exports". The actual page renders a
two-card explainer: "Current backup posture" (read-only) and "What
this page will become" (the entire interactive surface — list
snapshots, "Take backup now" button, per-port logical export, restore
preview, GDPR per-client export). None of those exist.
**Impact:** the "Backup & Restore" tile is functionally a docs page.
Compliance officers / users expecting a self-serve GDPR export
button have to file a support ticket.
**Fix:** match the language on the landing card to the page reality
("Backup posture" → docs only) until the snapshot/export buttons
ship. The maintenance worker already runs `database-backup` (per
`docs/audit-comprehensive-2026-05-06.md` C1 — though that worker isn't
imported), so wiring "Take backup now" against the existing job is
small once C1 is fixed.
**Scope:** small (doc tweak) or medium (button + per-port export
endpoint).
---
### V8. Inquiry inbox is read-only — no "Convert to Client" / "Mark resolved" / "Assign" actions
**File:** `src/components/admin/inquiry-inbox.tsx` (entire file, 207
lines, ends at the View payload toggle)
The inbox lists website-form submissions (berth_inquiry,
residence_inquiry, contact_form) with filter chips and a
"View payload" expand. There is no action to:
- create a client/interest from the submission,
- assign the inquiry to a sales rep,
- mark it resolved / triaged,
- reply directly,
- archive or trash the row,
- export.
The `website_submissions` table appears to be permanent — every
inquiry ever received remains in the inbox forever, with no triage
state. Sales has to manually copy the email into a new client form
and back-reference the original submission.
**Impact:** the inquiry-to-pipeline conversion step isn't supported in
the CRM. The marketing-site cutover (per the user's
`project_email_ownership_at_cutover.md` memory) will increase volume
on this surface and make the missing triage UX painful.
**Fix:** add a per-submission "Convert" action that prefills the
client + interest forms with the payload, plus a `triage_state`
column (open / converted / dismissed) and a default filter that hides
non-open rows.
**Scope:** medium.
---
## MOBILE PARITY
### V9. Mobile More-sheet is missing several real top-nav destinations
**File:** `src/components/layout/mobile/more-sheet.tsx:38-50`
`MORE_ITEMS` lists 11 entries. The dashboard route directory has at
least these top-level segments not represented anywhere in the mobile
bottom-tabs OR more-sheet:
- `residential` — exists at `/[portSlug]/residential/...`
- `notifications` — exists at `/[portSlug]/notifications/...`
- `berth-reservations` — exists at `/[portSlug]/berth-reservations/...`
- `documents` — exists as a top-level page (separate from the bottom
tab `documents`, which IS in mobile-bottom-tabs)
- `website-analytics` — exists at `/[portSlug]/website-analytics/...`
A mobile-only user has no path to any of them. The Documents bottom
tab does cover the doc list, but residential is an entire feature
domain (per the `(dashboard)/.../residential` directory) with no
mobile entry point.
**Impact:** anyone using the mobile chrome to triage on the go can't
reach residential clients/interests, alerts (`alerts` IS in the
sheet), or notifications.
**Fix:** add the missing segments to `MORE_ITEMS`. If the grid feels
too dense, reorganize into sections.
**Scope:** small.
---
### V10. Portal has no "Profile" / "Change password" surface
**Files:**
- `src/components/portal/portal-nav.tsx:8-15` — six tabs, no profile.
- Filesystem: no `src/app/(portal)/portal/profile/` directory.
A portal user who wants to change their email, phone, mailing address,
or password has no UI. The portal sign-in flow goes through the
better-auth session but the app exposes zero account-management
controls. The "Need assistance?" card on the dashboard tells the user
to contact the port team — which is the explicit answer for data
edits, but does not cover password changes (a security expectation,
not a per-port-staff burden).
**Impact:** every portal user who forgets their password (after
already activating) has to use `/portal/forgot-password` even if they
remember the old one. There's no proactive password rotation. A user
who changes their phone number has to email the port to update it.
**Fix:** ship `/portal/profile` with at minimum: read-only PII view +
"Change password" form (re-uses the existing reset-password endpoint
or a new `change-password` endpoint that takes the current pw).
Phone/address editing is a longer fix because of the audit-trail
implications.
**Scope:** small for password; medium with PII edits.
---
### V11. Portal invoices page lists invoices but offers no view/download — even though documents do
**File:** `src/app/(portal)/portal/invoices/page.tsx:53-99`
Each invoice row shows number, status, due/paid dates, amount, and a
small payment-status caption. There is no link, no PDF view, no
download. By contrast, the portal Documents page (peer route) ends
each row with a `<DocumentDownloadButton documentId={doc.id} />` that
fetches a signed S3 URL.
Compare to admin/CRM where invoices have a full PDF render flow
(invoice service generates the PDF + signed URL).
**Impact:** a portal user can see they owe money and cannot retrieve
the actual invoice document. They have to email the port to ask for a
PDF copy.
**Fix:** add an invoice-PDF endpoint under `/api/portal/invoices/[id]/
download` mirroring the documents one, and a download button on each
row. The invoice PDF generator already exists (`src/lib/services/
invoices.ts`).
**Scope:** small.
---
## DEV-NOTES (legitimately staged-for-later, calling out so they're not forgotten)
### V12. Email-templates admin only edits subject lines — body editing is a documented "next iteration"
**Files:**
- `src/components/admin/email-templates-admin.tsx:78-79` —
"Customize the subject line of transactional emails per port. Body
editing is the next iteration; for now the layout and HTML stay
locked to the default template."
- `src/lib/email/template-catalog.ts:5-9` — same statement in the
catalog header.
The page is honest about the limitation, so this isn't a "broken"
finding. But it's a notable shipped-without-the-killer-feature gap:
the multi-tenant promise of per-port email customization can't deliver
the body changes that ports actually want (logo placement, signature,
language). Combined with V2 (branding HTML fragments aren't read at
all), there is currently NO way for a non-super-admin per-port admin
to customize the email body in any way.
**Impact:** confined to admin expectations — most ports will assume
"Email templates" = "edit the email", click in, see only a subject
field, and request the missing body editor.
**Fix:** scope a body-editing flow that reuses the
`merge_fields.ts` token catalog (the validator already exists for
document templates) for safety. Until that's built, V2 + this finding
together mean a "rebrand the emails" task is single-tenant only.
**Scope:** large (HTML editor + token validator + per-port override
storage + render-side composition).
---
## Summary
12 findings, four severity tiers:
- **Visible-broken (V1-V5):** five admin/portal controls produce no
effect. V1 (email overrides) and V2 (branding) are the highest
impact — both silently break the multi-tenant promise.
- **Half-wired (V6-V8):** three pages where the surrounding wrapper
oversells what's there. V8 (inquiry inbox) is the largest scope.
- **Mobile parity (V9-V11):** mobile users can't reach several real
features; portal users have no profile/password surface and can't
download invoices.
- **Dev-notes (V12):** documented limitations called out for the
roadmap.
The two highest-leverage quick wins are **V1** (wire 6 missing
template subject overrides — a few hours) and **V11** (portal invoice
download — small, fixes a real customer pain point).

View File

@@ -0,0 +1,266 @@
# Per-role permission audit — 2026-05-06
Focused review of UI/server permission divergence on the new endpoints
shipped during the smart-archive / hard-delete / bulk-wizard /
external-EOI / webhook-replay work bundle. Skips items already covered
in `docs/audit-comprehensive-2026-05-06.md` (audit-log gating H6,
residential_partner sidebar nav).
The pattern hunted for: `<PermissionGate>` (or `usePermissions().can`)
on the UI side hides a control under permission **X**, while the
matching API route gates on permission **Y** (or doesn't gate at all,
or gates strictly — producing 403 toast spam for users who can see the
button but can't use it).
Scope: 8 routes + 5 components + the seed permission matrix. Hard cap
of 10 findings, ranked by impact. Critical/High/Medium/Low.
---
## CRITICAL
_None._ The four new hard-delete endpoints all gate on
`admin.permanently_delete_clients` on both layers (UI hides the button
via `<PermissionGate resource="admin" action="permanently_delete_clients">`
in `client-detail-header.tsx:162` and via `canHardDelete = can('admin',
'permanently_delete_clients')` in `client-list.tsx:53`; the four routes
all wrap with `withPermission('admin', 'permanently_delete_clients', …)`).
The webhook-replay route gates on `admin.manage_webhooks` — see H1 below
for the matching UI gap.
---
## HIGH
### H1. Webhook replay button has no UI permission gate (403 toast for non-admins)
- **UI:** `src/components/admin/webhooks/webhook-delivery-log.tsx:118-131`
— the Replay `<Button>` renders for any user who can load the page,
with no `<PermissionGate>` wrapper and no `usePermissions().can('admin',
'manage_webhooks')` check.
- **Server:** `src/app/api/v1/admin/webhooks/[webhookId]/deliveries/[deliveryId]/redeliver/route.ts:15`
`withPermission('admin', 'manage_webhooks', …)`.
**Divergence:** A `sales_manager` / `sales_agent` / `viewer` who
somehow lands on `/admin/webhooks/{id}` (e.g. via a deep link from a
shared message) sees enabled Replay buttons. Clicking surfaces a
generic 403 toast — the user has no signal that the action is
restricted, just that "Replay failed".
**Fix:** wrap the Replay `<Button>` in
`<PermissionGate resource="admin" action="manage_webhooks">…</PermissionGate>`,
or skip rendering the entire "Replay" column when
`!can('admin', 'manage_webhooks')`. The page-level guard on
`/admin/webhooks` should prevent non-admins from reaching the route in
the first place, but defense-in-depth is cheap and the toast UX is
poor.
---
### H2. Bulk-archive bulk action exposed to roles without `clients.delete`
- **UI:** `src/components/clients/client-list.tsx:182-190` — the
"Archive" entry in `bulkActions` is unconditionally rendered (only
the "Permanently delete" entry checks `canHardDelete`).
- **Server:** `src/app/api/v1/clients/bulk/route.ts:40-57` — gates
`archive` action on `clients.delete`. Also
`src/app/api/v1/clients/bulk-archive-preflight/route.ts:30`
`withPermission('clients', 'delete', …)`.
**Divergence:** `sales_agent` (`clients.delete:false`,
seed-permissions.ts:246) and `viewer` (`clients.delete:false`,
seed-permissions.ts:323) both see the Archive bulk action. Selecting
clients and pressing it fires the `BulkArchiveWizard`, which calls
`bulk-archive-preflight` (returns 403) followed by `bulk` archive
(also 403). The wizard surfaces this as an opaque error.
**Fix:** mirror the `canHardDelete` pattern — compute
`const canBulkArchive = can('clients', 'delete');` near
`client-list.tsx:53` and conditionally include the Archive entry.
---
### H3. Bulk add_tag / remove_tag exposed to viewer (clients.edit:false)
- **UI:** `src/components/clients/client-list.tsx:165-181` — the "Add
tag" / "Remove tag" bulk actions render with no permission check.
- **Server:** `src/app/api/v1/clients/bulk/route.ts:40-57` — both gate
on `clients.edit`.
**Divergence:** A `viewer` can multi-select rows, click "Add tag" or
"Remove tag", pick a tag in the dialog, hit "Apply", and receive a 403. The standalone bulk tag dialog has no inline gating to prevent
this.
**Fix:** the bulk action menu entries should gate on
`can('clients', 'edit')`. (Sales agent and above pass; only `viewer`
and `residential_partner` see the bug.)
---
### H4. `client-merge-log.surviving_client_id` enforcement absent from per-row port check on bulk hard-delete
- **Server:** `src/lib/services/client-hard-delete.service.ts:269-272`
The bulk preflight loads **every** row in the port
(`db.select(...).from(clients).where(eq(clients.portId, args.portId))`)
into memory, then validates the requested `clientIds` against that map.
That's correct for tenant isolation — a foreign-port id can't appear in
the map — but the inner loop at lines 364-389 then re-fetches each
client by `(id, portId)` and **silently skips** rows where the second
fetch returns nothing (line 377: `if (!c) continue;`). If a client is
archived between preflight and execute by another operator, the bulk
delete reports `deletedCount` lower than the requested set with no
error — the operator has no way to tell which ids were skipped.
**Divergence (perm-adjacent):** the per-row gate is enforced for
tenancy but the failure mode masquerades as success. Combined with
the route's all-or-nothing `withPermission` at the top, a
`permanently_delete_clients`-bearing operator can quietly under-delete.
**Fix:** when `c` is null, push the id into a `skipped: string[]`
array and return it in the response so the UI can surface "3
deleted, 1 skipped (not archived / removed by another user)".
---
## MEDIUM
### M1. `external-eoi` upload allows any role with `documents.upload_signed` regardless of `interests.edit`
- **UI:** `src/components/interests/interest-detail-header.tsx:382-395`
`<PermissionGate resource="documents" action="upload_signed">`.
- **Server:** `src/app/api/v1/interests/[id]/external-eoi/route.ts:8`
`withPermission('documents', 'upload_signed', …)`.
**Divergence:** UI and server agree on the permission, but the seed
matrix has `documents.upload_signed:true` for `sales_agent` (line 264) AND any custom role with that flag — uploading an externally
signed EOI mutates the **interest** (it's the operative `signedDocument`
that flips the interest into a "signed" state inside
`uploadExternallySignedEoi`). The user only needs `documents.upload_signed`,
not `interests.edit`. A custom role with `documents.upload_signed:true`
- `interests.edit:false` can mutate the interest's effective state.
**Fix:** add a second gate inside the route handler:
`if (!ctx.isSuperAdmin && !ctx.permissions?.interests?.edit) throw new ForbiddenError(...)`.
Rationale: signing a doc against an interest is an interest-state
change, not just a document upload. Mirror the same check in
`<PermissionGate>` (use `<PermissionGate resource="interests" action="edit">`
nested inside the `documents.upload_signed` gate).
---
### M2. `change_stage` UI doesn't expose override checkbox in `InlineStagePicker` — server still accepts override
- **UI:** `src/components/interests/inline-stage-picker.tsx:52-58`
the inline picker (used in the detail header at
`interest-detail-header.tsx:221`) sends only
`{ pipelineStage, reason }` and never sets `override:true`. Users
with `override_stage` get no UI affordance to actually use the
permission from the inline picker; they have to open the modal
`InterestStagePicker` (which does expose the checkbox at line 137).
Worse, when a user picks a stage that isn't a legal forward
transition, the inline picker just shows the toast from the server's
`ConflictError` — instead of "you need override; toggle this box".
- **Server:** `src/app/api/v1/interests/[id]/stage/route.ts:14-22`
reads `body.override` and re-checks `interests.override_stage`
permission.
**Divergence:** UI and permission map diverge in the affordance, not
the gate. End-result: the `override_stage` permission is partially
unreachable from the inline picker. Sales managers / agents can
override only via the modal picker.
**Fix:** when the inline picker sees a transition that isn't allowed
by `canTransitionStage(currentStage, newStage)`, check
`can('interests', 'override_stage')` and either auto-set
`override:true` (with a confirmation) or surface a "Use override"
secondary action. Keep the inline picker UX; just don't let the
override permission be silently inaccessible from the most-used
path.
---
### M3. `sales_agent` granted `interests.override_stage:true` — possible copy-paste from sales_manager
- **Seed:** `src/lib/db/seed-permissions.ts:253``SALES_AGENT_PERMISSIONS.interests.override_stage = true`.
This is identical to `SALES_MANAGER_PERMISSIONS.interests.override_stage = true`
at line 176. The same `sales_agent` block has `delete:false` for
clients/interests/yachts/companies/files/etc — all the other
"trust-elevated" flags are explicitly stripped from sales_agent. The
ability to bypass the pipeline-stage transition table is a meaningful
trust elevation: it lets an agent skip prerequisites (e.g. mark an
interest as `eoi_signed` without an actual signed doc) which has
downstream implications for the public berths feed (`Under Offer`
status), the recommender's tier ladder, and the EOI bundle.
**Divergence:** likely intent vs. permission map. Worth confirming
with a product owner; if intentional, leave a code comment. If
unintentional, flip to `false`.
**Fix:** product decision. If demoted, also update
`src/components/admin/roles/role-form.tsx → DEFAULT_PERMISSIONS`
(noted in the file header at seed-permissions.ts:9) so the UI
default for new roles matches.
---
### M4. `bulk-archive-preflight` returns dossier even when client is in another port (defense-in-depth)
- **Server:** `src/app/api/v1/clients/bulk-archive-preflight/route.ts:33-62`
The route loops through `ids` and calls `getClientArchiveDossier(id, ctx.portId)`
for each. If a `clientId` belongs to another port, `getClientArchiveDossier`
throws and the route catches it (line 52-61) and returns a fallback row
with `blockers: ['<error message>']`. This leaks **the existence of an
unknown client id** — an attacker enumerating UUIDs can distinguish
"client doesn't exist" from "client exists but you can't see it" by
parsing the blocker text. The bulk hard-delete route has the same
shape but returns `NotFoundError`.
**Divergence (perm-adjacent):** the preflight route doesn't enforce a
per-id port check before falling through to the dossier service, and
the catch block leaks the failure mode in the response.
**Fix:** in the catch block, replace the dossier error message with a
generic `'Could not load dossier'` blocker. The operator already
selected these ids so they know the count; they don't need the inner
error.
---
## LOW
### L1. `external-eoi` route doesn't enforce `interests.edit` defense-in-depth on the interest port
- **Server:** `src/app/api/v1/interests/[id]/external-eoi/route.ts:8-14`
The route receives `interestId` from the URL and passes it +
`ctx.portId` into `uploadExternallySignedEoi`. The service is
expected to enforce port isolation, but the route itself does no
upfront `(interestId, portId)` existence check before reading the
multipart body — meaning a cross-port id will fully process the
upload (read the file into memory) before the service rejects.
**Divergence:** not strictly a permission divergence; it's resource
waste from missing early port-ownership check. Low because the
service-level reject does close the security hole.
**Fix:** add a one-row `select` on `interests` matching `id` + `portId`
before parsing form data, throw `NotFoundError` on miss.
---
## Summary
- 0 critical
- 4 high (H1H4)
- 4 medium (M1M4)
- 1 low (L1)
Top recommendation: H1 (webhook-replay UI gate) is a
ten-line fix that closes a 403-toast UX bug. H2 + H3 (bulk-archive +
bulk-tag UI gates) are also trivial and remove the same class of bug
across the bulk actions menu. M3 (sales_agent override_stage) needs a
product decision, not code; flag it before shipping the audit.

View File

@@ -0,0 +1,220 @@
# Reliability audit — 2026-05-06 (focused, post-batch deltas)
Scope: NEW services from the recent archive/restore/hard-delete/external-EOI batches.
Out of scope (already covered in `docs/audit-comprehensive-2026-05-06.md`):
worker imports, rate limits, hard-delete error message UX, smart-restore
dead reversal applier, bulk hard-delete redis loop, audit log spam.
---
## Critical
### C1. Bulk archive enqueues zero post-commit side effects
- **File:** `src/app/api/v1/clients/bulk/route.ts:68-134`
- **Scenario:** When the bulk wizard archives 100 clients with high-stakes
reasons, `archiveClientWithDecisions` returns `externalCleanups` and
`releasedBerths` arrays per-client, but `runBulk` discards the return
value. Documenso envelopes that the wizard marked `void_documenso`
never get queued, and "next-in-line" notifications never fire. The
database is left in `documents.status='cancelled'` with the live
Documenso envelope still out for signature — the signer can complete
a legally-binding envelope that the CRM thinks is voided.
- **Fix:** Make the per-row callback return the result, then loop over
`results` after `runBulk` to enqueue Documenso voids and fire
next-in-line notifications (mirroring the single-client route).
Defaulting `documentDecisions` to `'leave'` (line 113-116) hides the
symptom for the bulk wizard but isn't enough — the single-client
service can still surface this if the bulk path is ever generalized.
---
## High
### H1. Restore wizard silently drops every released berth
- **File:** `src/lib/services/client-restore.service.ts:359-372`
- **Scenario:** `applyReversal` for `berth_released` is a no-op with a
comment saying "v1 leaves the berth available". But the dossier (line
122-129) classifies these as `autoReversible` and the UI tells the
operator "still available — re-attaching to the restored client". The
wizard increments `autoReversed` and the audit log records a
successful auto-reverse — but nothing actually happens. Operator
thinks restore re-linked their berth; it didn't.
- **Fix:** Either (a) actually re-link by persisting the original
`interestId` in the `berth_released` decision detail (it's already
there, line 211) and re-inserting an `interestBerths` row + flipping
the berth status back to `under_offer`, or (b) reclassify these as
`reversibleWithPrompt` with copy that says "berth left available —
re-add via the interest detail page".
### H2. Smart-archive berth status update has TOCTOU race
- **File:** `src/lib/services/client-archive.service.ts:191-207`
- **Scenario:** Berth row is read via `dossier.berths` (read outside the
tx) and modified inside the tx without a `for update` lock on
`berths`. Two concurrent flows — e.g. operator A archives client X
while operator B sells berth A1 to client Y — can race: A reads
`berth.status === 'sold' → false`, B's tx commits sold, A's tx then
flips it back to `available`. The "still under offer" subselect
doesn't catch this because berth.status is the source of truth, not
interest_berths.
- **Fix:** Add `tx.select(...).from(berths).where(eq(berths.id, d.berthId)).for('update')`
before the status flip and re-check `status !== 'sold'` against the
locked row.
### H3. Bulk archive can pick the wrong interest for berth release
- **File:** `src/app/api/v1/clients/bulk/route.ts:95-103`
- **Scenario:** When a client has multiple interests linked to the same
berth, the bulk wizard picks `dossier.interests.find((i) =>
i.primaryBerthMooring === b.mooringNumber)` and falls back to
`dossier.interests[0]?.interestId ?? ''`. The fallback to the
first-interest-or-empty-string can hand `archiveClientWithDecisions`
an `interestId` that was never linked to that berth — so the
`delete from interest_berths where berthId=… and interestId=…`
matches zero rows and the link is silently retained. Worse: an empty
string `''` reaches the delete, which still matches zero rows but
leaves the berth status check believing the link was removed.
- **Fix:** Build the berth→interest map from `interestBerthRows` (the
authoritative join) rather than guessing by `primaryBerthMooring`,
and skip berths with no resolvable interest rather than emitting an
empty-string interestId.
### H4. External EOI runs four writes outside a transaction
- **File:** `src/lib/services/external-eoi.service.ts:67-155`
- **Scenario:** `getStorageBackend().put()`, `files.insert`,
`documents.insert`, `documentEvents.insert`, and the interests
update happen as five independent operations. If any one fails after
the storage upload, you're left with an orphan PDF in S3/MinIO and
partial DB state. If the documents insert fails after the file
insert, the file row points to a storage key with no document
referencing it — and the interest never advances.
- **Fix:** Wrap files/documents/documentEvents/interests in a single
`db.transaction`. Storage upload stays outside (S3 isn't
transactional) but on tx failure, schedule a cleanup job that deletes
the orphan storage object, or accept the orphan and add a janitor.
### H5. Bulk wizard double-submit re-archives the same client and racy errors
- **File:** `src/app/api/v1/clients/bulk/route.ts:68-120` +
`src/lib/services/client-archive.service.ts:165-173`
- **Scenario:** The single-client `archiveClientWithDecisions` locks
the row and throws `ConflictError('Client is already archived')` on
re-entry — good. But `runBulk` swallows the error string and returns
it as `{ok:false, error:"Client is already archived"}` for that
client. If the bulk wizard double-submits (network retry, double
click), partial successes from the first request now look like
per-client failures in the response, confusing the operator. There's
no idempotency key on the bulk submit.
- **Fix:** Treat `ConflictError('already archived')` as success in the
bulk per-row handler (the desired end state is reached). Or add an
idempotency-key header on the bulk endpoint that short-circuits a
duplicate request with the cached response.
---
## Medium
### M1. Hard-delete `clientMergeLog.surviving_client_id` deletes audit history
- **File:** `src/lib/services/client-hard-delete.service.ts:209`
- **Scenario:** The comment says "merged records remain in the log
because mergedClientId has no FK", but the delete is wider than
needed: it removes every merge-log row where this client was the
survivor. If client X (being deleted) previously absorbed clients
A/B/C, the audit trail of those merges is lost on X's deletion. The
surviving rows that remain (`mergedClientId = X`) are now
inconsistent — they reference a survivor that no longer exists.
- **Fix:** Either preserve the survivor rows by setting
`surviving_client_id = NULL` (requires column nullable) or keep the
current behavior but document it more visibly. At minimum, log the
deleted merge-log row count so operators can investigate gaps.
### M2. Documenso void worker has no max-retry guard for non-404 errors
- **File:** `src/lib/queue/workers/documents.ts:19-37`
- **Scenario:** `voidDocument` throws `CodedError` on non-404 failures
(auth error, network blip, Documenso 500). BullMQ retries with
backoff, but there's no per-job idempotency check — the second
retry hits the same envelope, voidDocument's 404 short-circuit only
kicks in if Documenso has actually voided it on the first retry
before the API call returned an error. A persistent 401 / 403 will
retry forever (until BullMQ exhausts attempts) and the documents row
stays `cancelled` in the CRM with the envelope still live in
Documenso. The DLQ is mentioned in the comment but the worker
doesn't surface a DLQ alert hook.
- **Fix:** On exhaustion, write back to `documents` (e.g.
`cancellation_failed=true`) and emit an admin notification so the
envelope can be voided manually.
### M3. Next-in-line notification fan-out unhandled rejection
- **File:** `src/lib/services/next-in-line-notify.service.ts:75-87`
- **Scenario:** Each `void createNotification(...)` is a fire-and-forget
promise with no `.catch` handler. If `notifications.service`
dispatches to a DB that's transiently down, the unhandled rejection
will surface in the Node process with no recipient context (the
closure captured `userId` is in the stack but pino won't include it
unless explicitly logged). Process-level handlers will log it but
individual recipients silently lose their notification.
- **Fix:** `.catch((err) => logger.warn({err, userId, berthId:
input.berthId}, 'next-in-line notification failed'))`.
### M4. Restore service uses `any` for transaction type
- **File:** `src/lib/services/client-restore.service.ts:354-355`
- **Scenario:** `applyReversal(tx: any, ...)` defeats Drizzle's type
safety. A future schema rename (e.g. `yachts.status` enum change)
won't fail at compile time inside this function. Combined with the
documented v1 no-op for `berth_released`, the function looks
innocuous but carries the most risk.
- **Fix:** Use the proper Drizzle tx type — `Parameters<Parameters<typeof
db.transaction>[0]>[0]` or a named type alias from
`@/lib/db/types.ts` if one exists.
### M5. interests.changeInterestStage milestones write outside tx
- **File:** `src/lib/services/interests.service.ts:630-648`
- **Scenario:** The override path (and normal path) writes
`pipelineStage` in one update and milestone fields
(`dateEoiSent`, `dateContractSigned`, etc.) in a second update. If
the process crashes between the two, the stage advances but the
milestone is never recorded. Funnel/conversion math then under-
counts that interest. Over-the-wire this is rare but the audit log
fires before the milestone update succeeds, so the audit trail
claims a complete transition that's actually half-applied.
- **Fix:** Combine both into a single update statement, computing the
milestone fields in JS and merging them into the `set({...})` clause.
---
## Low
### L1. Smart-archive coalesces invoice notes via SQL string concat
- **File:** `src/lib/services/client-archive.service.ts:288-291`
- **Scenario:** `notes: sql\`coalesce(${invoices.notes}, '') || ${...}\``embeds`new Date().toISOString()`and the action label inside a
parameterized string. The values are bound, so it's not an injection
risk, but the`\n[archive ...]` marker is appended unconditionally —
re-running the archive on a not-yet-committed client would double
the marker. Combined with H5 (no idempotency on bulk), a retry could
bloat invoice notes with duplicate markers.
- **Fix:** Append only when the marker isn't already present, or rely
on the `clients.archivedAt is null` precheck (which already guards
re-entry) and accept the duplicate as theoretically impossible.
### L2. Hard-delete `requestHardDeleteCode` reveals client existence pre-archive
- **File:** `src/lib/services/client-hard-delete.service.ts:77-85`
- **Scenario:** A user without `admin.permanently_delete_clients`
shouldn't reach this service, so this is theoretical, but the
ConflictError "Client must be archived" leaks the existence of an
unarchived client to anyone who can reach the route. The audit doc
flagged hard-delete error messages already (out of scope), but this
specific error path isn't covered there.
- **Fix:** Same as the audit-doc finding for the symmetric path —
return a generic `NotFoundError` instead of distinguishing
"not found" from "not archived" externally; log the distinction
internally only.

View File

@@ -0,0 +1,147 @@
# Handoff prompt for new Claude Code session
Copy everything below the `---` line into the new chat as your first message.
---
I'm continuing work on a comprehensive multi-feature push that was fully designed in a prior session but not yet implemented. The complete plan lives at `docs/berth-recommender-and-pdf-plan.md` (~1030 lines). **Read that file end-to-end before doing anything else — every design decision, schema change, edge case, and confirmed answer to a product question is captured there.** Don't re-litigate decisions; if something seems unclear, the answer is almost certainly in the plan.
## What the project is
A multi-tenant marina/port-management CRM at `/Users/matt/Repos/new-pn-crm`. Next.js 15 App Router, React 19, TypeScript strict, Drizzle ORM on Postgres, MinIO for files, BullMQ on Redis, better-auth, shadcn/ui, Tailwind. See `CLAUDE.md` for the conventions.
## What we're building (high level)
The plan bundles 8 capabilities into one branch (`feat/berth-recommender`):
1. **/clients + /interests list-column fix** (the original bug — list views show `-` everywhere because the service didn't join contacts/yachts)
2. **Full NocoDB Berths import** + seeding + mooring-number normalization (current CRM has `A-01..E-18`; canonical is `A1..E18`)
3. **Schema refactor** to many-to-many `interest_berths` with role flags (`is_primary`, `is_specific_interest`, `is_in_eoi_bundle`)
4. **Berth recommender** (SQL ranking, tier ladder, heat scoring, UI panel) — no AI; pure SQL
5. **EOI bundle** support (multi-berth EOIs + range formatter for the Documenso PDF: `["A1","A2","A3","B5","B6"]``"A1-A3, B5-B6"`)
6. **Pluggable storage backend** (s3-compatible OR local filesystem) so admins can run without MinIO if they want
7. **Per-berth PDFs** (versioned uploads, OCR-based reverse parser, conflict-resolution diff dialog)
8. **Sales send-out emails** (berth PDF + brochure) with full audit + size-aware fallback to download links
## Phase ordering (from plan §2)
```
Phase 0: Full NocoDB berth import + mooring normalization + 5 new pricing columns
Phase 1: /clients + /interests list column fix
Phase 2: M:M interest_berths schema refactor + desired dimensions on interests
Phase 3: CRM /api/public/berths endpoint + website cutover
Phase 4: Recommender SQL + tier ladder + heat + UI panel
Phase 5: EOI bundle + range formatter
Phase 6a: Pluggable storage backend + migration CLI + admin UI
Phase 6b: Per-berth PDF storage (versioned) + reverse parser
Phase 7: Sales send-outs + brochure admin + email-from settings
Phase 8: CLAUDE.md updates + final validation
```
**Start with Phase 0**.
## Working tree state at handoff
- Branch: `main` (you'll create `feat/berth-recommender` from here)
- Recent commits (already pushed):
- `8699f81 chore(style): codebase em-dash sweep + minor layout polish`
- `d62822c fix(migration): NocoDB import safety + dedup helpers + lead-source backfill`
- `089f4a6 feat(receipts): upload guide page + scanner head-tag fix`
- `77ad10c feat(dashboard): custom date range + KPI port-hydration gate`
- `e598cc0 feat(layout): unified Inbox + UserMenu extraction`
- `f5772ce feat(analytics): Umami integration with per-port admin settings`
- `49d34e0 feat(website-intake): dual-write endpoint + migration chain repair`
- Untracked / uncommitted at handoff:
- `docs/berth-recommender-and-pdf-plan.md` (the plan — read this first)
- `docs/berth-feature-handoff-prompt.md` (this file)
- `berth_pdf_example/` (two reference files — see below)
- `.env.example` (modified — adds `WEBSITE_INTAKE_SECRET=`; pre-commit hook blocks `.env*` files so user adds this manually)
- Dev DB state:
- 245 clients (210 with no `nationality_iso` — Phase 1 backfills from primary phone's `value_country`)
- 4 test rows in `website_submissions` (from a previous live audit; safe to ignore)
- 90 berths with `mooring_number` in `A-01` format (Phase 0 normalizes to `A1`)
- vitest: 956 tests passing
- tsc: clean (one pre-existing issue in `scripts/smoke-test-redirect.ts` that's unrelated)
## Reference files
- `berth_pdf_example/Berth_Spec_Sheet_A1.pdf` (358 KB) — sample per-berth PDF. **0 AcroForm fields** (confirmed via pdf-lib) so OCR with positional heuristics is the primary parser tier; the AcroForm tier is built defensively. Plan §9.2 captures the layout structure.
- `berth_pdf_example/Port-Nimara-Brochure-March-2025_5nT92g.pdf` (10.26 MB) — sample brochure. Sized so it ships as an attachment under the 15 MB threshold. Plan §11.1 covers brochure handling.
## NocoDB access
You have `mcp__NocoDB_Base_-_Port_Nimara__*` tools available. Tables you'll touch most:
- `mczgos9hr3oa9qc` — Berths (Phase 0 imports from here; mooring numbers are stored as `A1..E18`)
- `mbs9hjauug4eseo` — Interests (the combined client+deal table the old system used)
## Branch & commit conventions
- Create the branch: `git checkout -b feat/berth-recommender`
- Commit messages match recent history style: `<type>(<scope>): <subject>` lowercase, terse subject, body explains why not what.
- **Pre-commit hook blocks any `.env*` file** including `.env.example`. If you need to update `.env.example`, leave it staged and tell the user to commit manually with `--no-verify` (they're aware of this).
- **Don't push without explicit user permission.** Commits are fine; pushes need approval.
- **Don't run `git rebase`, `git push --force`, or anything destructive without checking.** The branch is solo-owned but the repo's `main` is shared.
## User communication preferences (from prior session)
- Direct, no fluff. If something is a bad idea, say so — don't sycophant.
- When proposing changes, include trade-offs explicitly.
- For multi-question decisions, use `AskUserQuestion` rather than long bulleted lists.
- Run validation (vitest + tsc) at logical checkpoints. Don't ship a commit with regressions.
- The user prefers small focused commits over mega-commits. Within Phase 0 alone there will probably be 2-3 commits (e.g. mooring normalization, schema additions, NocoDB import script).
## Critical rules (from plan §14)
Eleven 🔴 critical items requiring tests before their phase ships:
1. NocoDB mooring collisions → unique constraint + ON CONFLICT
2. Non-PDF disguised upload → magic-byte check
3. Recipient email typos → pre-send confirmation
4. XSS in email body markdown → DOMPurify + payload tests
5. SMTP credentials silently failing → loud error + failed `document_sends` row
6. Wrong-environment `CRM_PUBLIC_URL` → health-check env match
7. Mooring format drift breaking `/berths/A1` URLs → Phase 0 normalization gates Phase 3
8. Multi-port isolation in recommender → explicit `port_id` filter + cross-port test
9. Permission escalation on SMTP creds → per-port admin only, no rep visibility
10. Filesystem backend in multi-node deployment → refuse to start; documented + health-check enforced
11. Path traversal via storage key in filesystem mode → strict regex validation + path realpath check
## Pending items (from plan §9)
These are non-blocking but worth knowing:
- Sample brochure already provided (the 10.26 MB file above).
- SMTP app password for `sales@portnimara.com` — not yet obtained; expected close to production cutover. Phase 7 ships the admin UI immediately and the credential gets entered when available.
- `CRM_PUBLIC_URL` confirmed as `https://crm.portnimara.com` once live; configurable via env.
- GDPR cascade behavior for `document_sends` (delete vs. anonymize-PII vs. keep) — left `OPEN` in §14.10, default lean: anonymize-PII. Revisit when Phase 7 schema lands.
## Scope reminder
- **No prod data depends on the current CRM schema** — refactors don't need backwards-compatibility shims. But every schema change still ships as a Drizzle migration with `pnpm db:generate`.
- **Pluggable storage** rejects Postgres `bytea` as an option (§4.7a). The two backends are s3-compatible (MinIO/AWS/B2/R2/etc.) and local filesystem. Filesystem is single-node only.
## What to do first
1. Read `docs/berth-recommender-and-pdf-plan.md` end-to-end. Don't skim. The edge-case audit in §14 alone is critical context.
2. Confirm you've understood the plan by stating back the 8-phase outline and the 11 critical items, then ask the user if they want to proceed with Phase 0.
3. Once approved, create `feat/berth-recommender` and start Phase 0.
Phase 0 deliverables (per plan):
- One commit normalizing existing CRM mooring numbers from `A-01``A1` form (via `regexp_replace` migration). Delete the offending `scripts/load-berths-to-port-nimara.ts`.
- One commit adding the 5 new berth columns (`weekly_rate_high_usd`, `weekly_rate_low_usd`, `daily_rate_high_usd`, `daily_rate_low_usd`, `pricing_valid_until`, `last_imported_at`). Run `pnpm db:generate`. Verify `meta/_journal.json` prevId chain stays contiguous.
- One commit adding `scripts/import-berths-from-nocodb.ts` — the idempotent NocoDB import (handles updates, preserves CRM-side edits via `last_imported_at vs updated_at` check, `pg_advisory_lock`, dry-run flag, etc. per §4.1 and §14.1).
- Update `src/lib/db/seed-data.ts` with the imported berth set so fresh installs get them.
- Final vitest + tsc validation at the end of Phase 0.
## Don't
- Don't push to remote during this session (user will batch the push later).
- Don't commit `.env*` files (hook blocks them anyway).
- Don't edit `.gitignore` to exclude generated artifacts; the repo's existing ignores are correct.
- Don't add documentation files unless the plan asks for them — the plan itself is the doc.
- Don't add features not in the plan. If something seems missing, ask.
- Don't use AI for the recommender (plan §1 + §13). Pure SQL ranking.
Once you've read the plan and confirmed understanding, ask me whether to proceed with Phase 0.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,722 @@
# Documenso signing-flow build plan
Captures every Documenso-related piece that isn't shipped yet, in attack order. A fresh session should be able to pick this up without re-reading the whole conversation.
**Companion docs:**
- [docs/documenso-integration-audit.md](./documenso-integration-audit.md) — what's already built, v1/v2 endpoint mapping, nginx CORS block
- Old system reference: [client-portal/server/api/eoi/generate-quick-eoi.ts](../client-portal/server/api/eoi/generate-quick-eoi.ts), [client-portal/server/api/webhooks/documenso.post.ts](../client-portal/server/api/webhooks/documenso.post.ts), [client-portal/server/services/documenso-notifications.ts](../client-portal/server/services/documenso-notifications.ts), [Port Nimara/Website/pages/sign/[type]/[token].vue](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue)
---
## Locked design decisions (from user, do NOT re-ask)
| Q | Decision |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Embedded signing host | `portnimara.com/sign/<role>/<token>` (marketing website hosts the embed page; CRM emits URLs in this format) |
| Initial "please sign" email | **Per-port admin setting** `eoi_send_mode`: `auto` = send branded email immediately on generate; `manual` = generate + show URL + Send button |
| Contract / Reservation generation | **Upload-and-place-fields per deal only.** EOI is the only template-driven flow. (Resolved Q6 — template-fallback dropped.) |
| Reminder cadence | **Manual by default.** Rep clicks "Send reminder" button. Per-doc opt-in for auto-reminders at upload time. (Resolved Q1) |
| Document expiration | **Never expire.** No `expiresAt` UI in v1. (Resolved Q2) |
| Approver vs CC | **Two concepts**: `APPROVER` = real Documenso recipient that gates signing; `Completion CC` = passive recipient that only receives the signed PDF. (Resolved Q4) |
| Witness | **First-class signer role.** Configurable per-document; full reminder/tracking flow. (Resolved Q7) |
| Per-port developer label | **Configurable** via `documenso_developer_label` / `documenso_approver_label`. (Resolved Q8 bonus) |
| Multi-port template config | All Documenso settings are per-port via `/[portSlug]/admin/documenso` (already wired) |
| Documenso API version | Both v1 + v2 supported. Per-port config picks. v1 is prod (1.32) — primary. v2 unlocks embed + envelope |
| nginx CORS | User applies manually. Block is in [docs/documenso-integration-audit.md](./documenso-integration-audit.md). Supports multi-origin via `set $cors_origin` regex |
| Signer override | **Hybrid** — template docs (EOI) keep template-fixed signers (per-port settings fill the slots). Custom-uploaded docs (contract, reservation) get full per-deal signer customization. |
| Multi-berth | EOI keeps existing bundle support. Contract/reservation are custom-uploaded PDFs — no PDF form-fill, just Documenso signature/initials/date fields |
| Test mode | Reuse `EMAIL_REDIRECT_TO` env var (already redirects every outbound email + Documenso recipient) |
| Regenerate handling | Match old system: 3 retries to delete prior Documenso doc with 2-second wait. **Plus** a confirm modal: "Retain old EOI? (default no)" |
| Field placement strategy | **Auto-detect (anchor text scanner) + manual drag-drop UI as safety net.** Auto-detect populates the initial state; rep can drag/delete/reassign before sending. |
---
## What's already shipped (foundation)
Files in place; do NOT rebuild:
- `src/lib/services/port-config.ts` — extended with: `documenso_developer_name/email`, `documenso_approver_name/email`, `eoi_send_mode`, `embedded_signing_host`, `documenso_contract_template_id`, `documenso_reservation_template_id`
- `src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx` — admin UI exposes every Documenso knob across 5 cards
- `src/lib/email/templates/document-signing.ts``signingInvitationEmail`, `signingCompletedEmail`, `signingReminderEmail` with per-port branding
- `src/lib/services/document-signing-emails.service.ts``sendSigningInvitation`, `sendSigningReminder`, `sendSigningCompleted`. Includes `transformSigningUrl(rawUrl, host, role)` for embed URL wrapping
- `src/lib/services/documenso-client.ts` — extended `DocumensoFieldType` to all 11 types: SIGNATURE, FREE_SIGNATURE, INITIALS, DATE, EMAIL, NAME, TEXT, NUMBER, CHECKBOX, DROPDOWN, RADIO. Plus typed `DocumensoTextFieldMeta`/`NumberFieldMeta`/`ChoiceFieldMeta` interfaces and `fieldTypeNeedsMeta(type)` helper
- `src/components/interests/interest-eoi-tab.tsx` — EOI workspace with active-doc hero, signing progress, paper-signed upload, history strip
- `src/components/interests/interest-contract-tab.tsx` — Contract workspace shell with paper-signed upload + "send for signing" placeholder dialog
- `src/components/interests/interest-reservation-tab.tsx` — Reservation workspace shell (clone of Contract)
- `src/components/interests/interest-tabs.tsx` — stage-conditional visibility wired
What works today end-to-end: generate EOI → Documenso template path → manual link sharing (rep copies URL out of UI). What does NOT yet work: auto-send branded invitation, cascading "your turn" emails, custom-doc upload-to-Documenso, embedded signing URL emission to the website, on-completion PDF distribution.
---
## Phase 1 — EOI generate flow polish (~3 hours)
> **Updated for Q1, Q4, Q6, Q8 resolutions.** Adds manual-reminder endpoint, two new per-port label settings, drop of contract/reservation template settings, schema columns for completion CCs + auto-reminder. Also folds in webhook-secret hardening (Risk #7 Option A) and `transformSigningUrl` role mapping (Risk #5 fix).
**Why first**: Smallest surface area, validates the per-port `eoi_send_mode` setting works end-to-end, gets the cascading-email mental model in place before tackling the bigger pieces.
### Tasks
1. **Auto-send wiring**: in `src/components/documents/eoi-generate-dialog.tsx`, after `handleGenerate()` succeeds:
- Fetch port's `eoi_send_mode` (already on `getPortDocumensoConfig(portId)`)
- If `auto`: server-side already sent the doc to Documenso with `sendEmail: false`. Now call new endpoint `POST /api/v1/documents/[id]/send-invitation` (build it) which:
- Looks up the document's signers
- Calls `sendSigningInvitation()` for the first signer (the client; signing order 1)
- Stores `sent_at` timestamp on the signer row
- If `manual`: do nothing. Surface the signing URL in the EOI tab + a "Send invitation" button that hits the same endpoint.
2. **Regenerate confirm modal**: when EOI tab's "Generate EOI" button is clicked AND a Documenso doc already exists for this interest (`activeDoc !== null`):
- Show a `<Dialog>` asking: "There's already an EOI in flight. Regenerating will create a new document and the existing one will be cancelled."
- Two buttons: "Cancel" (default), "Regenerate" (destructive)
- Below the buttons, a checkbox: "Keep the previous EOI in Documenso (don't delete)" — defaults UNCHECKED
- On confirm: if checkbox unchecked, call `voidDocument(oldId, portId)` with 3 retries + 2-second wait between (mirror old system's `generate-quick-eoi.ts` lines 110-162). Then run the normal generate flow.
3. **Send-invitation endpoint**: new file `src/app/api/v1/documents/[id]/send-invitation/route.ts`:
```ts
POST /api/v1/documents/[id]/send-invitation
Body: { recipientId?: string } // optional — defaults to first unsigned recipient
```
- Loads the document + signers
- Resolves the target recipient (passed-in or first unsigned in signing order)
- Resolves port's documenso config + the recipient's signing URL from the document_signers row
- Calls `sendSigningInvitation` from the email service
- Updates `document_signers.invited_at` (need to add column — see schema migration below)
4. **Schema migration**: add `invited_at` and `last_reminder_sent_at` columns to `document_signers`:
```sql
ALTER TABLE document_signers ADD COLUMN invited_at timestamptz;
ALTER TABLE document_signers ADD COLUMN last_reminder_sent_at timestamptz;
```
The webhook handler updates these (Phase 2). Apply via psql then restart dev server (per CLAUDE.md migration note).
### Acceptance criteria
- Setting `eoi_send_mode=auto` in admin → generating an EOI fires off our branded HTML email to the client immediately
- Setting `eoi_send_mode=manual` → no email fires; "Send invitation" button in EOI tab hits the endpoint
- Clicking Generate when an active EOI exists → confirm dialog with checkbox; default deletes prior doc with retries
---
## Phase 2 — Webhook handler enhancement (~3-4 hours)
**Why second**: Once invitations are flowing (Phase 1), the webhook needs to track the lifecycle and fire the cascading "your turn" emails as each signer completes. Without this, the system goes silent after the initial invite.
### Tasks
1. **Extend `src/app/api/webhooks/documenso/route.ts`** to handle `DOCUMENT_OPENED`, `DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED` (DOCUMENT_OPENED currently ignored).
2. **For `DOCUMENT_SIGNED`** (fires when one recipient signs, can fire multiple times per doc):
- Resolve the (port, document, signer) — existing per-port secret lookup already does this
- Update `document_signers.signed_at` for the matching signer
- Find the next unsigned signer in signing order
- If next signer exists AND we haven't already invited them: call `sendSigningInvitation()` with the next signer + their signing URL + role='developer' (or 'approver' depending on signing order). Mark `document_signers.invited_at` for them.
- This is the cascading "your turn" flow that mirrors `client-portal/server/services/documenso-notifications.ts`
3. **For `DOCUMENT_OPENED`**:
- Update `document_signers.opened_at` for the matching recipient (matched by token in payload)
- Used for analytics later ("12% of clients open within an hour")
4. **For `DOCUMENT_COMPLETED`** (fires once when all signers have signed):
- Update document `status='completed'`, `completed_at=...`
- Download signed PDF: `await downloadSignedPdf(documensoId, portId)` (existing)
- Store in storage backend via the file ingestion flow — this creates a `files` row
- Update the document row to point at the signed file (`signed_file_id`)
- Call `sendSigningCompleted()` with all signers + the signed file's id
- Update the linked interest's pipeline stage:
- If document type = `eoi` → `eoi_signed`
- If document type = `contract` → `contract_signed`
- If document type = `reservation_agreement` → leave stage; reservation is post-deal-close anyway
5. **Recipient-token matching**: webhooks include `payload.recipients[]` with each recipient's `token`. Use the token to match against `document_signers.signing_token` (need to add the column if not already). Old system's webhook does this via email match — fragile when the same email serves multiple roles. Token match is robust.
6. **Idempotency**: webhook can fire duplicates. Old system's `acquireWebhookLock` + signature comparison pattern is good. Port that logic.
### Schema migration
```sql
-- Add fine-grained tracking columns to document_signers
ALTER TABLE document_signers ADD COLUMN invited_at timestamptz;
ALTER TABLE document_signers ADD COLUMN opened_at timestamptz;
ALTER TABLE document_signers ADD COLUMN last_reminder_sent_at timestamptz;
ALTER TABLE document_signers ADD COLUMN signing_token text; -- index this
CREATE INDEX idx_ds_signing_token ON document_signers (signing_token);
```
### Acceptance criteria
- Client signs → developer receives our branded "your turn" email within seconds
- Developer signs → approver receives the same
- All signed → all three recipients receive the signed PDF as attachment
- Interest's pipeline stage advances to `eoi_signed` automatically
- Re-firing of duplicate webhooks is no-op
---
## Phase 3 — Custom document upload-to-Documenso (~6-8 hours)
**Why third**: Backend foundation for contract + reservation flows. Without this, the "Upload draft for signing" CTA on those tabs is a placeholder.
### Tasks
1. **New service** `src/lib/services/custom-document-upload.service.ts`:
```ts
export async function uploadDocumentForSigning(args: {
interestId: string;
portId: string;
documentType: 'contract' | 'reservation_agreement';
pdfBuffer: Buffer;
filename: string;
title: string;
recipients: Array<{
name: string;
email: string;
role: 'SIGNER' | 'APPROVER' | 'CC';
signingOrder: number;
}>;
fields: DocumensoFieldPlacement[]; // from auto-detect or manual placement
}): Promise<{ documentId: string; signingUrls: Record<string, string> }>;
```
Steps:
- Convert pdfBuffer → base64
- Call `createDocument(title, base64, recipients, portId)` — existing client function
- Call `placeFields(docId, fields, portId)` — existing client function (handles v1 + v2)
- Call `sendDocument(docId, portId)` — existing
- Return doc ID + per-recipient signing URLs
- Mirror the timing-safe URL extraction from old system's generate-quick-eoi (recipients[].signingUrl)
- Insert a row into our `documents` table with the new doc_id + signers + interest link
- If port's `eoi_send_mode === 'auto'`: kick off `sendSigningInvitation()` to first signer
2. **API endpoint**: `POST /api/v1/interests/[id]/upload-for-signing`
- Accepts multipart: `file` (the PDF), `documentType`, `title`, `recipients` (JSON), `fields` (JSON)
- Validates: file is PDF (magic-byte check, see berth-pdf flow), recipients ≥ 1, fields ≥ 1
- Calls service
- Returns 201 with the new document row
3. **Update Contract + Reservation tab placeholders** to open a real upload dialog (see Phase 4).
### Acceptance criteria
- Endpoint accepts a PDF + recipients + fields and returns a Documenso doc ID
- Document appears in the Documents tab with status `sent`
- v1 and v2 paths both work (same code path; client chooses based on per-port config)
---
## Phase 4 — Recipient configurator + Field placement UI (~10-14 hours)
**Why fourth**: This is the BIG visual piece. Don't start until Phase 3 backend is proven via curl.
### Sub-phase 4a: Recipient configurator (~2-3 hours)
UI inside a new `<UploadForSigningDialog>` component:
- File picker (drag-drop + click)
- Title input (defaults to filename minus extension)
- Recipients list:
- Add row → name + email + role (SIGNER/APPROVER/CC) + signing order (number, auto-increments)
- Drag to reorder (uses `dnd-kit`, already in deps)
- Delete row
- Defaults: client (signing order 1) prefilled from interest's linked client; developer + approver prefilled from port settings
- "Configure fields →" button advances to sub-phase 4b
### Sub-phase 4b: PDF rendering (~3-4 hours)
- Install: `pnpm add react-pdf` (uses pdfjs-dist under the hood; pdfme already pulls pdfjs-dist so no new dep weight)
- Render the uploaded PDF page-by-page using `<Document>` + `<Page>` from react-pdf
- Page navigation (prev/next, page picker)
- Zoom controls (50%, 75%, 100%, 125%, 150%)
### Sub-phase 4c: Auto-detect scanner (~4-6 hours)
New file `src/lib/services/document-field-detector.ts`:
```ts
export interface DetectedField {
type: DocumensoFieldType;
pageNumber: number;
pageX: number; // 0-100 percent
pageY: number;
pageWidth: number;
pageHeight: number;
/** Confidence 0-1 — how sure the scanner is. */
confidence: number;
/** Original anchor text (for debugging / display). */
anchorText?: string;
/** Inferred recipient (from nearby labels). null = unassigned. */
inferredRecipientLabel?: string | null;
}
export async function detectFields(pdfBuffer: Buffer): Promise<DetectedField[]>;
```
Implementation:
- Use `pdfjs-dist` to extract text per page with `getTextContent()` — gives `{str, transform: [a,b,c,d,e,f]}` per text item where `e,f` is position in PDF user space, plus `width/height`
- Anchor patterns:
- `SIGNATURE`: `/signature[:\s_-]+/i`, `/sign\s*here[:\s_-]*/i`, `/X\s*_{4,}/i`, `/signed\s*by[:\s]+/i`
- `INITIALS`: `/initials?[:\s_-]+/i`
- `DATE`: `/dated?[:\s_-]+/i`, `/date\s+of\s+signature/i`
- `NAME`: `/(printed?\s*)?name[:\s_-]+/i`, `/full\s+name[:\s_-]+/i`
- `EMAIL`: `/email[:\s_-]+/i`
- Catch-all: `/_{8,}/` → if not preceded by name/email/date keyword, default to TEXT
- For each match: place field bounding box immediately AFTER the matched text (offset 5pt right), with type-appropriate width:
- SIGNATURE: 150pt × 30pt
- INITIALS: 50pt × 30pt
- DATE: 80pt × 20pt
- NAME: 150pt × 20pt
- EMAIL: 200pt × 20pt
- TEXT: 200pt × 20pt
- Convert to PERCENT (divide by page width/height)
- Recipient inference: scan ±100pt of the field for labels like "Buyer", "Seller", "Client", "Developer", "Witness", "Notary". Map to recipient by role.
### Sub-phase 4d: Drag-drop overlay (~3-4 hours)
- Overlay absolute-positioned divs on top of the PDF viewer for each field
- Each field shows: type icon + recipient color + delete (×) handle + drag affordance
- Use `dnd-kit` to enable drag — update `pageX/pageY` in state on drop
- Field palette toolbar: 11 buttons (one per Documenso field type) — click to enter "place mode" → next click on the PDF places a new field at that coord
- Side panel for selected field:
- Type changer (dropdown)
- Recipient assignment (dropdown of configured recipients)
- Required toggle
- Per-type config (TEXT label, NUMBER min/max, CHECKBOX/DROPDOWN/RADIO options) — drives `fieldMeta`
- Width/height inputs
- Delete button
### Sub-phase 4e: Send (~1 hour)
"Send for signing" button:
- Validates: ≥1 recipient, ≥1 field, every field has a recipient assigned
- POSTs to `/api/v1/interests/[id]/upload-for-signing` (Phase 3)
- On success, closes dialog and refreshes the Contract/Reservation tab
### Acceptance criteria
- Upload a draft PDF → auto-detect runs → fields appear overlaid in their detected positions
- Rep can drag any field to reposition (state updates, persists to backend on send)
- Rep can change a field's type, recipient, or metadata via side panel
- Rep can add new fields by clicking palette button + clicking on PDF
- Rep can delete fields they don't want
- Click Send → fields ship to Documenso, signing flow starts, Contract tab shows the active doc
---
## Phase 5 — Embedded signing URL emission verification (~1-2 hours)
**Why later**: The Vue page on the marketing website already exists. This phase is a verification + documentation pass, not a code build.
### Tasks
1. **Verify URL transformation matches website expectations**:
- Website route: `/sign/[type]/[token]` where `type ∈ {client, cc, developer}`
- Our `transformSigningUrl()` emits `/sign/<role>/<token>` where role can be `client | developer | approver | witness | other`
- Mismatch: website only handles `client | cc | developer`. Our email service may emit `approver` (which the website doesn't route).
- **Fix**: either (a) update website's `[type].vue` to accept `approver` (and `witness | other` if needed), OR (b) map our role names to the website's expected names in `transformSigningUrl()`.
2. **For contract + reservation document types**: the website's `signerMessages` map only covers EOI-specific copy. When a contract goes out for signing and the recipient hits `portnimara.com/sign/client/<token>`, the page would show "Sign Your Expression of Interest" — wrong copy.
- **Fix**: add document-type to the URL too: `/sign/<docType>/<role>/<token>`. Update website's signerMessages to be keyed on `(docType, role)`.
3. **Webhook callback URL**: website POSTs to `client-portal.portnimara.com/api/webhook/document-signed` after signing. The new CRM is at a different domain. Update website's `handleDocumentSigned` to POST to the new CRM's webhook (a thin "client confirmed sign" notification, separate from Documenso's own webhook).
4. **Apply nginx CORS block** — already documented in [docs/documenso-integration-audit.md](./documenso-integration-audit.md). Apply via ssh when user grants access.
### Acceptance criteria
- Embedded URL points at a working website page that loads the right Documenso embed for any document type / role combo
- Post-sign callback updates our document_signers row (redundant with the Documenso webhook but useful as a real-time UI signal)
---
## Phase 6 — Polish & deferred items (~2-3 hours each, do as needed)
- **`auto` send mode delay**: optional per-port `eoi_send_delay_minutes` setting. When set, the auto-send fires after N minutes (BullMQ scheduled job) so the rep can review + cancel during the window. Default 0 (immediate).
- **Audit log entries**: every Documenso-related action (generate, send, remind, cancel, sign-event-received) writes to `audit_logs` with structured metadata. Mostly already there for the existing flow; extend to cover Phase 1-3 additions.
- **Per-document customization of email copy**: rep can override the default signing-invitation body before send. New textarea in the upload dialog. Stored as `documents.invitation_message`.
- **Document expiration**: Documenso supports `expiresAt`. Surface as a per-document field in the upload dialog.
- **Reminder rate-limit display**: surface "next reminder available in X days" on each unsigned signer in the signing-progress UI.
- **Failed-webhook recovery UI**: admin page showing webhooks that errored, with a "Replay" button. Old system has the foundation; CRM doesn't.
---
## Phase 7 — Project Director role + RBAC layer (~6-8 hours)
> **Surfaced from Q8 conversation.** The `developer` signer slot is conceptually the "Project Director" — the person at the port who countersigns deals on behalf of the port. Today every CRM user is either a sales rep or admin; there's no Project Director user role. Attack alongside the Documenso build because (a) the Documenso developer-label setting is meaningless if no user actually has the role, and (b) a few permissions naturally cluster around it.
### What a Project Director needs (vs sales rep)
| Capability | Sales rep | Project Director | Admin |
| -------------------------------------------------------- | --------- | ---------------- | ----------------------------- |
| Generate EOI / contract / reservation | ✓ | ✓ | ✓ |
| Approve / sign as the "developer" recipient on Documenso | — | ✓ | — (unless also designated PD) |
| View own deals | ✓ | ✓ | ✓ |
| View other reps' deals | — | ✓ | ✓ |
| View audit logs (read-only) | — | ✓ | ✓ |
| Trigger CSV / report exports | — | ✓ | ✓ |
| Re-assign deals between reps | — | ✓ | ✓ |
| Edit per-port settings | — | — | ✓ |
| Manage users + invitations | — | — | ✓ |
| Manage Documenso config | — | — | ✓ |
So Project Director sits between sales rep and admin: read-everywhere + a few action capabilities (re-assign, export, sign-as-PD), but no settings/user management.
### Tasks
1. **Add `project_director` to the role enum** in `src/lib/db/schema/users.ts` (or wherever port_roles enum lives). Existing role values (sales, admin, super_admin) stay; this is additive.
2. **Permission flags**: extend the per-port permissions matrix (`src/lib/auth/permissions.ts` or equivalent) with new flags:
- `viewAllDeals` — true for project_director, admin, super_admin
- `viewAuditLogs` — true for project_director, admin, super_admin
- `exportReports` — true for project_director, admin, super_admin
- `reassignDeals` — true for project_director, admin, super_admin
- `signAsProjectDirector` — true for project_director only (admin can sign as PD only if also assigned the role on this port)
These flags get checked in the relevant API handlers via the existing `withPermission()` middleware.
3. **Documenso developer-slot binding**: per-port admin UI gets a "Project Director user" dropdown alongside the existing developer-name/email free-text inputs. When a real CRM user is selected, the admin UI:
- Populates `documenso_developer_name/email` from the user's profile (read-only when bound)
- When that user signs an EOI/contract via Documenso, the webhook handler can match by user-email and update the in-CRM signing UI in real time (signer chip turns green for them specifically)
- Free-text fallback stays for ports without a CRM-PD user yet
4. **User invitations + role selection**: extend `src/components/admin/invite-user-dialog.tsx` to surface "Project Director" alongside Sales / Admin as a selectable role at invitation time.
5. **Audit-log access**: surface a new `/[portSlug]/admin/audit-log` route (or extend the existing one's permission gate) so Project Directors can read but not write. Hide write controls for non-admins.
6. **Reports page permission gate**: existing `/[portSlug]/reports` (or wherever exports live) checks `exportReports` permission flag instead of admin-only.
7. **Re-assign deals UI**: add a "Re-assign owner" action on the interest detail page, gated by `reassignDeals`. Writes to `interests.owner_user_id` (or whatever the assigned-rep field is) and audit-logs the change.
### Schema migration
```sql
-- Add project_director as a valid role; depends on how roles are stored.
-- If port_roles uses an enum:
ALTER TYPE port_role ADD VALUE 'project_director';
-- Or if it's a text column with check constraint:
ALTER TABLE port_roles DROP CONSTRAINT port_roles_role_check;
ALTER TABLE port_roles ADD CONSTRAINT port_roles_role_check
CHECK (role IN ('sales', 'admin', 'super_admin', 'project_director'));
-- Optional: link the per-port Documenso developer slot to a real user
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS user_id text REFERENCES users(id) ON DELETE SET NULL;
-- (Used for the documenso_developer_user_id setting; null for free-text fallback)
```
### Acceptance criteria
- A user invited as `project_director` can view all deals across the port (not just their own), read audit logs, trigger exports, and re-assign deals — but cannot edit settings or invite users
- Admin can bind a CRM user to the per-port Documenso developer slot; the user's name + email auto-populate in invitations and emails
- Non-PD users cannot trigger PD-only actions (server returns 403; UI hides the controls)
- Existing sales / admin / super_admin permissions are unchanged
### Why attack at the same time as the Documenso build
- Both touch `port-config.ts` and `admin/documenso/page.tsx` — fewer rebases if done in one push
- The `documenso_developer_label` setting (Q8 bonus) and the PD-user binding overlap; doing them together avoids re-touching the same admin card twice
- The Documenso webhook's per-signer matching benefits from having a real `users.email` to bind against, not just a free-text developer name
### Out of scope (defer to a later RBAC pass)
- Custom permission templates (e.g. "PD with no audit-log access")
- Per-deal ACLs (sharing a single interest with another rep)
- Time-bound role grants
- Cross-port role overrides for super_admin
---
## Risks + decisions (resolved through code review)
Each entry below was checked against the current code. The original "open question" form is preserved in italics for traceability; the **Decision** is what the next session should implement.
---
### 1. `fieldMeta` on Documenso v1.32
_Q: Does v1.32 silently ignore unknown properties, or does it reject the request?_
**Decision: not a risk in current code.** [src/lib/services/documenso-client.ts:491-501](../src/lib/services/documenso-client.ts#L491) shows the v1 path constructs its own body containing only `recipientId, type, pageNumber, pageX/Y/Width/Height` — `fieldMeta` is never sent on v1. The code comment at [line 341-344](../src/lib/services/documenso-client.ts#L341) is misleading — update it. Action for next session: change the comment to "v1 does not receive `fieldMeta` (we never send it). v1 renders TEXT/NUMBER/CHECKBOX/DROPDOWN/RADIO as blank inputs; if the per-port admin chose v1 the field UI should warn 'Configurable field types require Documenso v2'." The placement UI in Phase 4d should disable the meta-config side panel when the resolved port is on v1.
### 2. PDF dimension extraction (non-A4 contracts)
_Q: How do we get real page dimensions on the v1 path?_
**Decision: parse the PDF with pdf-lib in the upload service before calling `placeFields()`.** pdf-lib is already a transitive dep via the EOI form-fill flow ([src/lib/pdf/fill-eoi-form.ts](../src/lib/pdf/fill-eoi-form.ts)). Concrete change for Phase 3:
```ts
// In src/lib/services/custom-document-upload.service.ts
import { PDFDocument } from 'pdf-lib';
const pdfDoc = await PDFDocument.load(pdfBuffer);
const pageDims = pdfDoc.getPages().map((p) => {
const { width, height } = p.getSize();
return { width, height };
});
// Pass to placeFields as a per-page dimension map override
```
Then extend `placeFields` signature to accept an optional `pageDimensionsOverride?: DocumensoPageDimensions[]` (one entry per page). When provided, the v1 path uses `pageDimensionsOverride[fieldPageIndex]` instead of [`getPageDimensions()`'s A4 default](../src/lib/services/documenso-client.ts#L427). Falls back to A4 when override is missing — keeps the EOI template path (which IS A4) unchanged.
### 3. Multi-page signature blocks not picked up by auto-detect
_Q: What's the recovery path if the scanner misses a signature block on the last page?_
**Decision: not a risk — by design.** Phase 4d's drag-drop overlay + field palette is the explicit fallback. Auto-detect populates initial state; rep MUST be able to add fields manually. The acceptance criterion at the end of Phase 4 already covers this. Demoted from "risk" to "design note": every page must be reachable in the PDF viewer (Phase 4b's page navigation) and the field palette must be enabled even on auto-detected pages.
### 4. Webhook payload differences v1 vs v2
_Q: Does our webhook handler decode both v1 and v2 payload shapes correctly?_
**Decision: partially confirmed; finish the audit in Phase 2.** Confirmed working today:
- Secret transport: identical (`X-Documenso-Secret` plaintext) — see [route.ts:53](../src/app/api/webhooks/documenso/route.ts#L53)
- Event names: both versions send the uppercase Prisma enum (`DOCUMENT_SIGNED`); CLAUDE.md note documents this. The route also normalizes lowercase-dotted variants for forward-compat.
- Top-level shape `{ event, payload: { id, ... } }`: same on both versions
Still unverified (defer to Phase 2 implementation):
- v2 may rename `payload.id` → `payload.documentId` and `recipient.id` → `recipient.recipientId` (mirrors the API-response rename — see [src/lib/services/documenso-client.ts](../src/lib/services/documenso-client.ts) `normalizeDocument()`). Apply the same dual-field read pattern in the webhook handler: `const docId = payload.documentId ?? payload.id`.
- v2 may include `payload.envelopeId` instead of `payload.id` for envelope-level events (DOCUMENT_COMPLETED). Read both.
- Recipient token field: v1 uses `recipient.token`; v2 may differ. Phase 2's token-based matching (step 5) needs to handle both.
Test with a v2 instance during Phase 2; until then keep the per-port API version setting on v1 only.
### 5. `approver` role → `cc` URL mapping
_Q: How do we keep the website's signing page (which only routes `client | cc | developer`) working when our `SignerRole` includes `approver | witness | other`?_
**Decision: confirmed bug in current code; fix in Phase 5.** [Website route validation](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue#L175) explicitly redirects to `/sign/error` for any `signerType` not in `['client', 'cc', 'developer']`. Our [transformSigningUrl()](../src/lib/services/document-signing-emails.service.ts#L106) emits `${host}/sign/${signerRole}/${token}` with the raw `SignerRole` value. Today, an `approver` invite would land on `/sign/error`.
Concrete fix in `transformSigningUrl()`:
```ts
const ROLE_TO_URL_SEGMENT: Record<SignerRole, 'client' | 'cc' | 'developer'> = {
client: 'client',
developer: 'developer',
approver: 'cc', // legacy: approver showed as "EmbeddedSignatureLinkCC"
witness: 'cc', // route through cc page; copy needs a witness override (Phase 5)
other: 'cc',
};
const urlRole = ROLE_TO_URL_SEGMENT[signerRole];
return `${host}/sign/${urlRole}/${token}`;
```
Two follow-ups for Phase 5:
- Add the mapping above to `transformSigningUrl()` — DO this in Phase 1 already since Phase 1 fires the first invitation email.
- Update website's `signerMessages` (currently EOI-specific) to be keyed on `(documentType, signerType)` so contract+reservation invites get the right copy — see Phase 5 task 2.
### 6. Storage backend for signed PDFs
_Q: Does the on-completion download in Phase 2 use the pluggable storage backend?_
**Decision: confirmed — pattern already established, just follow it.** [`getStorageBackend()`](../src/lib/storage/index.ts) is used by 9 services in the codebase (berth-pdf, brochures, expense-pdf, invoices, gdpr-export, reports, document-templates, document-sends, email-compose). The [`documents` schema](../src/lib/db/schema/documents.ts) already has the `signedFileId` column with index `idx_docs_signed_file_id`. Phase 2 step 4 is just: `const buffer = await downloadSignedPdf(docId, portId); const file = await ingestFile({ buffer, portId, ... }); await db.update(documents).set({ signedFileId: file.id })...`. Demoted from "risk" to "implementation note" inside Phase 2.
### 7. Cross-port webhook secret collision
_Q: Can two ports happen to share the same webhook secret?_
**Decision: real risk — fix at write-time, not schema.** [system_settings](../src/lib/db/schema/system.ts#L137) is unique on `(key, port_id)`, so the same key+port combo is enforced unique, but there's no global uniqueness on the _value_. The [webhook handler](../src/app/api/webhooks/documenso/route.ts#L62) iterates all configured secrets and breaks on first match — if two ports paste the same secret, the second port's webhooks get attributed to the first. Three options, in preference order:
**Option A (recommended): generate, never paste.** Replace the textbox in [admin/documenso/page.tsx](<../src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx>) for `documenso_webhook_secret` with a "Generate secret" button that calls `crypto.randomBytes(32).toString('base64url')` server-side and writes it. Display once, mask after. Collision probability is negligible. Admin still has a "Regenerate" button for rotation.
**Option B: warn at write.** Keep the textbox but on PUT to the setting, query `system_settings WHERE key='documenso_webhook_secret' AND value=?` and fail with a 409 if any other port has this value. Cheap, defensive, but exposes that a value exists somewhere.
**Option C: schema-level enforcement.** Add a partial unique index `CREATE UNIQUE INDEX system_settings_documenso_secret_unique ON system_settings (value) WHERE key = 'documenso_webhook_secret'`. Strongest, but requires careful ordering during port-clone or restore-from-backup operations.
Pick Option A. Add to Phase 1 as a polish item — small change, eliminates the risk class.
---
## Open questions — RESOLVED 2026-05-07
All 10 questions plus the bonus role-label question have user-locked answers. Implementation must follow these decisions; do not re-litigate.
### Q1. Reminder cadence — RESOLVED
**Decision**: **Manual reminders by default.** Rep clicks a "Send reminder" button in the EOI/Contract tab. Per-document opt-in: rep can configure auto-reminders on a specific doc at send time (e.g. "remind every 7 days until signed").
**Implications**:
- No port-wide reminder schedule setting needed.
- Phase 1 / 2: skip the BullMQ scheduled-reminder job for now. Add a `POST /api/v1/documents/[id]/send-reminder` endpoint that calls `sendSigningReminder()` for the next-pending signer. Track `last_reminder_sent_at` to enforce Documenso's 24h rate limit on the UI ("Next reminder available in X").
- Phase 4a (upload dialog): add an optional "Auto-reminder schedule" field — None (default) / Every 3d / Every 7d. When set, store on `documents.auto_reminder_interval_days`; a once-daily worker iterates unsigned documents and fires due reminders.
### Q2. Document expiration — RESOLVED
**Decision**: **Never expire by default.** No expiration UI in v1. Skip Documenso's `expiresAt` entirely.
**Reasoning**: link expiration doesn't help the regenerate flow (regen already voids+recreates). Adding the UI is overhead with no immediate user benefit.
**Implications**:
- Phase 3 `uploadDocumentForSigning`: don't expose `expiresAt`.
- Phase 4a recipient configurator: no expiration field.
- Phase 6 deferred-items list: drop the "Document expiration" item.
### Q3. Auto-detect confidence threshold — RESOLVED
**Decision**: **Default ≥0.8 silent / 0.50.8 flagged / <0.5 drop**, with the drag-drop overlay (Phase 4d) as the universal fix mechanism — rep can reposition or delete any auto-placed field.
**Implications**:
- Phase 4c scanner: emit `DetectedField.confidence`; threshold checks live in the UI layer (Phase 4d) so they're easy to tune.
- Phase 4d overlay: flagged fields render with a yellow border + "?" badge; rep can click to confirm-as-correct (clears the badge) or drag/delete.
### Q4. Approver semantics — RESOLVED
**Decision**: **TWO concepts, not one.**
1. **APPROVER** = real Documenso `APPROVER` recipient. Gates signing flow (e.g. client signs → approver approves → developer signs). Configured per-port (existing `documenso_approver_name/email` settings).
2. **Completion CC** = passive recipient. Does NOT participate in signing. Receives only the final signed PDF as attachment when the doc completes. Set per-document by the rep at send time.
**Implications**:
- Phase 3 `uploadDocumentForSigning` recipients: support `role: 'SIGNER' | 'APPROVER' | 'CC'`. CCs are NOT created as Documenso recipients — they're stored on `documents.completion_cc_emails` (text array) and emailed by our own service when DOCUMENT_COMPLETED webhook fires.
- Phase 4a recipient configurator: split into two sections:
- **Signing recipients**: name + email + role (Signer / Approver) + signing order
- **Copy on completion** (CC): just email addresses, comma-separated
- Phase 2 step 4 (on-completion email distribution): include `documents.completion_cc_emails` recipients with the signed PDF. Dedup by email (see Q5).
- Schema migration: `ALTER TABLE documents ADD COLUMN completion_cc_emails text[] DEFAULT '{}'::text[];`
### Q5. On-completion PDF distribution — RESOLVED
**Decision**: **All signing recipients + rep who generated + per-deal CC**, deduplicated by email address.
**Implications**:
- Phase 2 step 4: build the recipient list as union of (a) all `document_signers` for this doc, (b) the user who created the doc (`documents.createdBy` → `users.email`), (c) `documents.completion_cc_emails`. Lowercase + dedupe before calling `sendSigningCompleted`.
- Common case (rep IS the approver): one email, not two.
- Per-port distribution list (originally proposed) is NOT needed — the per-deal CC field covers it. If a port wants `legal@portnimara.com` on every deal, the rep types it once per doc; if it's truly always-on, add a port-default later (deferred to Phase 6).
### Q6. `documenso_contract_template_id` / `documenso_reservation_template_id` — RESOLVED
**Decision**: **DROP both settings. EOI is the only template-driven flow.** Contracts and reservations are custom-uploaded per deal — no template fallback.
**Implications**:
- Remove `documenso_contract_template_id` and `documenso_reservation_template_id` from `port-config.ts` `SETTING_KEYS` and `PortDocumensoConfig` type.
- Remove the corresponding fields from `admin/documenso/page.tsx`. Card title becomes "Templates" with just the EOI template ID field.
- Phase 3: contract/reservation tabs go straight into the upload dialog — no `if (templateId) { ... }` branch.
- Locked design decisions table at top of this doc: update the "Contract / Reservation generation" row to remove the template-fallback option.
### Q7. Witness role — RESOLVED
**Decision**: **First-class. Configurable per-document at generation time.** Witness goes through the full invitation/reminder/tracking flow same as any other signer; signs the document attesting to having witnessed.
**Implications**:
- Keep `witness` in `SignerRole`.
- Phase 4a recipient configurator: "Witness" is a selectable role in the role dropdown (alongside Signer / Approver / CC).
- Phase 5 website edit: add witness copy to `signerMessages` map ("Witness this signing of…"). Add `witness` to the validated role list at line 175 of `[type]/[token].vue` — currently `['client', 'cc', 'developer']`, becomes `['client', 'cc', 'developer', 'witness']`.
- Risk #5 mapping in `transformSigningUrl()`: `witness → 'witness'` (NOT mapped to `cc`). Update the role-to-URL-segment table accordingly.
- Witness gets the same reminder/auto-reminder support as any signer — no special-casing.
### Q8. Multiple developers/approvers per port — RESOLVED (with rename)
**Decision**: **Stay single per port** for the standard `developer` and `approver` slots. If a port needs more on a custom doc, the rep adds extra signers via the upload-for-signing dialog (Phase 4a recipient configurator).
**Plus the bonus**: the per-port "developer" label IS configurable via a new `documenso_developer_label` setting (default: "Developer"). Used in email subjects, signer chips, and signing-progress UI. Backend type-name stays `developer` so no schema churn.
**Implications**:
- Add `documenso_developer_label` and `documenso_approver_label` to `SETTING_KEYS` + `PortDocumensoConfig`.
- Admin UI in `documenso/page.tsx` Signers card: each signer card gets a "Display label" input next to name/email.
- Email templates in `document-signing.ts`: read the label from the per-port branding config and use it in copy ("Your Project Director, {{name}}, has signed…").
- **Open follow-up (out of scope for Documenso build)**: the user mentioned the project-director user MIGHT need different CRM permissions/access from a sales rep (e.g. exclusive audit-log access, more prominent reports). That's a separate RBAC initiative — note it on the audit backlog and don't action here.
### Q9. Field placement draft persistence — RESOLVED
**Decision**: **No persistence.** If the rep closes the dialog mid-placement, state is lost.
**Implications**:
- Phase 4 architecture: keep all placement state in React component state. No localStorage, no DB drafts table.
- Add a confirm-close on the dialog if the rep has placed any fields ("Discard placement work?").
### Q10. Embedded signing host fallback — RESOLVED
**Decision**: **Send raw Documenso URLs** when host is unset. The Documenso API already returns a working signing URL per recipient (e.g. `https://signatures.portnimara.dev/sign/<token>`); `transformSigningUrl()` returns this raw URL untouched when `embeddedSigningHost` is null/empty (current behaviour, see [document-signing-emails.service.ts:106-117](../src/lib/services/document-signing-emails.service.ts#L106)).
**Implications**:
- Phase 1: no behaviour change in `transformSigningUrl()`. The current null-host short-circuit IS the fallback.
- Add a banner in the EOI/Contract tab when port has unset `embedded_signing_host` and at least one outstanding doc: "Signing emails currently link to signatures.portnimara.dev directly. Configure an embedded host in admin for branded signing pages."
- No new env var. No blocking-on-send.
---
## Schema migration summary (resolved)
Combining all resolved decisions, the migrations needed are:
```sql
-- Phase 1 (also covers Phase 2's lifecycle tracking)
ALTER TABLE document_signers ADD COLUMN invited_at timestamptz;
ALTER TABLE document_signers ADD COLUMN opened_at timestamptz;
ALTER TABLE document_signers ADD COLUMN last_reminder_sent_at timestamptz;
ALTER TABLE document_signers ADD COLUMN signing_token text;
CREATE INDEX idx_ds_signing_token ON document_signers (signing_token);
-- Phase 1 / Q4 (completion CCs are per-document)
ALTER TABLE documents ADD COLUMN completion_cc_emails text[] DEFAULT '{}'::text[];
-- Phase 1 / Q1 (auto-reminder opt-in per document)
ALTER TABLE documents ADD COLUMN auto_reminder_interval_days integer;
```
## Settings to add / remove (resolved)
**Add to `SETTING_KEYS` + `PortDocumensoConfig`:**
- `documenso_developer_label` (text, default "Developer") — Q8 bonus
- `documenso_approver_label` (text, default "Approver") — Q8 bonus
**Remove from `SETTING_KEYS` + `PortDocumensoConfig`:**
- `documenso_contract_template_id` — Q6
- `documenso_reservation_template_id` — Q6
**Remove from admin UI** (`admin/documenso/page.tsx`):
- Contract template ID input — Q6
- Reservation template ID input — Q6
**Add to admin UI:**
- Display-label inputs next to developer + approver name/email pairs — Q8 bonus
---
**Status**: Plan is now fully resolved. Phase 1 can start without further clarification.
---
## Quick file reference
**Existing — modify in place:**
- `src/lib/services/documenso-client.ts` (extend createDocument for v2; add recipient management functions)
- `src/lib/services/port-config.ts` (no changes expected)
- `src/lib/email/index.ts` (consider: add raw-Buffer attachment option to skip MinIO round-trip for one-off PDFs)
- `src/app/api/webhooks/documenso/route.ts` (Phase 2 — major rewrite)
- `src/components/interests/interest-contract-tab.tsx` (replace ComingSoonDialog with UploadForSigningDialog in Phase 4)
- `src/components/interests/interest-reservation-tab.tsx` (same)
- `src/components/documents/eoi-generate-dialog.tsx` (Phase 1 — add regenerate confirm)
**New files to create:**
- `src/lib/services/custom-document-upload.service.ts` (Phase 3)
- `src/lib/services/document-field-detector.ts` (Phase 4c)
- `src/components/documents/upload-for-signing-dialog.tsx` (Phase 4)
- `src/components/documents/pdf-field-canvas.tsx` (Phase 4b/4d)
- `src/components/documents/recipient-configurator.tsx` (Phase 4a)
- `src/components/documents/field-palette-toolbar.tsx` (Phase 4d)
- `src/components/documents/field-config-side-panel.tsx` (Phase 4d)
- `src/app/api/v1/documents/[id]/send-invitation/route.ts` (Phase 1)
- `src/app/api/v1/interests/[id]/upload-for-signing/route.ts` (Phase 3)
- DB migrations for `document_signers.invited_at` etc. (Phase 1, Phase 2)

View File

@@ -0,0 +1,223 @@
# Documenso integration audit
Reference for the multi-port Documenso signing pipeline in this CRM. Mirrors the legacy client portal's flow ([generate-quick-eoi.ts](../client-portal/server/api/eoi/generate-quick-eoi.ts), [documeso.ts](../client-portal/server/utils/documeso.ts), [documenso.post.ts](../client-portal/server/api/webhooks/documenso.post.ts), [website /sign/[type]/[token].vue](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue)) but rewired for multi-tenant + better-auth + Drizzle.
---
## Per-port configuration
All Documenso settings live in `system_settings` keyed by `(key, port_id)` and are read via [`getPortDocumensoConfig(portId)`](../src/lib/services/port-config.ts). Falls back to env vars when no per-port row exists. Surfaced in the admin UI at `/[portSlug]/admin/documenso`.
| Setting key | Type | Purpose |
| ----------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------- |
| `documenso_api_url_override` | string | Per-port Documenso instance URL. Falls back to `DOCUMENSO_API_URL` env. |
| `documenso_api_key_override` | string | API key. Stored plaintext. |
| `documenso_api_version_override` | `'v1' \| 'v2'` | Different ports may run different Documenso versions. |
| `documenso_eoi_template_id` | int | Template ID for EOI generation. |
| `documenso_client_recipient_id` | int | Template recipient slot — client (signing order 1). |
| `documenso_developer_recipient_id` | int | Template recipient slot — developer (signing order 2). |
| `documenso_approval_recipient_id` | int | Template recipient slot — approver (signing order 3). |
| `documenso_developer_name` | string | Display name for developer signer (legacy hardcoded "David Mizrahi"). |
| `documenso_developer_email` | string | Developer signer email. |
| `documenso_approver_name` | string | Approver display name. |
| `documenso_approver_email` | string | Approver email. |
| `documenso_webhook_secret` | string | Per-port webhook secret. Receiver tries each enabled secret with timing-safe equal. |
| `eoi_default_pathway` | `'documenso-template' \| 'inapp'` | Which path is used when EOI is generated without explicit choice. |
| `eoi_send_mode` | `'auto' \| 'manual'` | Auto = send branded invitation email immediately; manual = rep clicks Send. |
| `embedded_signing_host` | string | Public host that wraps Documenso URLs into `{host}/sign/<type>/<token>`. |
| `documenso_contract_template_id` | int (optional) | Optional template for sales contracts. Blank = upload-and-place-fields per deal. |
| `documenso_reservation_template_id` | int (optional) | Optional template for reservation agreements. Same logic as contract. |
---
## Document type matrix
| Type | Generation flow | Signers | Field placement |
| --------------- | ----------------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------- |
| **EOI** | Documenso template (`eoi_template_id`) + form-fill values | Static: client, developer, approver (per-port) | Templated — fields baked into Documenso template |
| **Contract** | Per-deal upload (drafted custom). Template fallback if configured | Custom per deal — rep specifies | Per-deal placement — default footer-anchored fallback |
| **Reservation** | Per-deal upload OR template if configured | Custom per deal | Per-deal placement |
## Documenso field types
Custom-uploaded documents (contracts, reservations) need a per-deal field placement step — different documents need different mixes. The CRM exposes the full Documenso-supported field palette so reps can place whatever the document calls for without code changes.
| Field type | Use case | Needs `fieldMeta`? | What goes in meta |
| ---------------- | ------------------------------------------------------- | ------------------ | --------------------------------------------------- |
| `SIGNATURE` | Drawn signature — almost every signing flow | No | — |
| `FREE_SIGNATURE` | Type-or-draw signature variant | No | — |
| `INITIALS` | Per-page initials block | No | — |
| `DATE` | Auto-fills the date when the recipient signs | No | — |
| `EMAIL` | Auto-fills the recipient's email | No | — |
| `NAME` | Auto-fills the recipient's name | No | — |
| `TEXT` | Free text input (e.g. address, notes, place of signing) | Yes | `{ text?, label?, required?, readOnly? }` |
| `NUMBER` | Numeric input with optional min/max | Yes | `{ numberFormat?, min?, max?, required? }` |
| `CHECKBOX` | Boolean / single checkbox | Yes | `{ values: [{ checked, value }], validationRule? }` |
| `DROPDOWN` | Pick from a fixed list | Yes | `{ values: [{ value }], defaultValue? }` |
| `RADIO` | Mutually-exclusive options | Yes | `{ values: [{ checked, value }] }` |
Helper: [`fieldTypeNeedsMeta(type)`](../src/lib/services/documenso-client.ts) returns true for the configurable types so the placement UI knows when to surface a config side-panel.
`fieldMeta` is forwarded verbatim by [`placeFields()`](../src/lib/services/documenso-client.ts) on the v2 path. v1 silently ignores the property — fields render as blank inputs. Configurable behaviour (validation, defaults) only fires on v2 instances.
---
## Documenso v1 vs v2 endpoint mapping
The [`documenso-client.ts`](../src/lib/services/documenso-client.ts) abstracts both. Each function picks v1 or v2 from `getPortDocumensoConfig(portId).apiVersion`.
| Operation | v1 (1.131.32) | v2.x |
| ------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------- |
| Create document from upload | `POST /api/v1/documents` (body: `{ title, document, recipients }`) | `POST /api/v2/envelope` |
| Generate document from template | `POST /api/v1/templates/{id}/generate-document` | (template-from-envelope path) |
| Send for signing | `POST /api/v1/documents/{id}/send` | `POST /api/v2/envelope/{id}/send` |
| Place a field | `POST /api/v1/documents/{id}/fields` (PIXEL coords, one at a time) | `POST /api/v2/envelope/field/create-many` (PERCENT, bulk) |
| Get document state | `GET /api/v1/documents/{id}` | `GET /api/v2/envelope/{id}` |
| Send reminder to one recipient | `POST /api/v1/documents/{id}/recipients/{rid}/remind` | `POST /api/v2/envelope/{id}/recipient/{rid}/remind` |
| Download finalized PDF | `GET /api/v1/documents/{id}/download``{ downloadUrl }` then GET that URL | `GET /api/v2/envelope/{id}/download` (same shape) |
| Cancel / void | `DELETE /api/v1/documents/{id}` | `DELETE /api/v2/envelope/{id}` |
| Healthcheck | `GET /api/v1/health` | (v1 path used) |
**Field key rename in v2 responses**: `id``documentId` and recipient `id``recipientId`. Our [`normalizeDocument()`](../src/lib/services/documenso-client.ts) handles both shapes.
---
## Signing-flow lifecycle
```
[rep clicks Generate] (CRM)
buildEoiContext(interestId, portId) service
generateAndSign(templateId, ctx, signers) creates Documenso doc
POST /documents/{id}/send {sendEmail:false} Documenso starts the chain;
it does NOT email signers
extract signing URLs from response service
transformSigningUrl(url, host, role) wrap as {host}/sign/<role>/<token>
if eoi_send_mode === 'auto':
sendSigningInvitation(client) our branded HTML email goes out
else:
UI shows the URL + Send button rep dispatches manually
```
When the client signs:
```
Documenso fires DOCUMENT_SIGNED webhook ──► /api/webhooks/documenso
verify x-documenso-secret (per-port lookup)
update document_signers row: status='signed', signedAt=...
if next signer in chain has not been notified:
sendSigningInvitation(developer) cascading "your turn" email
```
When the document reaches fully-signed:
```
Documenso fires DOCUMENT_COMPLETED webhook
download signed PDF from Documenso
store in storage backend → creates files row
update document: status='completed', completedAt=...
sendSigningCompleted([client, developer, approver], pdfFileId)
all parties get the signed PDF
update interest: pipelineStage='eoi_signed' (or contract_signed, etc)
```
---
## Embedded signing on the marketing website
The CRM emits signing URLs in the form `{embeddedSigningHost}/sign/<role>/<token>`. The marketing website ([Port Nimara/Website/pages/sign/[type]/[token].vue](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue)) hosts the page, embeds Documenso via `@documenso/embed-vue`'s `<EmbedSignDocument>`, and POSTs back to the CRM webhook on completion.
For the embed to work, the Documenso instance MUST send `Access-Control-Allow-Origin` headers permitting the website origin.
### nginx CORS block to apply on `signatures.portnimara.dev`
Add to the relevant `server { ... }` block:
```nginx
location / {
# CORS for embedded signing — allow the marketing-website origin
# to load the Documenso signing iframe.
add_header 'Access-Control-Allow-Origin' 'https://portnimara.com' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
# Preflight
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://portnimara.com' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
# ... your existing proxy_pass block to Documenso
}
```
To support multiple website origins (e.g. Port Amador hosted on a different domain), use a regex:
```nginx
set $cors_origin "";
if ($http_origin ~* "^https://(portnimara\.com|portamador\.com)$") {
set $cors_origin $http_origin;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
```
---
## What's deferred vs landed in this build
**Landed:**
- Per-port admin settings — every Documenso config knob is exposed at `/admin/documenso`
- Branded invitation, completion, and reminder email templates
- `transformSigningUrl()` for `{host}/sign/<role>/<token>` URL wrapping
- Documenso v1 + v2 dual-version client (existing)
- Webhook handler with timing-safe per-port secret resolution (existing)
- Contract + Reservation tab UI shells with paper-signed upload + "send for signing" placeholder
- Stage-conditional tab visibility for EOI / Contract / Reservation
**Deferred (separate sessions):**
- Custom document upload-to-Documenso service for contract/reservation (POST PDF → place fields → send). The tabs currently surface a "coming soon" dialog.
- Recipient + signing order configurator UI (rep specifies signers per deal for custom-uploaded docs).
- Drag-and-drop field placement UI on uploaded PDF previews. The fallback when this lands will be `computeDefaultSignatureLayout()` (footer-anchored fields).
- Webhook handler enhancements to track per-signer `sent_at`/`opened_at`/`signed_at` and trigger the cascading "your turn" branded emails. Currently the webhook just updates document status.
- Auto-store signed PDFs in storage backend and trigger `sendSigningCompleted()` on `DOCUMENT_COMPLETED`. Old system has this; needs porting.
**Manual ops work for you:**
- Apply the nginx CORS block above on your prod Documenso instance.
- Decide whether to upgrade prod Documenso to v2 (would unlock cleaner field placement + better envelope semantics).
- Configure each port's developer/approver names and template IDs at `/[portSlug]/admin/documenso`.

View File

@@ -19,18 +19,23 @@ The template exposes eight text fields (`formValues` keys) and two boolean check
## Field mapping ## Field mapping
| Documenso key | Type | Legacy source | New `EoiContext` path | Notes | The legacy template (Documenso template `8`, configured in production) auto-fills exactly the fields below. All eight text fields + two booleans are populated by `buildDocumensoPayload()` from the resolved `EoiContext`. Anything else on the form (signature, date, terms acknowledgment) is filled in by the client inside Documenso.
| -------------- | ------- | --------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------- |
| `Name` | text | `interest['Full Name']` | `context.client.fullName` | The interest's point-of-contact client (billing signer). | | Documenso key | Type | Legacy source | New `EoiContext` path | Notes |
| `Email` | text | `interest['Email Address']` | `context.client.primaryEmail` | Primary email contact from `client_contacts`. | | -------------- | ------- | --------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Address` | text | `interest['Address']` | concat `context.client.address.{street,city,country}` | Concatenate street, city, country with `', '`. Empty if address is null. | | `Name` | text | `interest['Full Name']` | `context.client.fullName` | The interest's point-of-contact client (billing signer). |
| `Yacht Name` | text | `interest['Yacht Name']` | `context.yacht.name` | Yacht is now a first-class row; pulled via `interest.yachtId`. | | `Email` | text | `interest['Email Address']` | `context.client.primaryEmail` | Primary email contact from `client_contacts`. |
| `Length` | text | `interest['Length']` | `context.yacht.lengthFt` | Send as string. Documenso doesn't enforce numeric format. | | `Address` | text | `interest['Address']` | concat `context.client.address.{street,city,country}` | Concatenate street, city, country with `', '`. Empty if address is null. |
| `Width` | text | `interest['Width']` | `context.yacht.widthFt` | Same. | | `Yacht Name` | text | `interest['Yacht Name']` | `context.yacht.name` | Yacht is now a first-class row; pulled via `interest.yachtId`. Empty string when no yacht is linked yet. |
| `Draft` | text | `interest['Depth']` | `context.yacht.draftFt` | Legacy field was named "Depth" in NocoDB; Documenso key is "Draft". | | `Length` | text | `interest['Length']` | `context.yacht.lengthFt` | Boat dimension. Send as string. Documenso doesn't enforce numeric format. Empty string when not applicable. |
| `Berth Number` | text | `berthNumbers` (joined) | `context.berth.mooringNumber` | One berth per reservation. Multi-berth case was multi-interest in legacy. | | `Width` | text | `interest['Width']` | `context.yacht.widthFt` | Same. |
| `Lease_10` | boolean | hardcoded `false` | `false` | Hardcoded — legacy flow defaults to Purchase (not Lease). | | `Draft` | text | `interest['Depth']` | `context.yacht.draftFt` | Legacy field was named "Depth" in NocoDB; Documenso key is "Draft". |
| `Purchase` | boolean | hardcoded `true` | `true` | Hardcoded — legacy flow defaults to Purchase. | | `Berth Number` | text | `berthNumbers` (joined) | `context.berth.mooringNumber` | The interest's PRIMARY berth (resolved via `interest_berths.is_primary=true`). Empty string when no primary set. |
| `Berth Range` | text | (new) | `context.eoiBerthRange` | **NEW IN PHASE 5** — compact range string for multi-berth EOIs (e.g. `"A1-A3, B5-B7"`) covering every junction row marked `is_in_eoi_bundle=true`. Empty string when the bundle is empty. **The live Documenso template (id `8`) does NOT yet have this field. Add a `Berth Range` text field to the template before multi-berth EOIs render the range; until then Documenso silently drops the value and only `Berth Number` (the primary mooring) renders.** |
| `Lease_10` | boolean | hardcoded `false` | `false` | Hardcoded — legacy flow defaults to Purchase (not Lease). |
| `Purchase` | boolean | hardcoded `true` | `true` | Hardcoded — legacy flow defaults to Purchase. |
**Backwards-compatibility guarantee**: every legacy `formValues` key is still emitted with the same name and type. The only addition is `Berth Range` (Phase 5). Documenso silently ignores unknown formValues keys, so old templates that don't have `Berth Range` will simply not render it — single-berth EOIs continue to work identically. No template changes are required for legacy use.
## Document `meta` fields (non-`formValues`) ## Document `meta` fields (non-`formValues`)

188
docs/error-handling.md Normal file
View File

@@ -0,0 +1,188 @@
# Error handling
## Overview
Every authenticated request runs inside an `AsyncLocalStorage` frame
that carries a `requestId` (UUID) plus the resolved `portId` / `userId`
/ HTTP method / path / start time. The id surfaces:
- as `X-Request-Id` on every response header (success or failure)
- inside every pino log line emitted during the request
- in the JSON error body returned to the client (`requestId` field)
- as the primary key of the `error_events` row written when a 5xx fires
A user who hits a failure can copy the **Reference ID** from the toast
and a super admin can paste it into `/<port>/admin/errors/<requestId>`
to see the full request context, sanitized body, error stack, and a
heuristic "likely culprit" hint.
## Throwing errors from a service
Use `CodedError` with a registered code:
```ts
import { CodedError } from '@/lib/errors';
if (!hasReceipts && !ack) {
throw new CodedError('EXPENSES_RECEIPT_REQUIRED');
}
```
The code drives:
- the HTTP status (defined in `src/lib/error-codes.ts`)
- the **plain-text user-facing message** (no jargon — written for the
rep on the phone with a customer)
- the stable identifier the user can quote to support
For more verbose internal context — admin-only — use `internalMessage`:
```ts
throw new CodedError('CROSS_PORT_LINK_REJECTED', {
internalMessage: `interest ${a.id} (port ${a.portId}) ↔ berth ${b.id} (port ${b.portId})`,
});
```
The `internalMessage` lands in the `error_events` row and the admin
inspector but **never** reaches the client.
## Adding a new error code
1. Open `src/lib/error-codes.ts`.
2. Add an entry to the `ERROR_CODES` map. Convention: `DOMAIN_REASON`
in SCREAMING_SNAKE_CASE.
```ts
FOO_INVALID_BAR: {
status: 400,
userMessage: 'That bar value is no good. Please try another.',
},
```
3. Use it: `throw new CodedError('FOO_INVALID_BAR')`.
4. The code, status, and message are now contractually stable —
never rename a code once it has shipped. Documentation, UI, and
external integrations may pin to it.
## Plain-text message guidelines
User-facing messages should:
- Avoid internal jargon (no "constraint violation", "FK", "row lock").
- Be written for a rep on the phone with a customer.
- Include the suggested next action when natural ("Ask an admin if you
think you should").
- Not include any technical detail that doesn't help the user — the
request id + error code carry that.
Verbose technical detail belongs in `internalMessage` (admin-only).
## Client side
In a `useMutation`, render errors with the shared helper:
```ts
import { toastError } from '@/lib/api/toast-error';
const mutation = useMutation({
mutationFn: () => apiFetch('/api/v1/foo', { method: 'POST', body: { ... } }),
onSuccess: () => { ... },
onError: (err) => toastError(err),
});
```
The toast renders three lines:
```
{plain-text message}
Error code: EXPENSES_RECEIPT_REQUIRED
Reference ID: 8f3c-ab12-… [Copy ID]
```
The "Copy ID" action puts the request id on the clipboard so the
user can paste it into a support ticket.
## Admin inspector
`/<port>/admin/errors` lists captured 5xx errors:
- Status badge + method + path
- "Likely culprit" badge (heuristic — Postgres SQLSTATE, error name,
stack-path patterns, message keywords)
- Truncated error name + message
- Timestamp + reference id
Click any row for `/<port>/admin/errors/<requestId>` which shows:
- Request shape (method / path / when / duration / port / user / IP / UA)
- Likely culprit + plain-English hint + subsystem tag
- Full error name, message, stack head (first 4 KB)
- Sanitized request body excerpt (max 1 KB; sensitive keys redacted)
- Raw metadata (Postgres SQLSTATE codes, internalMessage, etc.)
Permission: `admin.view_audit_log`. Super admins see every port's
errors; regular admins are scoped to their active port.
## What gets persisted
| Status | error_events row? | Toast shows code? |
| ------ | ----------------- | ----------------- |
| 4xx | No | Yes |
| 5xx | **Yes** | Yes |
4xx errors are user-action mistakes (validation, not-found, permission
denied). They're visible in the audit log but not the error inspector
— that table is reserved for platform faults.
5xx errors hit the `errorEvents` table via `captureErrorEvent` inside
`errorResponse`, which:
1. Reads the request context from ALS.
2. Sanitizes + truncates the body (1 KB cap, sensitive keys redacted).
3. Pulls Postgres `code` / `severity` / `cause.code` if the underlying
error is a `postgres` driver error.
4. Truncates the stack to 4 KB.
5. Inserts one row keyed on `requestId` with `ON CONFLICT DO NOTHING`.
Failure to persist NEVER throws — the user is already getting an
error response; we don't want a logging-pipeline failure to mask it.
## Likely-culprit classifier
`src/lib/error-classifier.ts` runs four passes against an
`error_events` row, first match wins:
1. **Postgres SQLSTATE** (from `metadata.code`): 23502 NOT NULL,
23503 FK, 23505 unique, 23514 CHECK, 42703 schema drift, 42P01
missing table, 40001 serialization, 53300 connection limit, …
2. **Error class name**: `AbortError`, `TimeoutError`, `FetchError`,
`ZodError`.
3. **Stack path**: `/lib/storage/`, `/lib/email/`, `documenso`,
`openai|claude`, `/queue/workers/`.
4. **Message free-text**: `econnrefused`, `rate limit`, `timeout`,
`unauthorized|invalid api key`.
Returns `null` when nothing matches; the inspector renders
"Uncategorized" in that case. Adding a new heuristic is a one-line
edit to the relevant array.
## Pruning
`error_events` rows are dropped after 90 days by the maintenance
worker (TODO: confirm the worker has the deletion path; if not, add
a periodic job that runs `DELETE FROM error_events WHERE created_at <
now() - interval '90 days'`).
## Migration path for legacy throws
Existing `NotFoundError` / `ForbiddenError` / `ConflictError` /
`ValidationError` / `RateLimitError` still work — the user-facing
messages on these classes have been rewritten to plain-text defaults.
Migration to `CodedError` happens opportunistically: when touching a
service to fix something else, swap the throw site for a registered
code.
A follow-up audit pass should walk `git grep "throw new ValidationError"`
and migrate the user-impactful ones to specific codes.

View File

@@ -0,0 +1,123 @@
# Outbound communications safety net
**Last reviewed:** 2026-05-03
**Owner:** matt@portnimara.com
This doc enumerates every channel through which the CRM can produce
outbound communication (email, document signing, webhooks) and describes
how each channel respects the `EMAIL_REDIRECT_TO` env var. The goal: a
single environment flip pauses **all** outbound traffic, so a production
data import, dedup migration dry-run, or staging environment can run
against real data without anyone getting paged or spammed.
> **Single env switch:** when `EMAIL_REDIRECT_TO` is set to an address,
> all outbound communication is rerouted there or short-circuited. Unset
> it in production.
---
## Channels
### 1. Direct email (`sendEmail`)
**Path:** `src/lib/email/index.ts``sendEmail()` → nodemailer SMTP transport.
**Safety:** YES — covered.
When `EMAIL_REDIRECT_TO` is set, `sendEmail()` rewrites the `to` header
to the redirect address and prefixes the subject with
`[redirected from <orig>]`. The original recipient is logged.
**Call sites** (all flow through `sendEmail`, so all are covered):
- `src/lib/services/portal-auth.service.ts` — portal activation + reset
- `src/lib/services/crm-invite.service.ts` — CRM user invitations
- `src/lib/services/document-templates.ts` — template-generated PDFs sent
as attachments (the PDF body is generated locally; the email itself
goes through SMTP)
- `src/lib/services/email-compose.service.ts` — ad-hoc emails composed
in the in-app UI
- `src/lib/services/gdpr-export.service.ts` — GDPR export delivery
### 2. Documenso e-signature recipients
**Path:** `src/lib/services/documenso-client.ts``createDocument()` /
`generateDocumentFromTemplate()` → Documenso REST API.
**Safety:** YES — covered as of 2026-05-03.
Documenso's own server sends the signing-request email on our behalf.
We can't intercept that at the SMTP layer because it's external. The
fix is at the REST-call boundary: when `EMAIL_REDIRECT_TO` is set,
`createDocument` rewrites every recipient's email to the redirect
address and prefixes the recipient name with `(was: <orig email>)` so
the doc is still traceable to its intended recipient.
`generateDocumentFromTemplate` does the same for both shapes the
template-generate endpoint accepts (v1.13 `formValues.*Email` keys and
v2.x `recipients` array).
The redirect happens **before** the API call, so even if Documenso has
its own retry logic the original email never leaves our process.
### 3. Webhooks (outbound to user-configured URLs)
**Path:** `src/lib/queue/workers/webhooks.ts` → BullMQ job → `fetch(webhook.url, ...)`.
**Safety:** YES — covered as of 2026-05-03.
When `EMAIL_REDIRECT_TO` is set, the webhook worker short-circuits
before the HTTP call. The delivery row is marked `dead_letter` with a
human-readable reason so it's still visible in the deliveries listing.
The SSRF guard remains in place independently.
### 4. WhatsApp / phone deep-links
**Path:** `<a href="https://wa.me/...">` and `<a href="tel:...">` in
client / interest detail headers.
**Safety:** N/A — user-initiated only.
These are deep links the user explicitly clicks. No automated dispatch.
A deep link click opens the user's WhatsApp / phone app, which is the
intended interaction. No safety net needed.
### 5. SMS
Not implemented. The `interests.preferredContactMethod` enum includes
`'sms'` as a value but no sending path exists. If/when SMS is added (e.g.
via Twilio), the new send function should respect `EMAIL_REDIRECT_TO`
the same way `sendEmail` does — log the original number, drop the
message, or reroute to a configurable `SMS_REDIRECT_TO` env.
---
## Verification checklist before importing real data
- [ ] `.env` has `EMAIL_REDIRECT_TO=<my-address>` set.
- [ ] Restart dev server (or worker) so the new env is picked up — env
vars are read at import time in some paths.
- [ ] Send a test email via `pnpm tsx scripts/dev-trigger-portal-invite.ts`
or similar. Confirm subject is prefixed with `[redirected from ...]`.
- [ ] Trigger an EOI send through the UI (any client). Confirm Documenso
shows the redirect address as recipient (not the real client email).
- [ ] If any webhooks are configured, trigger an event that fires one and
confirm the delivery is recorded as `dead_letter` with the
"EMAIL_REDIRECT_TO is set" reason.
- [ ] Run the NocoDB migration `--dry-run` to count clients/interests; the
`--apply` step is what creates real records but emails/webhooks are
still gated by the redirect env.
## Production cutover
When ready to go live:
1. Run a final dry-run of the data migration with `EMAIL_REDIRECT_TO` set
to a sandbox address.
2. Verify the snapshot looks right (counts, client coverage).
3. Unset `EMAIL_REDIRECT_TO` in the production env.
4. Restart the app + worker.
5. Run the migration with `--apply`. From this point forward, real
recipients will receive real comms.
If you ever need to re-pause outbound (e.g. handling a security incident,
re-importing on top of existing data), set `EMAIL_REDIRECT_TO` again.

View File

@@ -0,0 +1,199 @@
# Backup and restore runbook
This runbook documents what gets backed up, how often, where it lands, and
the exact commands to restore the system from a cold start. The goal is
that any operator who has the off-site backup credentials can bring the
CRM back up on a clean host without help.
## Scope of a "full backup"
The CRM has three stateful surfaces. All three must be captured for a
restore to be useful.
| Surface | Holds | Risk if missing |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| **PostgreSQL** (`port_nimara_crm`) | Every relational record: clients, yachts, companies, interests, reservations, invoices, audit log, GDPR exports, AI usage ledger, Documenso webhook receipts, etc. | Total data loss — site is unrecoverable. |
| **MinIO bucket** (`MINIO_BUCKET`, default `crm-files`) | Receipts, signed contracts, EOI PDFs, GDPR export ZIPs, document attachments. | Files reachable by row references in Postgres become 404s. |
| **`.env` + secrets** | DB password, MinIO keys, Documenso webhook secret, SMTP creds, encryption key (`ENCRYPTION_KEY`). | OCR API keys re-resolve from `system_settings` (encrypted at rest), but **without the original `ENCRYPTION_KEY` they're unreadable**. |
The Redis instance is not backed up. It only holds queue state, rate-limit
counters, and Socket.IO presence — all reconstructable. Stop the workers
during a restore so the queue starts clean.
## Backup schedule
Defaults are tuned for a single-port deployment with O(10k) clients. Bump
on the producing side as scale demands.
| Job | Frequency | Retention | Where |
| ---------------------------------- | -------------------- | ----------------------------- | -------------------------------------------------------------------- |
| `pg_dump` (custom format, gzipped) | Hourly | 7 days hourly + 30 days daily | `${BACKUP_BUCKET}/pg/<host>/<UTC date>/<hour>.dump.gz` |
| MinIO mirror | Hourly (incremental) | 30 days versions | `${BACKUP_BUCKET}/minio/` |
| `.env` snapshot (encrypted) | On change (manual) | Forever | Password manager / secrets vault — **never the same bucket as data** |
The hourly cadence is the right answer for this workload — invoices and
contracts cluster around business hours, and an hour of lost work is the
worst-case data loss window most clients will tolerate. Promote to 15-min
WAL streaming if a customer demands tighter RPO.
## Required environment variables
The scripts below read these. Store them in a CI secret store, not the
host's bash profile.
```
# Source (the running CRM database)
DATABASE_URL=postgresql://crm:<pw>@<host>:<port>/port_nimara_crm
# MinIO (source bucket — the live one)
MINIO_ENDPOINT=minio.letsbe.solutions
MINIO_PORT=443
MINIO_USE_SSL=true
MINIO_ACCESS_KEY=<live key>
MINIO_SECRET_KEY=<live secret>
MINIO_BUCKET=crm-files
# Backup destination (a *separate* MinIO/S3 endpoint or a different bucket
# with no IAM overlap with the live keys)
BACKUP_S3_ENDPOINT=https://s3.eu-west-1.amazonaws.com
BACKUP_S3_REGION=eu-west-1
BACKUP_S3_BUCKET=portnimara-backups-prod
BACKUP_S3_ACCESS_KEY=<dedicated read+write key for this bucket only>
BACKUP_S3_SECRET_KEY=<...>
# Optional: encrypts dumps at rest with a passphrase. Cuts a wider blast
# radius if the backup bucket itself is compromised.
BACKUP_GPG_RECIPIENT=ops@portnimara.com
```
## Provisioning the backup destination
1. Create a dedicated S3-compatible bucket in a **different account** from
the live infra. AWS S3, Backblaze B2, or a separately-credentialed
MinIO instance all work.
2. Apply object-lock or versioning so an attacker who steals the backup
write key still can't permanently delete history.
3. Generate IAM credentials scoped to `s3:PutObject`, `s3:GetObject`,
`s3:ListBucket` on this bucket only. Inject them as
`BACKUP_S3_*` above. Do not reuse the live `MINIO_*` keys.
4. Set a 90-day lifecycle rule that transitions objects older than 30
days to cold storage and deletes them at 90 days. Past 90 days it's
cheaper to restart from a snapshot taken outside the system.
## The scripts
Three scripts in `scripts/backup/`:
- `pg-backup.sh` — runs `pg_dump`, gzips, optionally GPG-encrypts, uploads
- `minio-mirror.sh``mc mirror` of the live bucket → backup bucket
- `restore.sh` — interactive restore (DB + MinIO) given a snapshot path
Make them executable and wire them into cron / GitHub Actions / your
scheduler of choice. Sample crontab on the worker host:
```cron
# Hourly DB dump at minute 7
7 * * * * /opt/pncrm/scripts/backup/pg-backup.sh >> /var/log/pncrm-backup.log 2>&1
# Hourly MinIO mirror at minute 17 (offset so the two don't fight for I/O)
17 * * * * /opt/pncrm/scripts/backup/minio-mirror.sh >> /var/log/pncrm-backup.log 2>&1
# Weekly restore drill (smoke-test to a throwaway DB on Sunday at 03:00)
0 3 * * 0 /opt/pncrm/scripts/backup/restore.sh --drill >> /var/log/pncrm-restore-drill.log 2>&1
```
## Restoring from cold
These steps have been rehearsed against the dev environment; expect them
to take 1530 minutes for a typical port. **The drill (last cron line
above) ensures the runbook stays correct — if the drill fails, the
real restore will too.**
### 0. Stop everything that writes
```bash
docker compose -f docker-compose.prod.yml stop web worker scheduler
# Leave postgres + minio + redis up; we'll point them at restored data.
```
### 1. Restore PostgreSQL
```bash
# Find the dump you want. Prefer the most recent successful hour.
mc ls "$BACKUP_S3_BUCKET/pg/$(hostname)/" | tail
SNAPSHOT="2026-04-28/14.dump.gz"
# Pull it.
mc cp "$BACKUP_S3_BUCKET/pg/$(hostname)/$SNAPSHOT" /tmp/
# Decrypt if BACKUP_GPG_RECIPIENT was set on the producer side.
gpg --decrypt /tmp/14.dump.gz.gpg > /tmp/14.dump.gz
# Drop & recreate the database. The 'restrict' FK from gdpr_exports.requested_by
# to user means we restore in the right order — pg_restore handles this.
psql "$DATABASE_URL" -c 'DROP DATABASE IF EXISTS port_nimara_crm WITH (FORCE);'
psql "$DATABASE_URL" -c 'CREATE DATABASE port_nimara_crm;'
gunzip -c /tmp/14.dump.gz | pg_restore --no-owner --no-privileges \
--dbname "$DATABASE_URL"
```
### 2. Restore MinIO
```bash
# Sync the backup bucket back over the live one. --overwrite handles
# files that were modified between snapshots.
mc mirror --overwrite \
"$BACKUP_S3_BUCKET/minio/" \
"live/$MINIO_BUCKET/"
```
### 3. Restore secrets
The `.env` file is **not** in object storage. Pull it from the password
manager / secrets vault. Verify `ENCRYPTION_KEY` matches the value used
when the database was last running — if it doesn't, rows in
`system_settings` (OCR API keys, etc.) decrypt to garbage and the OCR
"Test connection" button will return an opaque error. There is no
recovery path; the keys must be re-entered through the admin UI.
### 4. Bring services back up
```bash
docker compose -f docker-compose.prod.yml up -d
# Watch the worker logs; expect a flurry of socket reconnections, then quiet.
docker compose -f docker-compose.prod.yml logs -f worker
```
### 5. Verify
Tail through the smoke checklist, in order:
1. **DB up**`psql "$DATABASE_URL" -c 'SELECT count(*) FROM clients;'`
matches the producer-side count from the snapshot's hour.
2. **MinIO up** — open any client with attachments in the CRM, click a
receipt thumbnail; verify the signed URL serves the file.
3. **Documenso webhooks** — re-trigger one in the Documenso admin and
confirm `audit_logs` records the receipt.
4. **Email** — send a portal invite to a real address.
5. **Realtime** — open two browser windows, edit a client in one, watch
the other update via Socket.IO.
6. **AI usage ledger**`SELECT count(*) FROM ai_usage_ledger;`
non-empty if AI was being used. Old rows survive but the budget gates
reset alongside the period boundary at month rollover.
## Drill schedule
The weekly drill (cron line above) runs `restore.sh --drill` against a
throwaway database and a sandbox MinIO bucket. It must produce zero diff
between the restored row counts and the live row counts (modulo the
hour-or-so the drill takes to run).
Failure modes the drill catches before they bite production:
- New tables added without inclusion in `pg_dump`'s `--schema=public` (we
use the default, which captures everything in `public` — but a future
developer adding a `tenant_X` schema will silently lose it).
- MinIO bucket-policy changes that block the backup-side `s3:GetObject`
on certain prefixes.
- GPG passphrase rotation that wasn't propagated to the restore host.
- A `pg_restore` version skew with the producer-side `pg_dump`.

View File

@@ -0,0 +1,186 @@
# Email deliverability runbook
The CRM sends transactional email through three different surfaces. Each
has a different failure mode when it lands in spam. This runbook covers
how to diagnose, fix, and verify each path.
## What email the CRM sends
| Surface | Trigger | Template | Default `from` |
| ----------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------- |
| Portal activation / password-reset | Admin invites a client to the portal | `src/lib/email/templates/portal-auth.ts` | per-port `email_settings.from_address` or `SMTP_FROM` |
| Inquiry confirmation + sales notification | Public website POSTs to `/api/public/interests` or `/api/public/residential-inquiries` | `inquiry-client-confirmation.ts`, `inquiry-sales-notification.ts` | same |
| GDPR export ready | Staff requests an export with `emailToClient=true` | inline in `gdpr-export.service.ts` | same |
| Documenso reminders | Cadence job fires for an unsigned signer | `documenso/reminders/*` | same |
Documenso _itself_ sends signing requests with its own `from` address —
those don't flow through this codebase. SPF/DKIM for the Documenso
sender is the Documenso operator's problem, not yours.
## DNS records
For every domain that appears in a `from:` header you must publish:
### 1. SPF
A single TXT record at the apex authorizing whichever provider is
sending. Multiple SPF records on the same name **break SPF entirely**
combine into one.
```
v=spf1 include:_spf.google.com include:amazonses.com -all
```
The `-all` (hardfail) is correct for transactional mail. Switch to `~all`
(softfail) only as a temporary diagnostic when migrating providers.
### 2. DKIM
Each provider publishes its own selector. Common shapes:
- Google Workspace: `google._domainkey` → 2048-bit RSA pubkey (rotate every 12 months).
- Amazon SES: `xxxx._domainkey`, `yyyy._domainkey`, `zzzz._domainkey` (three CNAMEs SES gives you).
- Postmark / Resend / Mailgun: one CNAME per selector.
Verify alignment — the `d=` value in the DKIM signature must match the
`From:` domain (relaxed alignment is fine, strict is overkill).
### 3. DMARC
Start at `p=none` while you build deliverability data, then upgrade.
```
_dmarc 14400 IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@portnimara.com; ruf=mailto:dmarc@portnimara.com; fo=1; adkim=r; aspf=r; pct=100"
```
`rua` (aggregate reports) is the diagnostic feed — set it before the
first send so the first weekly report has data.
### 4. MX (only if you also receive)
The CRM's IMAP probe (`scripts/dev-imap-probe.ts`) and the inbound thread
sync rely on a real mailbox. Whoever runs that mailbox publishes the MX
records — typically Google Workspace or a dedicated provider. Don't add
an MX pointing at the CRM host; it doesn't accept SMTP IN.
## Per-port overrides
Each port can override `from_address`, `from_name`, and SMTP creds via
the admin email-settings page. When set, `getPortEmailConfig()` returns
those values and `sendEmail()` uses them in preference to the global
`SMTP_*` env. **The override domain still needs SPF / DKIM / DMARC** on
its own DNS — without them, every send from that port lands in spam.
When a customer reports "our portal invite didn't arrive":
1. Pull the port's email settings from the admin UI. Check `from_address`.
2. Run `dig TXT <from-domain>` and `dig TXT _dmarc.<from-domain>`.
Confirm SPF includes the SMTP provider's domain and DMARC exists.
3. Send a probe through `mail-tester.com`: paste the address into a
test send, click the score breakdown.
4. Score < 8/10 → fix whatever's flagged before doing anything else in
this runbook.
## Diagnosing a "didn't arrive" report
Order matters — go top-down, stop when one of these is the answer.
### Step 1: Was the send attempted?
```bash
# Tail the worker logs for the recipient address.
docker compose logs worker | grep '<recipient>'
```
You'll see one of three patterns:
- **Nothing**: The job didn't run. Check that BullMQ actually queued it.
`redis-cli LLEN bull:email:waiting` — if non-zero, the worker is dead.
`docker compose logs scheduler | tail` to see why.
- **`Email sent`** with a message-id: The provider accepted it. Move to
Step 2.
- **`SendError`**: Provider rejected. The error string says why
(auth, rate limit, blocked recipient).
### Step 2: Is `EMAIL_REDIRECT_TO` set?
In dev/test we set `EMAIL_REDIRECT_TO=ops@portnimara.com` so seeded fake
clients don't get real email. **It must be unset in production.**
```bash
# On the production host:
docker exec pncrm-web printenv EMAIL_REDIRECT_TO
# Should print nothing.
```
If it's set, every email is going to the redirect target with the
original recipient prefixed in the subject — the customer never sees it.
### Step 3: Did it land but get filtered?
Ask the recipient to check:
- Spam / Junk folder
- Gmail "Promotions" tab
- Outlook "Other" folder (vs Focused)
- The Quarantine console if they're on M365 with anti-spam enabled
If found in a spam folder: the email arrived; the recipient's filter
classified it. SPF/DKIM/DMARC alignment is suspect — re-run the
mail-tester probe from above.
### Step 4: Was the recipient on a suppression list?
Some providers (SES, Postmark) maintain a suppression list — once a
domain bounces from an address, future sends are dropped silently.
```bash
# SES example:
aws ses list-suppressed-destinations --region eu-west-1
```
If the recipient is suppressed, remove them and ask them to retry. The
CRM doesn't track suppression locally; that's the provider's job.
## When migrating SMTP providers
1. Add the new provider's DKIM CNAMEs alongside the old ones.
2. Add the new provider's `include:` to the existing SPF record.
3. Wait 48 hours for DNS to propagate and DMARC reports to confirm both
providers align.
4. Switch `SMTP_*` env to the new provider on a single staging host.
5. Send through the staging host for a week. Watch DMARC reports.
6. Cut production over.
7. Wait two weeks before removing the old provider's DNS — undelivered
bounce reports keep arriving for a while.
## Testing a deliverability fix
There's no automated test for "did this email reach the inbox" — that's a
property of the recipient's filter, which we don't control. The closest
proxy is the realapi suite:
```bash
pnpm exec playwright test --project=realapi
```
It runs `tests/e2e/realapi/portal-imap-activation.spec.ts` which sends a
real portal-invite email through SMTP, then polls the configured IMAP
mailbox for the activation link. If it appears within 30 seconds, the
SMTP→DKIM→DMARC chain is alive end-to-end. If the test times out, work
backwards through this runbook.
The realapi suite needs `SMTP_*` and `IMAP_*` env vars — see the
"Optional dev/test-only env vars" block in `CLAUDE.md`.
## Bounce handling
The CRM doesn't currently process bounces. If you start seeing volume:
- Set up the provider's webhook (SES → SNS → Lambda; Postmark → webhook
URL) to POST bounce events to a new `/api/webhooks/email-bounce` route.
- Persist the bounced address into a `email_suppressions` table.
- Have `sendEmail()` consult that table before each send.
That work isn't in scope yet; this runbook just flags it as the next
deliverability gap.

View File

@@ -0,0 +1,56 @@
# Permission Matrix Audit
Scanned 182 route files under `src/app/api/v1/`.
**No violations.** Every internal v1 handler is permission-gated.
**Allow-listed:** 46 handler(s) intentionally skip `withPermission`.
| File | Method | Reason |
| ---------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------- |
| `src/app/api/v1/admin/alerts/run-engine/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/connections/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/errors/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/health/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/ocr-settings/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/ocr-settings/route.ts` | PUT | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/ocr-settings/test/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/queues/[queueName]/[jobId]/retry/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/queues/[queueName]/[jobId]/route.ts` | DELETE | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/queues/[queueName]/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/queues/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/admin/users/options/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
| `src/app/api/v1/ai/email-draft/[jobId]/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
| `src/app/api/v1/ai/email-draft/route.ts` | POST | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
| `src/app/api/v1/ai/interest-score/bulk/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
| `src/app/api/v1/ai/interest-score/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
| `src/app/api/v1/alerts/[id]/acknowledge/route.ts` | POST | Alerts are user-scoped; port-filtered via auth context. |
| `src/app/api/v1/alerts/[id]/dismiss/route.ts` | POST | Alerts are user-scoped; port-filtered via auth context. |
| `src/app/api/v1/alerts/count/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. |
| `src/app/api/v1/alerts/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. |
| `src/app/api/v1/berth-reservations/[id]/route.ts` | PATCH | TODO: PATCH should map to reservations:edit (not currently in catalog). |
| `src/app/api/v1/currency/convert/route.ts` | POST | Currency reference data; port-scoped, no PII. |
| `src/app/api/v1/currency/rates/refresh/route.ts` | POST | TODO: gate with admin:manage_settings — currently allow-listed. |
| `src/app/api/v1/currency/rates/route.ts` | GET | Currency reference data; port-scoped, no PII. |
| `src/app/api/v1/custom-fields/[entityId]/route.ts` | GET | TODO: needs custom_fields:\* permission. PUT path internally validated. |
| `src/app/api/v1/custom-fields/[entityId]/route.ts` | PUT | TODO: needs custom_fields:\* permission. PUT path internally validated. |
| `src/app/api/v1/expenses/export/parent-company/route.ts` | POST | Internally gated by isSuperAdmin inside the handler. |
| `src/app/api/v1/me/route.ts` | GET | Self-endpoint — auth is sufficient. |
| `src/app/api/v1/me/route.ts` | PATCH | Self-endpoint — auth is sufficient. |
| `src/app/api/v1/notifications/[notificationId]/route.ts` | PATCH | User-scoped notifications — caller is the resource owner. |
| `src/app/api/v1/notifications/preferences/route.ts` | GET | User-scoped notifications — caller is the resource owner. |
| `src/app/api/v1/notifications/preferences/route.ts` | PUT | User-scoped notifications — caller is the resource owner. |
| `src/app/api/v1/notifications/read-all/route.ts` | POST | User-scoped notifications — caller is the resource owner. |
| `src/app/api/v1/notifications/route.ts` | GET | User-scoped notifications — caller is the resource owner. |
| `src/app/api/v1/notifications/unread-count/route.ts` | GET | User-scoped notifications — caller is the resource owner. |
| `src/app/api/v1/saved-views/[id]/route.ts` | PATCH | User-self saved views — caller is the resource owner. |
| `src/app/api/v1/saved-views/[id]/route.ts` | DELETE | User-self saved views — caller is the resource owner. |
| `src/app/api/v1/saved-views/route.ts` | GET | User-self saved views — caller is the resource owner. |
| `src/app/api/v1/saved-views/route.ts` | POST | User-self saved views — caller is the resource owner. |
| `src/app/api/v1/search/recent/route.ts` | GET | Port-scoped search — results filtered by auth context (resources have own perms). |
| `src/app/api/v1/search/route.ts` | GET | Port-scoped search — results filtered by auth context (resources have own perms). |
| `src/app/api/v1/settings/feature-flag/route.ts` | GET | Public read of feature-flag bool — no PII; auth is sufficient. |
| `src/app/api/v1/tags/options/route.ts` | GET | Tags are cross-cutting reference data; port-scoped via auth. |
| `src/app/api/v1/tags/route.ts` | GET | Tags are cross-cutting reference data; port-scoped via auth. |
| `src/app/api/v1/users/me/preferences/route.ts` | GET | User-self preferences — caller is the resource owner. |
| `src/app/api/v1/users/me/preferences/route.ts` | PATCH | User-self preferences — caller is the resource owner. |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,376 @@
# Google Workspace inbox-triage integration (exploratory)
**Status:** Exploratory — not approved for build
**Date:** 2026-04-29
**Tracks:** AI inbox-triage, Google Workspace email connection
## What this spec is for
The user has flagged inbox-triage as the most valuable AI surface left to
build, but conditioned email integration on it being via Google Workspace
specifically (not generic IMAP), with a per-port toggle so clients who
don't use GWS aren't billed for capability they can't reach.
This document captures what that build actually costs — especially on
the Google side, which is where most teams underestimate the work — so
we can decide whether to commit before writing any code. **Nothing in
this spec is approved for implementation.** The deliverable is a go /
no-go decision and, if go, a scope choice between three deployment
models that cost wildly different amounts of calendar time.
## What inbox-triage actually does for the user
Concretely, on the staff member's desktop:
1. **Linked-inbox panel on the client detail page.** When you open
`/[port]/clients/<id>` you see the last N email threads with that
client, pulled from the staff member's own Gmail. Each thread has
the latest message preview, an "open in Gmail" deep-link, and a
"draft reply" button (Phase 2+).
2. **Inbox triage queue.** A new top-level page `/[port]/inbox` that
lists unread/unanswered threads ranked by AI-assessed importance
(high-value client, contractual urgency, chase-overdue). Each row
has one-click actions: "log this as a note on the client",
"create a follow-up reminder", "draft reply".
3. **Email-driven alerts.** When a high-value client emails and no one
responds within X hours, the existing alerts engine fires a
`inbox.unanswered_high_value` rule (slots into the alert framework
from Phase B without schema change).
4. **Reply drafts (Phase 3).** AI generates a reply draft grounded in
the client's CRM record (open interests, pending reservations,
recent invoices). Staff edit and send through Gmail.
The value is selective: a port with three staff members fielding 50
client emails a day saves maybe an hour a day collectively if the
ranking is right. Below that volume the build doesn't pay back.
## What already exists in the codebase
The CRM is roughly halfway scaffolded for this:
| Surface | Status | Notes |
| ----------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `email_accounts` table | ✅ Exists | Has `provider: 'google' \| 'outlook' \| 'custom'` discriminator and `imap_*` / `smtp_*` cols. Built for IMAP, not OAuth. |
| `email_threads` / `email_messages` tables | ✅ Exists | Already linked to `clientId`. Schema is good as-is for Gmail. |
| `email-threads.service.ts` `syncInbox()` | ⚠ Stub-ish | IMAP-flow only. Won't reach Gmail without OAuth + Gmail API rewrite. |
| `email` BullMQ queue + `inbox-sync` job name | ✅ Exists | Worker dispatches on the job name; new sync impl drops in. |
| `google_calendar_tokens` table | ✅ Exists | OAuth token storage shape we can mirror for Gmail. |
| Per-port email override (port `email_settings`) | ✅ Exists | Used for outbound only today; Gmail integration is per-staff-user, not per-port. |
| `ai_usage_ledger` + per-port `aiEnabled` flag | ✅ Exists (Phase 3a/3b) | Triage AI calls book against the same ledger. |
| `withRateLimit('ai', ...)` wrapper | ✅ Exists (Phase 3c) | Caps triage AI traffic at 60/min/user out of the box. |
Net: schemas are mostly right. The OAuth flow, Gmail API client, push
notification receiver, and triage classifier are the new builds.
## Why Google Workspace specifically
The user's stated constraint: "I don't think we need email integration
unless we connect it to Google Workspace." Reasons that hold up:
- **No password storage.** OAuth tokens are revocable, scoped, and
rotate. IMAP requires app passwords, which Google has been actively
deprecating since 2024 — they'll be gone for the workspace plans
this product targets.
- **Push notifications, not polling.** Gmail's `users.watch` API plus
Google Pub/Sub means we get an HTTP callback within seconds of a new
message landing. IMAP requires polling on a 30-60 second cadence,
which costs more and lags worse.
- **Search and labels.** The Gmail API exposes label management and
full-text search natively; IMAP search is much weaker.
- **Threading.** Gmail's `threadId` is canonical. Reconstructing
threads over IMAP from `In-Reply-To` / `References` headers is
reliable in theory, painful in practice.
Microsoft 365 is the obvious peer integration but is out of scope here.
The Graph API model is similar enough that a future M365 path can reuse
most of the storage shape.
## Three deployment models — pick one before building
This is the most important decision in the spec. Each model has
different OAuth-verification consequences, which dominate everything
else.
### Model A — Marketplace-published OAuth app
A single OAuth client owned by Port Nimara, listed in the Google
Workspace Marketplace, that any GWS customer can install. Each staff
member clicks "Connect Gmail," consents to the scopes, and the CRM
stores their refresh token.
**Google-side work:**
1. Build the OAuth flow in CRM (~1 week).
2. Submit for OAuth verification. Gmail's `gmail.readonly` /
`gmail.modify` scopes are **restricted scopes** — they require:
- Domain-verified production URLs
- A homepage with a privacy policy that explicitly enumerates which
scopes are used and why
- A demo video (literally a screen recording) showing the consent
screen and what happens next
- **A third-party security assessment from a Google-approved
vendor** ($15k$75k, 612 weeks)
- A Cloud Application Security Assessment (CASA) report
3. Marketplace listing review (~2 weeks after CASA passes).
**Calendar time:** 46 months.
**Money:** $15k$75k for the security assessment alone.
**Recurring:** Re-verification every 12 months.
Right answer if Port Nimara wants to be the marina-CRM that ships GWS
out of the box for _any_ customer. Wrong answer if there are <5
customers who'd use it.
### Model B — Per-customer "Internal" OAuth app
Each customer's GWS admin creates an OAuth client _inside their own
workspace_ and gives Port Nimara the client ID + secret. Because the
app is "Internal," Google skips verification entirely — the consent
screen is unverified-but-permitted. Tokens never cross workspace
boundaries.
**Google-side work per customer:**
1. Customer's GWS admin enables the Gmail API in their Cloud project.
2. Creates an OAuth 2.0 client ID with type "Internal" + your CRM's
redirect URI.
3. Hands the client ID + secret to Port Nimara out-of-band.
4. Staff connect their Gmail through that client.
**Calendar time per customer:** ~1 hour of admin work.
**Money:** $0.
**Limit:** Doesn't span across GWS workspaces. A user with two GWS
accounts (e.g. the marina + a personal workspace) can only connect the
one matching the OAuth client.
This is the **clear winner for the current customer base**: small
number of customers, each with their own GWS workspace, and each
buying the integration as part of an onboarding conversation.
### Model C — Forward-to-CRM mailbox
The CRM exposes a per-port email alias (e.g.
`port-nimara-NN@inbox.portnimara.com`). Customers configure a Gmail
filter or mailing rule that BCCs that alias on relevant threads. The
CRM ingests via SMTP and runs the same triage pipeline.
**Google-side work:** None. Customer does it as a Gmail filter.
**Calendar time:** ~1 week of CRM-side build.
**Limit:** Receive-only — no reply drafts, no thread state changes,
no labels. The "draft reply" feature in Phase 3 above is impossible
under this model.
Model C is the right answer if the user wants to ship inbox-triage
_now_ and decide on bidirectional Gmail integration later. The schema
is designed so the model can be upgraded to A or B without data
migration.
### Recommendation
**Build Model B first.** It costs nothing on the Google side, takes
~3 weeks of CRM work, and matches the actual customer profile.
**Promote to Model A only after 3+ paying customers ask for it
unprompted.** Until then, the security-assessment cost can't justify
itself.
Model C as a fallback for customers who refuse to set up an Internal
OAuth app. Build it last, lazily — the schema accommodates it.
## End-to-end flow (Model B)
### 1. Per-port OAuth-app config
New admin page `/[port]/admin/google-workspace`:
- Field: "OAuth client ID" (their internal client ID)
- Field: "OAuth client secret" (encrypted at rest using `ENCRYPTION_KEY`)
- Field: "Authorized redirect URI" (read-only; we display the value
they need to paste into their Google Cloud Console)
- Toggle: "Enable Gmail integration for this port"
Stored in `system_settings` under key `gws.config`, port-scoped.
Resolution mirrors the existing OCR config service.
### 2. Per-staff connect flow
Staff member visits `/[port]/me/integrations`, clicks "Connect Gmail."
```
GET /api/v1/auth/gws/start
→ looks up port's gws.config
→ builds Google authorize URL with port's client_id + state token
→ 302 to Google
[ user consents ]
→ 302 back to /api/v1/auth/gws/callback?code=…&state=…
→ exchanges code for tokens via port's client_secret
→ stores in new `gws_user_tokens` table (encrypted)
→ schedules an `inbox-watch` job
```
### 3. Push notification subscription
After tokens are stored, the worker calls
`gmail.users.watch({ topicName: <Pub/Sub topic>, labelIds: ['INBOX'] })`.
Gmail then posts to a Pub/Sub topic on every inbox change. The CRM
exposes a Pub/Sub push subscription endpoint at
`/api/webhooks/gmail-push` which fetches the changed messages via the
delta `historyId` and writes them into `email_messages`.
Watch subscriptions expire every 7 days. A maintenance job
re-establishes them daily.
### 4. Triage pipeline
For each new inbound message:
1. Match against `clients` and `companies` by `from_address` against
`client_contacts` (email channel). Persist a thread→client link if
found.
2. If port has `aiEnabled` AND `gws.triageEnabled`, queue an `ai`
job that classifies the thread:
- `urgency`: low / medium / high
- `category`: invoice-question / availability / contract / other
- `requires_response`: boolean
3. AI call records into `ai_usage_ledger` with `feature='inbox_triage'`.
The existing per-port budget gates apply automatically.
4. Triage output written to a new `email_triage` table keyed on
`email_messages.id`.
### 5. UI surfaces
- `/[port]/inbox` — sorted by triage rank, port-wide view.
- Linked-inbox panel on `client-tabs.tsx` — adds a new "Email" tab
pulling from `email_threads` filtered to that client.
- Alert rule `inbox.unanswered_high_value` slots into Phase B's
alert engine; no schema change.
## Schema additions
Three new tables, all port-scoped where it matters:
```ts
// Per-staff Gmail tokens. Mirror of google_calendar_tokens.
gws_user_tokens {
id, userId (UNIQUE), portId, emailAddress,
accessTokenEnc, refreshTokenEnc, tokenExpiry,
scope, watchExpiresAt, watchHistoryId,
connectedAt, lastSyncAt, syncEnabled, createdAt, updatedAt
}
// Triage classifications keyed to messages.
email_triage {
messageId (PK, FK email_messages.id ON DELETE CASCADE),
urgency, category, requiresResponse,
modelVersion, tokensUsed, classifiedAt
}
// Pub/Sub idempotency log. Gmail re-delivers; we dedupe.
gws_push_log {
messageId (Pub/Sub message id, PK),
historyId, receivedAt
}
```
Plus extensions to `email_messages`:
- `googleMessageId` (text, indexed) — Gmail's own ID for thread ops.
- `googleThreadId` (text, indexed).
- `gmailLabels` (text[]) — for "is unread" checks without hitting Gmail.
The existing `emailAccounts.provider='google'` column repurposes
unchanged; the IMAP fields go nullable since OAuth-flow accounts won't
populate them.
## AI cost interaction
Triage AI is opt-in **twice**: the port admin must turn on
`aiEnabled` (Phase 3a flag, default off) **and** `gws.triageEnabled`
(this spec, default off). Either toggle off and the inbox sync still
runs but skips classification, so staff can manually scan threads
without burning tokens.
Per-message token cost on a current Haiku-class model is roughly
15002500 tokens including the system prompt. A port doing 200 inbound
emails a day at the upper bound is ~500k tokens/day. The default
hard-cap is 500k/month, so triage will trip it inside a day. Two
mitigations baked in:
- The system prompt is short (<500 tokens) and prompt-cached on the
Anthropic side, so most tokens are output.
- Triage runs only on threads not already classified — re-syncs from
the watch loop don't re-bill.
The admin UI shows triage as its own line in the per-feature breakdown
so customers can see how much their inbox is costing them and tune
caps accordingly.
## Phased build (assuming Model B)
| Phase | Scope | Effort | Ships when |
| ---------------------------- | ------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------- |
| **G1** Connect | OAuth flow + per-port config + per-user token storage. No sync yet. Staff can connect; nothing happens. | 1 week | Standalone |
| **G2** Read-only sync | Pub/Sub push receiver + delta sync into `email_messages`. Linked-inbox tab on client detail. No AI. | 1 week | After G1 |
| **G3** Triage classification | AI classifier, `email_triage` writes, `/inbox` page sorting. Per-port toggle. | 1 week | After G2; depends on Phase 3b budgets being live (they are) |
| **G4** Reply drafts | Gmail API send + draft creation. "Draft reply" button on the client detail Email tab. | 1 week | After G3 |
| **G5** Alerts | New `inbox.unanswered_high_value` rule. Hooks into Phase B alert engine. | 2 days | After G3 |
Total: ~5 weeks for a single engineer, assuming the user provides one
real GWS workspace to test against during G1.
## Open decisions for the user
These are the questions to resolve before scheduling the build, in
priority order:
1. **Deployment model — A, B, or C?** Default recommendation B.
2. **Single user or domain-wide delegation?** Per-staff connect (one
token per user) is simpler. Domain-wide delegation lets the port
admin connect once on behalf of every staff member but requires
the customer to grant a service account broader access. Default
recommendation: per-staff.
3. **Scope set.** Minimal viable scope is `gmail.readonly`. To send
replies (G4) we need `gmail.send`. To manage labels (e.g. mark
"triaged-by-CRM") we need `gmail.modify`. Each scope expansion
widens the consent screen scariness but doesn't add new
verification steps under Model B.
4. **Pub/Sub topic ownership.** Pub/Sub topics live in _some_ GCP
project. Under Model B the customer's project owns the topic —
they pay for Pub/Sub (cents/month) and grant our service account
subscriber access. Alternative: Port Nimara owns the topic and
the customer's Gmail publishes cross-project (allowed, slightly
more setup). Default: customer-owned topic, fewer moving parts.
5. **Triage model.** Haiku 4.5 is right for cost; Sonnet 4.6 is
right if the ranking quality on Haiku turns out to be poor.
Defer this until G3 has real-world tuning data.
## Things that are NOT in this spec
- **Microsoft 365 / Outlook integration.** Same shape, different API.
Once Model B is proven on GWS, Graph API takes another ~3 weeks.
- **Reply drafts grounded in CRM context.** That's G4 and depends on
the work in this spec, but the prompt engineering for "good replies
citing this client's open interests + reservations + invoices"
deserves its own design pass before building.
- **Cross-staff triage queue (i.e. "show me all unanswered emails
across the team").** That requires either domain-wide delegation
(decision #2 above) or per-staff opt-in to a shared view. Punt
until staff actually ask for it.
- **Sentiment / urgency tone analysis.** Tempting; almost always
wrong; skip in v1.
- **"Smart drafts" using the recipient's past replies as context.**
Every customer asks for this and almost no one uses it once
built. Skip.
## Cost summary at a glance
| Item | Model A | Model B | Model C |
| ------------------------------- | ------------------------------- | -------------------------------------- | ------------------------------------ |
| Build effort | 34 weeks | ~5 weeks (over G1G5) | ~1 week (receive-only) |
| Calendar time to first customer | 46 months | 1 hour of customer admin work | 1 hour of customer Gmail-filter work |
| Up-front cash | $15k$75k (CASA) | $0 | $0 |
| Recurring | Re-verification annually | None | None |
| Best for | 50+ customers, Marketplace play | 110 customers, white-glove onboarding | Customers who refuse OAuth setup |
The recommendation stands: build Model B for G1 + G2 + G3, ship that,
and let real customer demand decide whether G4/G5 and Model A
promotion are worth the calendar time.

View File

@@ -0,0 +1,189 @@
# Mobile Optimization Design
**Status**: Design approved 2026-04-29 — pending plan.
**Plan decomposition**: Foundation PR (§3) is one implementation plan; per-page migration phases (§5) become follow-up plans, scoped per phase.
**Branch base**: stacks on `refactor/data-model`.
**Out of scope**: Phase B/C features, desktop redesign, Capacitor wrapper, swipe-actions on rows, native menus, server-driven UI.
---
## 1. Background
The CRM was built desktop-first. A 2026-04-29 mobile audit captured every authenticated and public page across the active iPhone viewport range. Findings:
1. **No `viewport` meta in the root layout** (one exists only in the scanner PWA sub-layout, `src/app/(scanner)/[portSlug]/scan/layout.tsx`). Without it, iOS Safari renders pages at the default 980px logical width and zooms out to fit — text becomes unreadable and touch targets sub-tappable. Playwright's `isMobile` emulation in the audit forces 393px-wide rendering, which exposes the layout breakage you'd otherwise have to discover by pinching to zoom.
2. **Topbar overflows**. Search input + port switcher + sign-out button cram into one row; sign-out clips off the right edge as a half-visible blue bar on every authenticated page.
3. **Tables render as desktop tables**. Every list page (clients, yachts, companies, invoices, expenses, interests, audit, users, etc.) shows truncated columns with horizontal scroll.
4. **Page headers don't downsize**. Titles like "Dashboard" truncate to "Dash..."; primary action buttons (`+ New Client`) overlap their subtitles.
5. **Detail page action chips overflow**. The chip row ("Invite to portal | GDPR export | Archive | …") horizontally overflows on every detail page.
6. **One half-good pattern**: detail pages already collapse their tabs to a `<select>` dropdown on small screens. Worth extending.
7. **Auth + scanner pages are already mobile-first** (`/login`, `/[portSlug]/scan`). Reference for the "what good looks like" target.
The audit harness (`tests/e2e/audit/mobile.spec.ts` + `mobile-audit` Playwright project) is added on this branch (not yet committed); re-runs regenerate `.audit/mobile/` (gitignored).
## 2. Approach
**Adaptive shell + responsive content** — chosen over (a) per-page conditional render, (b) a separate `(mobile)` route group, and (c) Tailwind-only responsive.
The "native feel" the user wants comes from the chrome — bottom tab bar, sheet modals, sticky compact header, safe-area awareness. Page content (forms, lists, details) doesn't need duplication; it gets responsive via shared mobile-aware primitives. This concentrates the dedicated-mobile work in ~10 components and keeps content single-source.
**Breakpoint**: Tailwind `lg` (1024px). Below `lg`, the mobile shell renders. At and above, the existing desktop shell is untouched.
### 2.1 Target iPhone viewport range
The mobile shell + content primitives must look correct across the full active iPhone viewport range (portrait):
| Tier | Models | Viewport |
| ------------------------------------------ | ----------------------------------------------- | -------- |
| Narrowest | iPhone SE 2nd / 3rd gen | 375×667 |
| Standard | iPhone 12/13/14 (and Mini) | 390×844 |
| Standard newer | iPhone 15 / 15 Pro / 16 | 393×852 |
| Pro newer (Dynamic Island, thinner bezels) | iPhone 16 Pro / 17 Pro | 402×874 |
| Plus / older Max | iPhone 14 Plus / 15 Plus / 15 Pro Max / 16 Plus | 430×932 |
| Pro Max | iPhone 16 Pro Max / 17 Pro Max | 440×956 |
**Anchors used by audit and design validation**: 375×667 (worst-case narrow + short), 393×852 (most common current), 402×874 (current Pro), 440×956 (current Pro Max). Models within ±5px of an anchor (390, 430) are skipped — primitives that look correct at the anchors will look correct at neighbors.
**Dynamic Island**: iPhone 14 Pro and later have a larger top safe-area inset (~59px vs ~47px on classic-notch models). The CSS `env(safe-area-inset-top)` we expose as `pt-safe` handles this transparently — no per-model code paths.
**Landscape**: out of scope for this design. Phones in landscape are rare for CRM-style work; if needed later, the mobile shell at landscape widths would still fall under `lg` and would just stretch. Tablet landscape is addressed in the §5 tablet-pass phase.
**Routing**: no new route group. URLs and middleware unchanged. RBAC, services, queries, validators, RHF/zod forms, TanStack Query stores, socket.io — all unchanged.
## 3. Foundation PR
A single branch lands the infra + shell + primitives before any per-page work. After this merges, every authenticated page already gains: real viewport meta, no clipped topbar, bottom tab navigation, safe-area handling, and 44px touch targets — without any per-page edits.
### 3.1 Infrastructure
- `viewport` export in `src/app/layout.tsx``width=device-width, initial-scale=1, viewport-fit=cover`.
- `theme-color` meta + `apple-mobile-web-app-capable` meta + `apple-mobile-web-app-status-bar-style` for PWA-ish status-bar integration.
- Safe-area CSS variables (`env(safe-area-inset-*)`) exposed as Tailwind utilities (`pt-safe`, `pb-safe`, `pl-safe`, `pr-safe`).
- `useIsMobile()` hook in `src/hooks/use-is-mobile.ts` — backed by `window.matchMedia('(max-width: 1023.98px)')`, no resize listener.
- Server-side body-class detection: the root layout (`src/app/layout.tsx`) reads the `user-agent` request header via `next/headers`'s `headers()`, runs a small known-mobile-token check (Mobile / iPhone / iPad / Android — no library), and renders `<body data-form-factor="mobile|desktop">`. No middleware needed. CSS `[data-form-factor="mobile"]` reveals the mobile shell. The CSS media-query fallback (`@media (max-width: 1023.98px)`) handles UA misclassification (e.g., desktop browser resized to narrow width, or stripped UA).
### 3.2 Mobile shell
Both desktop and mobile shells are rendered to the DOM by the root layout; CSS reveals one and hides the other based on `[data-form-factor="mobile"]` plus a `@media (max-width: 1023.98px)` fallback. The existing `<Sidebar>` and `<Topbar>` components stay unchanged for the desktop shell. The mobile shell is wholly new:
- **`<MobileLayout>`** (`src/components/layout/mobile/mobile-layout.tsx`)
Fixed 52px compact topbar (safe-area aware) + scrollable content + fixed 56px bottom tab bar (safe-area inset). Renders instead of the desktop sidebar+topbar shell when the form factor resolves to mobile.
- **`<MobileTopbar>`**
Page title (auto-truncating, single-line) + back button when route depth > 1 + single primary action slot (passed via context from the page) + port-switcher behind a `<Sheet>` trigger.
- **`<MobileBottomTabs>`**
Fixed 5 tabs: **Dashboard / Clients / Yachts / Berths / More**. Active state from current path. Lucide icons (no emoji). Badge support for the alerts count.
- **`<MoreSheet>`**
Bottom sheet opened by the More tab. Holds the long tail in a scrollable list grouped by section: Companies, Interests, Invoices, Expenses, Documents, Email, Alerts, Reports, Reminders, Settings, Admin (with admin nesting one level deep into a child sheet).
- **`<MobileLayoutProvider>`**
React context that lets each page push its title, back button, and primary action slot to `<MobileTopbar>` via a hook (`useMobileChrome({ title, action })`).
### 3.3 Primitives
All built once in `src/components/shared/`. Render desktop-style above `lg`, mobile-style below.
- **`<Sheet>`** — vaul-based bottom sheet on mobile, falls through to existing Radix `<Dialog>` on desktop. Same API as `<Dialog>` so adoption is mechanical.
- **`<DataView>`** — accepts the same column defs the codebase uses today via TanStack Table. Above `lg`: renders the existing table. Below `lg`: renders a card list with a per-row `cardRender({ row }) => ReactNode` callback. Filter chips stay above the list; sort moves into a `<Sheet>` opened by a sort button.
- **`<PageHeader>`** — title + optional subtitle + actions. Truncates title to one line, stacks actions to a second row on mobile, hides subtitle below `sm` if action row is present.
- **`<ActionRow>`** — chip-style action group; `flex-nowrap overflow-x-auto scroll-smooth snap-x` on mobile, no overflow on desktop.
- **`<DetailPageShell>`** — wraps detail pages with: sticky compact header (entity name, primary status), tab dropdown selector (existing pattern, extracted), scrollable content area, optional sticky bottom action bar (Save / Archive / etc.) on mobile that pins above the bottom tab bar.
- **`<FilterChips>`** — chip-row filter UI used by `<DataView>`. Active filters are dismissable chips; "Add filter" opens a `<Sheet>`.
### 3.4 Default style adjustments
- `<Button>` and `<Input>` defaults: `min-h-11` (44px, Apple HIG touch-target).
- `<Input>` and `<Textarea>` body text: `text-base` (16px) so iOS doesn't zoom on focus.
- `<Dialog>` default base styling tweaked so any remaining unmigrated dialogs render full-screen on mobile (until they get migrated to `<Sheet>`).
### 3.5 Bundle impact
Both shells render server-side and switch via the `data-form-factor` body attribute, so both ship to every client (dynamic-importing one would cause a hydration flash). Rough estimate ~40KB gzipped added to the layout subtree for the mobile shell + new primitives (vaul ≈ 5KB gz, the rest is in-house components). Verify post-build with `pnpm build` and adjust if it's materially higher. Acceptable trade for no flash and no UA-based render-time branching.
### 3.6 PWA assets
The PWA scanner already references `icon-192.png`, `icon-512.png`, `icon-512-maskable.png` from `public/`, but those files don't exist yet (separate flagged blocker). The mobile shell adds an `apple-touch-icon` reference too. The Foundation PR includes placeholder PNGs so home-screen install works; production-quality icons can replace them without a code change.
## 4. Per-page playbook
Once foundation lands, each page follows the same workflow:
1. Open the page in headed Playwright at the anchor viewports per §2.1 (start at 393×852 for the iteration loop, spot-check 375 and 440 before declaring done).
2. Replace any `<Dialog>` with `<Sheet>`.
3. If list page: wrap the table in `<DataView>` and provide a `cardRender` callback. The 2-3 fields shown on the card are decided per page during migration with the user.
4. Replace the ad-hoc page header with `<PageHeader>`.
5. Replace ad-hoc action button rows with `<ActionRow>`.
6. Touch up any custom embedded widgets the page uses (rare for simple pages, common for `email`, `documents`, `expenses/scan`).
7. User reviews live in the headed browser, points out tweaks, iterate.
Most pages take 515 minutes in this loop. Heavy pages (email inbox, documents hub) may take 3060 because the embedded widgets need their own mobile treatment beyond the primitives.
## 5. Migration sequence
After foundation PR:
1. **Quick-win sweep** (~half day) — pages mostly fixed by foundation alone. Just need `<PageHeader>` swap-in (no list-card conversion, no detail-shell wrap):
`dashboard` (overview), `settings` (user-profile), `reports`, and the admin sub-pages that are forms or stat cards: `admin/settings`, `admin/branding`, `admin/forms`, `admin/ocr`, `admin/roles`, `admin/tags`, `admin/documenso`, `admin/templates`, `admin/custom-fields`, `admin/monitoring`, `admin/backup`, `admin/webhooks`, `admin/import`, `admin/ports`.
2. **List pages** (~12 days) — convert via `<DataView>` + per-page `cardRender`:
`clients`, `yachts`, `companies`, `berths`, `interests`, `invoices`, `expenses`, `alerts`, `reminders`, `admin/audit`, `admin/users`.
3. **Heavy pages** (~1 day each) — embedded widgets need their own mobile treatment beyond the primitives:
`documents` (sig-tracking + filters from Phase A), `email` (thread list + reader + composer).
4. **Detail pages** (~12 days) — wrap in `<DetailPageShell>`, extend the tab-dropdown pattern, add sticky bottom action shelf:
`clients/[clientId]`, `yachts/[yachtId]`, `companies/[companyId]`, `berths/[berthId]`, `invoices/[id]`, `expenses/[id]`.
5. **Forms & wizards** — touch-up only, since `<Input>`/`<Button>` defaults handle the bulk:
`invoices/new` (3-step wizard), `expenses/scan` (already mobile-first, just verify).
6. **Portal** — same patterns, smaller scope:
authenticated: `portal/dashboard`, `portal/invoices`, `portal/my-yachts`, `portal/documents`, `portal/interests`, `portal/my-reservations`. Public: `portal/login`, `portal/activate`, `portal/forgot-password`, `portal/reset-password` (already styled by `<BrandedAuthShell>` — just verify).
7. **Tablet pass** — re-audit at iPad Air 11" portrait (820×1180) and landscape (1180×820), iPad Air 13" portrait (1024×1366) and landscape (1366×1024). The 820 portrait case will hit the mobile shell (820 < 1024) and probably want a "tablet-portrait" treatment with sidebar visible — flagged for design refinement at that phase, not now. The other three viewports fall above `lg` and use the desktop shell unchanged.
## 6. Testing
- **Mobile audit project** (`mobile-audit` in `playwright.config.ts`) is the regression harness. Re-runs after every page-migration PR; output goes to `.audit/mobile/` (gitignored). Audit covers the four anchor viewports defined in §2.1: 375×667, 393×852, 402×874, 440×956. Run time ~14 min headed.
- **Smoke project** gets a curated mobile-viewport variant (~10 pages at the 393×852 anchor) — adds ~2 min to CI; full audit stays out of CI to avoid the ~14 min cost.
- **Visual baselines** — `visual` project gets new mobile snapshots at the 393×852 anchor for: dashboard, clients-list, clients-detail, invoices-list, invoices-new, scan, documents, login. Regenerate with `--update-snapshots` after intentional changes (existing convention).
- **Anchor device descriptors** lifted into a shared fixture at `tests/e2e/fixtures/devices.ts` (one per anchor in §2.1) so specs don't redefine viewport.
- **No new unit tests** for the primitives — they are presentational. Coverage comes from visual + integration runs.
## 7. Open questions
- **Bottom-tab taxonomy**: locked at Dashboard / Clients / Yachts / Berths / More for now. The More sheet holds everything else losslessly, so this is reversible — if real usage suggests a different top-5 (e.g., Interests or Invoices in the tabs), swap them later without code restructure.
- **`refactor/data-model` push order**: 155 commits unpushed. Foundation PR can stack on top and rebase, or wait until that branch merges. Decision deferred to user.
- **Desktop touch-target adjustments**: bumping `<Button>`/`<Input>` to `min-h-11` will affect desktop too. Verify visually that no desktop layout breaks; if any does, scope the bump to mobile-only via the `data-form-factor` attribute.
## 8. Files to create
```
src/hooks/use-is-mobile.ts
src/components/layout/mobile/
mobile-layout.tsx
mobile-topbar.tsx
mobile-bottom-tabs.tsx
more-sheet.tsx
mobile-layout-provider.tsx
src/components/shared/
sheet.tsx (new — vaul wrapper)
data-view.tsx (new — table↔card)
page-header.tsx (new)
action-row.tsx (new)
detail-page-shell.tsx (new)
filter-chips.tsx (new)
src/app/layout.tsx (modified — viewport export, theme-color, UA-derived data-form-factor body attribute via headers())
public/icon-192.png (placeholder PWA asset)
public/icon-512.png (placeholder PWA asset)
public/icon-512-maskable.png (placeholder PWA asset)
public/apple-touch-icon.png (placeholder PWA asset)
tailwind.config.ts (modified — safe-area utilities, touch-target defaults)
tests/e2e/fixtures/devices.ts (new — shared device descriptors)
```
## 9. Files to modify per page
Per the playbook in §4, each page typically needs:
- One swap of header markup → `<PageHeader>`.
- For list pages: one wrap of table → `<DataView>` + add `cardRender` callback.
- For detail pages: wrap in `<DetailPageShell>`.
- Replace `<Dialog>` imports with `<Sheet>`.
- No service, validator, query, or schema changes anywhere.

View File

@@ -0,0 +1,564 @@
# Client Deduplication and NocoDB Migration Design
**Status**: Design draft 2026-05-03 — pending approval.
**Plan decomposition**: Three implementation plans stack from this design — (P1) normalization + dedup core library; (P2) admin settings + at-create + interest-level guards (runtime); (P3) NocoDB migration script + review queue UI. P1 unblocks P2 and P3.
**Branch base**: stacks on `feat/mobile-foundation` once it merges to `main`.
**Out of scope**: live merge of two clients across ports (cross-tenant), automated AI-judged matches, profile-photo / face-match dedup, web-of-trust referrer relationships.
---
## 1. Background
### 1.1 Why this exists
The legacy CRM lives in a NocoDB base whose `Interests` table conflates _the human_ with _the deal_. A row contains `Full Name`, `Email Address`, `Phone Number`, `Address`, `Place of Residence` _and_ the sales-pipeline state for one specific berth. A single human pursuing two berths becomes two rows with semi-duplicated personal data. A 2026-05-03 read-only audit confirmed:
- **252 Interests rows** in NocoDB, against an estimated ~190200 unique humans (~2025% duplication rate).
- **35 Residential Interests rows** in a parallel residential pathway with the same conflation.
- **64 Website Interest Submissions + 47 Website Contact Form Submissions + 1 EOI Supplemental Form** as inbound capture surfaces.
- **No Clients table.** The conflated structure is structural, not accidental.
The new CRM (`src/lib/db/schema/clients.ts`) splits this into `clients` (people) ↔ `interests` (deals), with `clientContacts` (multi-channel), `clientAddresses` (multi-address), and a pre-existing `clientMergeLog` table that anticipates merge with undo. The design has been ready; what's missing is (a) a normalization + matching library, (b) the at-create / at-import surfaces that use it, and (c) the migration of the existing 252+35 records.
### 1.2 Real duplicate patterns observed in the live data
Sampled 200 of the 252 NocoDB Interests rows. Confirmed duplicate clusters fall into six patterns:
| Pattern | Example rows | Signature |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| **A. Pure double-submit** | Deepak Ramchandani #624/#625; John Lynch #716/#725 | All fields identical; created same day |
| **B. Phone format variance** | Howard Wiarda #236/#536 (`574-274-0548` vs `+15742740548`); Christophe Zasso #701/#702 (`0651381036` vs `0033651381036`) | Same email, normalize-equal phone |
| **C. Name capitalization** | Nicolas Ruiz #681/#682/#683; Jean-Charles Miege/MIEGE #37/#163; John Farmer/FARMER #35/#161 | Same email or empty; surname case differs |
| **D. Name shortening** | Chris vs Christopher Allen #700/#534; Emma c vs Emma Cauchefer #661/#673 | Same email + phone; given-name truncated |
| **E. Resubmit with typo** | Christopher Camazou #649/#650 (phone last 4 digits typo); Gianfranco Di Constanzo/Costanzo #585/#336 (surname typo, **different yacht** — should be ONE client + TWO interests) | Score-on-everything-else high, one field has small-edit-distance noise |
| **F. Hard cases** | Etiennette Clamouze #188/#717 (same name, different country phone + email); Bruno Joyerot #18 with email belonging to Bruce Hearn #19 (couple sharing contact) | Cannot resolve without a human |
This dataset will be the fixture for the dedup library's tests — every pattern above must be either auto-detected or flagged for review, and the false-positive bar must be high enough that Pattern F doesn't get force-merged.
### 1.3 Dirty data inventory
The migration normalizer must survive these real values from production:
**Phone fields**: `+1-264-235-8840\r` (with carriage return), `'+1.214.603.4235` (apostrophe + dots), `0677580750/0690511494` (two numbers in one field), `00447956657022` (00 prefix), `+447000000000` (placeholder all-zeros), `+4901637039672` (impossible — stripped 0 + country prefix), various unprefixed local formats, dashed US numbers without country code.
**Email fields**: mixed case rampant (`Arthur@laser-align.com` vs `arthur@laser-align.com`); ALL-CAPS local parts; trailing whitespace.
**Name fields**: ALL-CAPS surnames mixed with title-case given names; embedded `\n` and `\r`; double spaces; lowercase-only entries; slash-with-company variants (`Daniel Wainstein / 7 Knots, LLC`, `Bruno Joyerot / SAS TIKI`); placeholder `Mr DADER`, `TBC`.
**Place of Residence (free text)**: `Saint barthelemy`, `St Barth`, `Saint-Barthélemy` (same place, three forms); `anguilla`, `United States `, `USA`, `Kansas City` (city without country), `Sag Harbor Y` (typo).
### 1.4 Existing battle-tested algorithm
`client-portal/server/utils/duplicate-detection.ts` already implements blocking + weighted-rules dedup against this same NocoDB. It runs in production today. We **port it forward** (don't reinvent), then add: soundex/metaphone for surname matching, compounded-confidence when multiple rules match, and negative evidence (same email + different country phone reduces confidence).
### 1.5 Why the website is no longer the source of new dirty data
The website forms (`website/components/pn/specific/website/{berths-item,register,form}/form.vue`) use `<v-phone-input>` with a country picker (`prefer-countries: ['US', 'GB', 'DE', 'FR']`) and `[(value) => !!value || 'Phone number is required']` validation. Output is E.164-shaped. The 252 dirty rows are legacy — pre-form-redesign submissions, sales-rep manual entries, and external CSV imports. Future inbound is clean.
---
## 2. Approach
Three artifacts, layered:
1. **A pure-logic normalization + matching library** at `src/lib/dedup/`. JSX-free, vitest-native (proven pattern: `realtime-invalidation-core.ts`). Tested against the dirty-data fixture corpus drawn from §1.2.
2. **Three runtime surfaces** that use the library: at-create suggestion in client/interest forms; interest-level same-berth guard; admin review queue powered by a nightly background scoring job.
3. **A one-shot migration script** that pulls NocoDB → normalizes → dedupes → writes new schema → produces a CSV report with auto-merge log + flagged-for-review pile.
**Configurability via admin settings** (`system_settings` per port) so the team can tune sensitivity without code changes. Defaults err on the safe side — a flagged review is cheaper than a wrongly-merged record.
**Reversibility**: every merge writes a `client_merge_log` row containing the loser's full pre-state JSON. A 7-day undo window lets a wrong merge be reversed without engineering involvement. After 7 days the snapshot is purged for GDPR; merges become permanent.
---
## 3. Normalization library
Lives at `src/lib/dedup/normalize.ts`. Pure functions, no DB, vitest-tested. Used by the dedup algorithm AND by all create-paths so what gets stored is already normalized.
### 3.1 `normalizeName(raw: string)`
```ts
export function normalizeName(raw: string): {
display: string; // human-readable, kept for UI
normalized: string; // for matching
surnameToken?: string; // for surname-based blocking
};
```
- Trim leading/trailing whitespace
- Replace `\r`, `\n`, tabs with single space
- Collapse consecutive whitespace to single space
- Smart title-case: keep particles (`van`, `de`, `del`, `O'`, `di`, `le`, `da`) lowercase except as first token
- `display` preserves user's intent (slash-with-company stays intact)
- `normalized` is `display.toLowerCase()` for comparison
- `surnameToken` is the last non-particle token for blocking
### 3.2 `normalizeEmail(raw: string)`
```ts
export function normalizeEmail(raw: string): string | null;
```
- Trim + lowercase
- Validate via `zod.email()` schema
- Returns `null` for empty / invalid (caller decides what to do)
- **Does NOT strip plus-aliases** (`user+tag@domain.com`) — both intentional (real distinct addresses) and malicious-prevention apply. Compare by full localpart.
### 3.3 `normalizePhone(raw: string, defaultCountry: string)`
```ts
export function normalizePhone(
raw: string,
defaultCountry: string,
): {
e164: string | null; // canonical, e.g. '+15742740548'
country: string | null; // ISO-3166-1 alpha-2
display: string | null; // user-facing pretty
flagged?: 'multi_number' | 'placeholder' | 'unparseable';
} | null;
```
Pipeline:
1. Strip `\r`, `\n`, tabs, single quotes, dots, dashes, parens, spaces
2. If contains `/` or `;` or `,` → flag `multi_number`, take first segment
3. If matches `+\d{2}0+$` (e.g., `+447000000000`) → flag `placeholder`, return null
4. If starts with `00` → replace with `+`
5. If starts with `+` → parse as E.164
6. Else if `defaultCountry` provided → parse against that country
7. Else return null (caller's problem)
Backed by `libphonenumber-js` (already in deps via `tests/integration/factories.ts` usage if not, will add). The hostile cases above all need explicit handling — naïve regex won't survive.
### 3.4 `resolveCountry(text: string)`
```ts
export function resolveCountry(text: string): {
iso: string | null; // ISO-3166-1 alpha-2
confidence: 'exact' | 'fuzzy' | 'city' | null;
};
```
Reuses `src/lib/i18n/countries.ts`. Pipeline:
1. Lowercase + strip diacritics
2. Exact match against country names (any locale we ship)
3. Fuzzy match (Levenshtein ≤ 2 against canonical English names)
4. City fallback — small in-package mapping for high-frequency cities seen in legacy data (`Sag Harbor → US`, `Kansas City → US`, `St Barth → BL`, etc.). Order: exact → city → fuzzy.
The mapping is opinionated and small (~30 entries covering the actual values seen in the 252-row dataset). Anything that fails to resolve returns `null` and lands in the migration's flagged pile.
---
## 4. Dedup algorithm
Lives at `src/lib/dedup/find-matches.ts`. Pure function. Vitest-tested against the §1.2 cluster fixtures.
### 4.1 Public API
```ts
export interface MatchCandidate {
id: string;
fullName: string | null;
emails: string[]; // already normalized
phonesE164: string[]; // already normalized E.164
countryIso: string | null;
}
export interface MatchResult {
candidate: MatchCandidate;
score: number; // 0100
reasons: string[]; // human-readable, e.g. ["email match", "phone match"]
confidence: 'high' | 'medium' | 'low';
}
export function findClientMatches(
input: MatchCandidate,
pool: MatchCandidate[],
thresholds: DedupThresholds,
): MatchResult[];
```
### 4.2 Scoring rules (compound)
Each rule produces a score addition. **Compounding**: when two strong rules match (e.g., email AND phone), the result is ~95+ rather than max(50, 50). Negative evidence subtracts.
| Rule | Score | Notes |
| --------------------------------------------------------------- | ----- | ------------------------------------------------------ |
| Exact email match (case-insensitive, normalized) | +60 | One match suffices |
| Exact phone E.164 match (≥ 8 significant digits) | +50 | Excludes placeholder all-zeros |
| Exact normalized full-name match | +20 | Many "John Smith"s exist |
| Surname soundex match + given-name fuzzy match (Lev ≤ 1) | +15 | Catches `Constanzo/Costanzo`, `Christophe/Christopher` |
| Same address (normalized fuzzy ≥ 0.8) | +10 | Bonus signal |
| **Negative**: Same email but different country code on phone | 15 | Suggests spouse / coworker / shared inbox |
| **Negative**: Same name but DIFFERENT email AND DIFFERENT phone | 20 | Two distinct people with the same name |
### 4.3 Confidence tiers (post-compound)
- **score ≥ 90 — `high`** — email AND phone match, or email + name + address. Block-create suggest "Use existing." Auto-link on public-form submit by default.
- **score 5089 — `medium`** — single strong signal (email or phone alone), or email + same-name + different country (Etiennette case). Soft-warn but allow.
- **score < 50 — `low`** — weak signals only. Don't surface in UI; only relevant in background-job review queue.
### 4.4 Blocking strategy
For O(n) scan over a pool of N existing clients, build three lookup maps once per scan:
- `byEmail: Map<string, MatchCandidate[]>` — keyed by normalized email
- `byPhoneE164: Map<string, MatchCandidate[]>` — keyed by E.164
- `bySurnameToken: Map<string, MatchCandidate[]>` — keyed by `normalizeName(...).surnameToken`
For an incoming `MatchCandidate`, the candidate set to compare is the union of pool entries reachable through any of its emails/phones/surname-token. Typically 05 candidates per query, regardless of N.
### 4.5 Performance budget
For migration: 252 rows compared pairwise once. ~30k comparisons after blocking — a few seconds.
For runtime at-create: incoming candidate against existing pool of N clients per port. Expected pool size at maturity: 1k10k. With blocking: <10 comparisons, <1ms target. No DB query needed beyond the initial pool fetch (which itself uses the indexed columns).
For background nightly job: full pairwise within port, blocked. 10k clients → ~50k pairwise checks per port → <30s. Fine for a nightly cron.
---
## 5. Configurable thresholds (admin settings)
New rows in `system_settings` per port. Default values err safe (more confirmation, less auto-action).
| Key | Default | Effect |
| ------------------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `dedup_block_create_threshold` | `90` | Score above which the client-create form interrupts: "Use existing client?" |
| `dedup_soft_warn_threshold` | `50` | Score above which a soft-warn panel surfaces below the form |
| `dedup_review_queue_threshold` | `40` | Background job lands pairs ≥ this score in `/admin/duplicates` |
| `dedup_public_form_auto_link` | `true` | When a public-form submission scores ≥ block-threshold against existing client, attach the new interest to that client without prompting. **Safe**: no merge, just attaching a deal. |
| `dedup_auto_merge_threshold` | `null` (disabled) | If non-null, merges happen automatically at this threshold without human confirmation. Recommend leaving null until the team is comfortable; `95` is a reasonable cautious value. |
| `dedup_undo_window_days` | `7` | How long the loser's pre-state JSON is retained for merge-undo. After this, the snapshot is purged (GDPR) and merges are permanent. |
Each setting is a row in `system_settings`. UI surface in `/[portSlug]/admin/dedup` (a new admin page) with an "Advanced" toggle to expose the thresholds and brief explanations.
If the sales team complains the safer mode is too click-heavy, an admin flips `dedup_auto_merge_threshold` to `95` without any code change.
---
## 6. Merge service contract
### 6.1 Data flow
`mergeClients(winnerId, loserId, fieldChoices, ctx)` does, in a single transaction:
1. **Snapshot loser** — full row + all attached `clientContacts`, `clientAddresses`, `clientNotes`, `clientTags`, plus a count of dependent rows about to be moved (interests, yacht-memberships, etc.). Stored as `mergeDetails` JSONB in `clientMergeLog`.
2. **Reattach** — every row pointing at `loserId` updates to point at `winnerId`:
- `interests.clientId`
- `clientContacts.clientId` — with conflict handling: if winner already has the same email, keep winner's; flag the duplicate for the user
- `clientAddresses.clientId` — same conflict handling
- `clientNotes.clientId` — preserve `authorId` + `createdAt` (never overwrite)
- `clientTags.clientId`
- `clientYachtMembership.clientId` (or whatever the table is called)
- `auditLogs.entityId` — annotate, don't move (audit truth)
3. **Apply fieldChoices** — for each field where the user picked the loser's value, copy that into the winner row.
4. **Soft-archive loser**`loser.archivedAt = now()`, `loser.mergedIntoClientId = winnerId`. Row stays in DB so the merge is reversible.
5. **Write `clientMergeLog`**`{ winnerId, loserId, mergedBy, mergedAt, mergeDetails: <snapshot>, fieldChoices }`.
6. **Audit log** — top-level `auditLogs` row: `{ action: 'merge', entityType: 'client', entityId: winnerId, metadata: { loserId, score, reasons } }`.
### 6.2 Schema additions (migration)
`clients` table gets a new column:
```ts
mergedIntoClientId: text('merged_into_client_id').references(() => clients.id),
```
The existing `clientMergeLog` table is reused. Add a partial index for the undo-window query:
```sql
CREATE INDEX idx_cml_recent ON client_merge_log (port_id, created_at DESC) WHERE created_at > NOW() - INTERVAL '7 days';
```
A daily maintenance job (using the existing `maintenance-cleanup.test.ts` infrastructure) purges `mergeDetails` JSONB older than `dedup_undo_window_days` setting.
### 6.3 Undo
`unmergeClients(mergeLogId, ctx)`:
1. Within the undo window, look up the snapshot
2. Restore loser: clear `archivedAt`, `mergedIntoClientId`
3. Restore loser's contacts/addresses/notes/tags from snapshot
4. Detach reattached rows: `interests` etc. that were touching `winnerId` and originally belonged to loser go back. The snapshot stores the original `(rowType, rowId)` list explicitly so this is deterministic.
5. Mark log row `undoneAt = now()`, `undoneBy = userId`
After 7 days the snapshot is gone and unmerge returns `410 Gone`.
### 6.4 Concurrency
Both merge and unmerge wrap in a single transaction with `SELECT … FOR UPDATE` on `clients.id` of both winner and loser. A second merge attempt against the same loser sees `mergedIntoClientId` already set and refuses (clear error: "Already merged into …").
---
## 7. Runtime surfaces
### 7.1 Layer 1 — At-create suggestion
In `ClientForm` (and the public `register` form once that hits the new system):
- Debounced 300ms after email or phone field changes
- Calls `findClientMatches` against current port's clients
- Renders top-1 match if score ≥ `dedup_soft_warn_threshold`:
```
┌─────────────────────────────────────┐
│ This looks like an existing client │
│ ML Marcus Laurent │
│ marcus@… +33 6 12 34 56 78 │
│ 2 interests · last 9d ago │
│ [ Use this client ] [ Create new ] │
└─────────────────────────────────────┘
```
- "Use this client" → form switches to "create new interest under existing client" mode (preserves whatever other fields the user typed)
- "Create new" → audit-log `dedup_override` with the candidate's id and reasons (so we have data on false positives)
### 7.2 Layer 2 — Interest-level same-berth guard
Cheap one-liner in `createInterest` service:
- Check `(clientId, berthId)` against existing non-archived interests
- If hit, throw `BerthDuplicateError` with the existing interest details
- UI catches and prompts: "Update existing or create separate?"
This is NOT the same as client-level dedup. Same client legitimately can pursue the same berth a second time after it falls through. But the prompt-before-create catches the accidental double-submit case.
### 7.3 Layer 3 — Background scoring + review queue
- A nightly cron (using existing BullMQ infrastructure — search for `scheduled-tasks` in repo) runs `findClientMatches` over each port's full client pool
- Pairs scoring ≥ `dedup_review_queue_threshold` land in a `client_merge_candidates` table:
```ts
export const clientMergeCandidates = pgTable('client_merge_candidates', {
id: text('id').primaryKey()...,
portId: text('port_id').notNull()...,
clientAId: text('client_a_id').notNull()...,
clientBId: text('client_b_id').notNull()...,
score: integer('score').notNull(),
reasons: jsonb('reasons').notNull(),
status: text('status').notNull().default('pending'), // pending | dismissed | merged
createdAt: timestamp('created_at')...,
resolvedAt: timestamp('resolved_at'),
resolvedBy: text('resolved_by'),
})
```
- `/[portSlug]/admin/duplicates` lists pending candidates sorted by score desc, with `[Review →]` opening a side-by-side merge dialog
- Dismissing a candidate marks it `status=dismissed` so the job doesn't re-surface the same pair tomorrow (a future score increase re-creates it).
---
## 8. NocoDB → new system field mapping
This is the explicit mapping the migration script applies. One NocoDB Interest row produces multiple new rows.
### 8.1 Top-level transform
```
NocoDB Interests row
─→ 01 client (deduped against existing pool)
─→ 01 client_address
─→ 02 client_contacts (email, phone)
─→ exactly 1 interest
─→ 01 yacht (when Yacht Name present and not "TBC"/"Na"/empty placeholders)
─→ 01 document (when documensoID present)
```
### 8.2 Field map
| NocoDB field | Target | Transform |
| ----------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| `Full Name` | `clients.fullName` | `normalizeName().display` |
| `Email Address` | `clientContacts(channel='email', value=...)` | `normalizeEmail()` |
| `Phone Number` | `clientContacts(channel='phone', valueE164=..., valueCountry=...)` | `normalizePhone(raw, defaultCountry)` |
| `Address` | `clientAddresses.streetAddress` (LongText preserved) | trim |
| `Place of Residence` | `clientAddresses.countryIso` AND `clients.nationalityIso` | `resolveCountry()` |
| `Contact Method Preferred` | `clients.preferredContactMethod` | lowercase, mapped: Email→email, Phone→phone |
| `Source` | `clients.source` | mapped: portal→website, Form→website, External→manual; null → manual |
| `Date Added` | `interests.createdAt` (fallback to NocoDB `Created At` then now) | parse: try `DD-MM-YYYY`, then `YYYY-MM-DD`, then ISO |
| `Sales Process Level` | `interests.pipelineStage` | see §8.3 |
| `Lead Category` | `interests.leadCategory` | General→general_interest, Friends and Family→general_interest with tag |
| `Berth` (FK) | `interests.berthId` | resolve via `Berths` table by `Mooring Number` |
| `Berth Size Desired` | `interests.notes` (appended) | preserve |
| `Yacht Name`, `Length`, `Width`, `Depth` | `yachts.name`, `lengthM`, `widthM`, `draughtM` | skip if name in {`TBC`, `Na`, ``, null}; ft→m via `\* 0.3048` |
| `EOI Status` | `interests.eoiStatus` | Awaiting Further Details→pending; Waiting for Signatures→sent; Signed→signed |
| `Deposit 10% Status` | `interests.depositStatus` | Pending→pending; Received→received |
| `Contract Status` | `interests.contractStatus` | Pending→pending; 40% Received→partial; Complete→complete |
| `EOI Time Sent` | `interests.dateEoiSent` | parse |
| `clientSignTime` / `developerSignTime` / `all_signed_notified_at` | `interests.dateEoiSigned` (use latest) | parse |
| `Time LOI Sent` | `interests.dateContractSent` | parse |
| `Internal Notes` + `Extra Comments` | `clientNotes` (one row, system author) | concatenate with section markers |
| `documensoID` | `documents.documensoId` (when present, type='eoi') | preserve |
| `Signature Link Client/CC/Developer`, `EmbeddedSignature*` | `documents.signers[]` | one row per non-null signer |
| `reminder_enabled`, `last_reminder_sent`, etc. | `interests.reminderEnabled`, `interests.reminderLastFired` | parse, default true |
### 8.3 Sales-stage mapping (8 → 9)
| NocoDB | New (PIPELINE_STAGES) |
| ------------------------------- | ------------------------------------------------------------------------ |
| General Qualified Interest | `open` |
| Specific Qualified Interest | `details_sent` |
| EOI and NDA Sent | `eoi_sent` |
| Signed EOI and NDA | `eoi_signed` |
| Made Reservation | `deposit_10pct` |
| Contract Negotiation | `contract_sent` |
| Contract Negotiations Finalized | `contract_sent` (with audit-note: legacy "negotiations finalized") |
| Contract Signed | `contract_signed` (or `completed` when deposit + contract both complete) |
### 8.4 Other tables
- **Residential Interests** (35 rows) — same shape as Interests but maps to `residentialClients` + `residentialInterests`. Smaller and cleaner. Same dedup runs within this pool independently.
- **Website - Interest Submissions** (64 rows) — these are **inbound capture, not yet a client**. Treat as if each row is a fresh public-form submission today: run dedup against the migrated client pool. Auto-link if `dedup_public_form_auto_link` setting allows.
- **Website - Contact Form Submissions** (47 rows) — sparse data (just name + email + interest type). Skip migration; export as CSV for manual triage. Not the source of truth for any deal.
- **Website - Berth EOI Details Supplements** (1 row) — single record, preserved as a one-off attached to the matching Interest.
- **Newsletter Sending** (69 rows) — out of scope; that's a marketing surface, not CRM.
- **Interests Backup, Interests copy** — historical artifacts. Skipped by default. A `--include-backups` flag attaches them as audit-note entries on the corresponding live Interest if the user wants the history.
---
## 9. Migration script
Located at `scripts/migrate-from-nocodb.ts`. Idempotent: safe to re-run. Three main flags:
```
$ pnpm tsx scripts/migrate-from-nocodb.ts --dry-run [--port-slug X]
Pulls everything, transforms, runs dedup, writes CSV report to .migration/<timestamp>/. No DB writes.
$ pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<timestamp>/
Reads the report, performs the writes the dry-run promised. Refuses if the source data has changed since the report was generated (hash mismatch).
$ pnpm tsx scripts/migrate-from-nocodb.ts --rollback --apply-id <id>
Reads the apply log, undoes the writes (only valid within the undo window).
```
Reuses the `client-portal/server/utils/nocodb.ts` adapter for the NocoDB API client (no need to rebuild). Writes to the new system via Drizzle (re-using the existing services like `createClient`, `createInterest`, etc., so all the same validation runs).
### 9.1 Dry-run report format
`.migration/<timestamp>/report.csv`:
```csv
op,reason,nocodb_row_id,target_table,target_value,confidence,manual_review_required
create_client,new,624,clients.fullName,Deepak Ramchandani,N/A,false
create_contact,new,624,clientContacts.email,dannyrams8888@gmail.com,N/A,false
create_contact,new,624,clientContacts.phone,+17215868888,N/A,false
create_interest,new,624,interests.berthId,a1b2c3...,N/A,false
auto_link,score=98 (email+phone),625,clients.id,<existing client UUID from row 624>,high,false
flag_for_review,score=72 (same name diff country),188,client.id,<existing client UUID from row 717>,medium,true
country_unresolved,fallback to AI (port country),198,clientAddresses.countryIso,AI,low,true
phone_unparseable,placeholder all-zeros,641,clientContacts.phone,<skipped>,N/A,true
```
Plus `.migration/<timestamp>/summary.md`:
```
# Migration Dry-Run — 2026-05-03 14:23 UTC
NocoDB: 252 Interests + 35 Residences + 64 Website Submissions
Outcome: 198 clients, 287 interests (incl. residences), 91 yachts, 412 contacts
Auto-linked (high confidence, no human action needed):
- Nicolas Ruiz: rows 681,682,683 → 1 client + 3 interests
- John Lynch: rows 716,725 → 1 client + 2 interests
- Deepak Ramchandani: rows 624,625 → 1 client + 2 interests
- [12 more]
Flagged for manual review (medium confidence):
- Etiennette Clamouze (rows 188,717): same name, different country phone + email
- Bruno Joyerot #18 + Bruce Hearn #19: shared household contact
- [4 more]
Country resolution failed for 7 rows. All defaulted to port country (AI). Review:
- Row 239: "Sag Harbor Y" → AI (likely US)
- [6 more]
Phone parsing failed for 3 rows. All flagged, no contact created:
- Row 178: empty
- Row 641: placeholder "+447000000000"
- Row 175: empty
Run `--apply` to commit these changes.
```
### 9.2 Apply phase
`--apply` reads the report, re-fetches the source rows (via NocoDB MCP / API), recomputes the hash, fails fast if NocoDB changed since dry-run. Then performs the writes within a single PostgreSQL transaction per port (commit at end). On any error mid-transaction, full rollback.
After successful apply, an `apply_id` is generated and an audit-log row written. The `apply_id` is the handle used for `--rollback`.
### 9.3 Idempotency
The script tracks NocoDB row IDs in a `migration_source_links` table:
```ts
export const migrationSourceLinks = pgTable('migration_source_links', {
id: text('id').primaryKey()...,
sourceSystem: text('source_system').notNull(), // 'nocodb_interests' | 'nocodb_residences' | …
sourceId: text('source_id').notNull(), // NocoDB row id as string
targetEntityType: text('target_entity_type').notNull(), // client | interest | yacht | …
targetEntityId: text('target_entity_id').notNull(),
appliedAt: timestamp('applied_at')...,
appliedBy: text('applied_by'),
}, (table) => [
uniqueIndex('idx_msl_source').on(table.sourceSystem, table.sourceId, table.targetEntityType),
]);
```
Re-running `--apply` against the same report skips rows already in this table. Useful for partial-failure resumption.
---
## 10. Test plan
### 10.1 Library-level (vitest unit)
- `tests/unit/dedup/normalize.test.ts` — every dirty-data pattern from §1.3 has a fixture asserting the expected normalized output.
- `tests/unit/dedup/find-matches.test.ts` — every duplicate cluster from §1.2 has a fixture asserting score + confidence tier. Hard cases (Pattern F) assert "medium" not "high" — false-positive guard.
### 10.2 Service-level (vitest integration)
- `tests/integration/dedup/client-merge.test.ts` — merge service exercised: full reattach, clientMergeLog written, undo within window restores, undo after window returns 410, concurrent merge of same loser fails the second.
- `tests/integration/dedup/at-create-suggestion.test.ts` — `findClientMatches` against a seeded pool returns expected matches + reasons.
### 10.3 Migration script (vitest integration with NocoDB mock)
- `tests/integration/dedup/migration-dry-run.test.ts` — feed the script a fixture NocoDB dump (the 252 rows, frozen as a JSON snapshot in fixtures), assert the resulting CSV matches a golden file. Catch any future regression in the transform pipeline.
- `tests/integration/dedup/migration-apply.test.ts` — apply the dry-run output to a clean test DB, assert all expected rows exist, assert idempotency (re-apply is a no-op).
### 10.4 E2E (Playwright)
- `tests/e2e/smoke/30-dedup-create.spec.ts` — type into ClientForm with an email matching seeded client; assert suggestion card appears; click "Use this client"; assert form switches to interest-create mode.
- `tests/e2e/smoke/31-admin-duplicates.spec.ts` — admin views review queue, opens a candidate, side-by-side merge UI works, merge succeeds, undo within window works.
---
## 11. Rollback plan
Three layers of safety, ordered by reversibility:
1. **Per-merge undo** — admin clicks Undo on a wrongly-merged pair, system rolls back from `clientMergeLog` snapshot. 7-day window. No engineering needed.
2. **Migration `--rollback` flag** — entire migration apply is reversed via the `apply_id` and `migration_source_links` table. Useful in the first 24h after `--apply`. Engineering-supervised.
3. **DB restore from backup** — the existing `docs/ops/backup-runbook.md` covers this. Last resort if both above are blocked.
Pre-migration, take a hot backup of the new DB (`pg_dump`). Pre-merge in production (before any human-facing surface ships), the `dedup_auto_merge_threshold` defaults to `null` so no automatic merges happen — every merge is human-confirmed.
---
## 12. Open items
- **Soundex vs metaphone** — Soundex is simpler but English-leaning. Metaphone handles non-English surnames better (the dataset has French, German, Italian, Slavic names). Default to metaphone via the `natural` package; revisit if it adds significant install size.
- **Cross-port dedup** — not in scope. Each port's clients are deduped within that port. A future "shared address book" feature would need its own design.
- **Profile photo / face match** — out of scope.
- **AI-assisted match resolution** — out of scope. The Layer-3 review queue is human-only.
---
## Implementation sequence
P1 (this design's library) → P2 (runtime surfaces) → P3 (migration). Each is a separate plan / PR.
**P1 deliverables**: `src/lib/dedup/{normalize,find-matches}.ts` + tests. No UI changes. No DB changes (except indexed lookups added to existing `clientContacts`). ~1.5 days.
**P2 deliverables**: at-create suggestion in `ClientForm` + interest-level guard in `createInterest` service + admin settings UI for thresholds + `clientMergeCandidates` table + nightly job + admin review queue page + merge service + side-by-side merge UI. ~57 days.
**P3 deliverables**: `scripts/migrate-from-nocodb.ts` + `migration_source_links` table + dry-run + apply + rollback. CSV report format frozen against fixture. ~3 days, including fixture creation from the live NocoDB snapshot.
Total: ~1012 engineering days from approval. Can be split across three PRs landing independently — each is testable in isolation and the runtime surfaces (P2) work even without P3 being run.

View File

@@ -1,5 +1,55 @@
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
const isProd = process.env.NODE_ENV === 'production';
/**
* Security headers applied to every response. Per audit-pass-#3 finding:
* the previous config emitted no CSP, X-Frame-Options, HSTS, or
* X-Content-Type-Options — the app was open to clickjacking + MIME
* sniffing.
*
* CSP notes:
* - 'unsafe-inline' on style-src is required by Tailwind's runtime
* style injection and Radix; revisit when Tailwind v4 ships a
* nonce story.
* - 'unsafe-eval' on script-src is dev-only — Next dev uses eval for
* HMR. Production drops it.
* - connect-src allows ws/wss for Socket.IO and https: for outgoing
* fetches; tighten in prod via per-port branding URLs once we move
* the s3 image references into a known allowlist.
* - img-src https: is wide because port branding pulls from
* s3.portnimara.com plus per-port image URLs configured at runtime.
*/
// Dev-only allow-list: react-grab (the in-page click-to-source devtool)
// is fetched from unpkg, so script/style/connect must allow it. Strip
// these entries in prod via the conditional below.
const devScriptHosts = isProd ? '' : ' http://unpkg.com https://unpkg.com';
const devConnectHosts = isProd ? '' : ' http://unpkg.com https://unpkg.com';
const csp = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${isProd ? '' : " 'unsafe-eval'"}${devScriptHosts}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
`connect-src 'self' ws: wss: https:${devConnectHosts}`,
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
].join('; ');
const securityHeaders = [
{ key: 'Content-Security-Policy', value: csp },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(self), microphone=(), geolocation=()' },
...(isProd
? [{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }]
: []),
];
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
serverExternalPackages: [ serverExternalPackages: [
@@ -24,6 +74,14 @@ const nextConfig: NextConfig = {
// process.cwd() requires the file to be traced explicitly. // process.cwd() requires the file to be traced explicitly.
'/api/v1/document-templates/**': ['./assets/eoi-template.pdf'], '/api/v1/document-templates/**': ['./assets/eoi-template.pdf'],
}, },
async headers() {
return [
{
source: '/:path*',
headers: securityHeaders,
},
];
},
}; };
export default nextConfig; export default nextConfig;

View File

@@ -4,6 +4,10 @@ proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection ""; proxy_set_header Connection "";
# Defense-in-depth for CVE-2025-29927: strip the header attackers use to
# skip Next.js middleware. Patched in next>=15.2.3, but neutralizing the
# input at the edge means a future regression cannot reopen the bypass.
proxy_set_header X-Middleware-Subrequest "";
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
proxy_read_timeout 60s; proxy_read_timeout 60s;
proxy_send_timeout 60s; proxy_send_timeout 60s;

View File

@@ -2,6 +2,7 @@
"name": "port-nimara-crm", "name": "port-nimara-crm",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "pnpm@10.33.2",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build && pnpm build:server", "build": "next build && pnpm build:server",
@@ -14,6 +15,11 @@
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:seed": "tsx src/lib/db/seed.ts", "db:seed": "tsx src/lib/db/seed.ts",
"db:seed:realistic": "tsx src/lib/db/seed.ts",
"db:seed:synthetic": "tsx src/lib/db/seed-synthetic.ts",
"db:reset": "tsx scripts/db-reset.ts --confirm",
"db:reseed:realistic": "pnpm db:reset && pnpm db:seed:realistic",
"db:reseed:synthetic": "pnpm db:reset && pnpm db:seed:synthetic",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:smoke": "playwright test --project=smoke", "test:e2e:smoke": "playwright test --project=smoke",
"test:e2e:exhaustive": "playwright test --project=exhaustive", "test:e2e:exhaustive": "playwright test --project=exhaustive",
@@ -52,6 +58,8 @@
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@tanstack/react-query-devtools": "^5.62.0", "@tanstack/react-query-devtools": "^5.62.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/pdfkit": "^0.17.6",
"archiver": "^7.0.1",
"better-auth": "^1.2.0", "better-auth": "^1.2.0",
"bullmq": "^5.25.0", "bullmq": "^5.25.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
@@ -61,34 +69,43 @@
"drizzle-orm": "^0.38.0", "drizzle-orm": "^0.38.0",
"imapflow": "^1.2.13", "imapflow": "^1.2.13",
"ioredis": "^5.4.0", "ioredis": "^5.4.0",
"iso-3166-2": "^1.0.0",
"jose": "^6.2.1", "jose": "^6.2.1",
"libphonenumber-js": "^1.12.42",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
"mailparser": "^3.9.4", "mailparser": "^3.9.4",
"minio": "^8.0.0", "minio": "^8.0.0",
"next": "15.1.0", "next": "15.2.9",
"next-themes": "^0.4.0", "next-themes": "^0.4.0",
"nodemailer": "^6.9.0", "nodemailer": "^6.9.0",
"openai": "^6.27.0", "openai": "^6.27.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfkit": "^0.18.0",
"pino": "^9.5.0", "pino": "^9.5.0",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"postgres": "^3.4.0", "postgres": "^3.4.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-day-picker": "^9.14.0", "react-day-picker": "^9.14.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-easy-crop": "^5.5.7",
"react-hook-form": "^7.54.0", "react-hook-form": "^7.54.0",
"recharts": "^3.8.0", "recharts": "^3.8.0",
"sharp": "^0.34.5",
"socket.io": "^4.8.0", "socket.io": "^4.8.0",
"socket.io-client": "^4.8.0", "socket.io-client": "^4.8.0",
"sonner": "^1.7.0", "sonner": "^1.7.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tesseract.js": "^7.0.0",
"vaul": "^1.1.2",
"zod": "^3.24.0", "zod": "^3.24.0",
"zustand": "^5.0.0" "zustand": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.5", "@eslint/eslintrc": "^3.3.5",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@types/archiver": "^7.0.0",
"@types/iso-3166-2": "^1.0.4",
"@types/mailparser": "^3.4.6", "@types/mailparser": "^3.4.6",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/nodemailer": "^6.4.0", "@types/nodemailer": "^6.4.0",
@@ -100,12 +117,13 @@
"drizzle-kit": "^0.30.0", "drizzle-kit": "^0.30.0",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-next": "15.1.0", "eslint-config-next": "15.2.9",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"husky": "^9.1.0", "husky": "^9.1.0",
"lint-staged": "^15.2.0", "lint-staged": "^15.2.0",
"postcss": "^8.4.0", "postcss": "^8.4.0",
"prettier": "^3.4.0", "prettier": "^3.4.0",
"react-grab": "^0.1.32",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",

View File

@@ -75,6 +75,24 @@ export default defineConfig({
viewport: { width: 1440, height: 900 }, viewport: { width: 1440, height: 900 },
}, },
}, },
{
// Mobile / tablet audit — visits every page in headed Chromium at iPhone
// viewports (portrait), screenshots full-page to .audit/mobile/<viewport>/,
// and writes an index.md. Depends on `setup` for seeded admin + port-role.
name: 'mobile-audit',
testMatch: /audit\/mobile\.spec\.ts/,
dependencies: ['setup'],
// Single test walks 4 viewports × ~45 routes sequentially with slowMo;
// 30 min headroom keeps us well under the wall-clock cost.
timeout: 1_800_000,
use: {
headless: false,
launchOptions: { slowMo: 200 },
screenshot: 'off',
video: 'off',
trace: 'off',
},
},
], ],
// Don't start the dev server — we expect it to already be running // Don't start the dev server — we expect it to already be running

1266
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

30
public/manifest.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "Port Nimara CRM",
"short_name": "Port Nimara",
"description": "Marina/port management CRM",
"start_url": "/",
"display": "standalone",
"background_color": "#f2f2f2",
"theme_color": "#0f172a",
"orientation": "any",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

@@ -0,0 +1,188 @@
/**
* Permission-matrix audit.
*
* Walks every src/app/api/v1/** /route.ts file and reports each exported HTTP
* handler (GET/POST/PUT/PATCH/DELETE) that is *not* wrapped in withPermission().
* Internal v1 routes should be permission-gated; routes that intentionally use
* withAuth() alone (e.g. user-self endpoints) can be allow-listed below.
*
* Run:
* pnpm tsx scripts/audit-permissions.ts
*
* Exit code:
* 0 — every handler is permission-gated or in the allow-list
* 1 — at least one handler is missing both a withPermission wrapper and an
* allow-list entry. CI should fail.
*/
import { readdir, readFile } from 'node:fs/promises';
import { join, relative } from 'node:path';
const ROOT = join(process.cwd(), 'src/app/api/v1');
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
/**
* Routes intentionally exempt from withPermission. Each entry should explain
* why — typically because the route operates on the caller's own resources
* (no port-level permission semantics) or is admin-only and gated by
* isSuperAdmin inside the handler.
*/
const ALLOW_LIST: ReadonlyArray<{ pattern: RegExp; reason: string }> = [
// Self / admin / public
{ pattern: /\/me\/route\.ts$/, reason: 'Self-endpoint — auth is sufficient.' },
{ pattern: /\/admin\//, reason: 'Admin-only — gated by isSuperAdmin inside handler.' },
{
pattern: /\/notifications\//,
reason: 'User-scoped notifications — caller is the resource owner.',
},
{ pattern: /\/socket\//, reason: 'Socket auth handshake.' },
{ pattern: /\/health\//, reason: 'Public health check.' },
{ pattern: /\/users\/me\//, reason: 'User-self preferences — caller is the resource owner.' },
{ pattern: /\/saved-views\//, reason: 'User-self saved views — caller is the resource owner.' },
{
pattern: /\/settings\/feature-flag\//,
reason: 'Public read of feature-flag bool — no PII; auth is sufficient.',
},
// Cross-cutting / port-scoped reference data
{ pattern: /\/tags\//, reason: 'Tags are cross-cutting reference data; port-scoped via auth.' },
{
pattern: /\/currency\/(convert|rates)\/route\.ts$/,
reason: 'Currency reference data; port-scoped, no PII.',
},
{
pattern: /\/currency\/rates\/refresh\//,
reason: 'TODO: gate with admin:manage_settings — currently allow-listed.',
},
{
pattern: /\/search\//,
reason: 'Port-scoped search — results filtered by auth context (resources have own perms).',
},
// Alerts surface in topbar/dashboard for every signed-in user; per-port not per-resource.
{ pattern: /\/alerts\//, reason: 'Alerts are user-scoped; port-filtered via auth context.' },
// Internally gated by isSuperAdmin
{
pattern: /\/expenses\/export\/parent-company\//,
reason: 'Internally gated by isSuperAdmin inside the handler.',
},
// Pending dedicated permissions
{
pattern: /\/ai\//,
reason: 'TODO: needs ai:* permission catalog entry. Currently allow-listed.',
},
{
pattern: /\/custom-fields\/\[entityId\]\//,
reason: 'TODO: needs custom_fields:* permission. PUT path internally validated.',
},
{
pattern: /\/berth-reservations\/\[id\]\/route\.ts$/,
reason: 'TODO: PATCH should map to reservations:edit (not currently in catalog).',
},
];
interface Finding {
file: string;
method: string;
reason: 'no-withPermission' | 'no-withAuth' | 'allow-listed';
allowReason?: string;
}
async function* walk(dir: string): AsyncGenerator<string> {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) yield* walk(path);
else if (entry.isFile() && entry.name === 'route.ts') yield path;
}
}
function isAllowListed(file: string): { allowed: boolean; reason?: string } {
for (const { pattern, reason } of ALLOW_LIST) {
if (pattern.test(file)) return { allowed: true, reason };
}
return { allowed: false };
}
async function auditFile(file: string): Promise<Finding[]> {
const src = await readFile(file, 'utf-8');
const findings: Finding[] = [];
for (const method of HTTP_METHODS) {
// Match: export const GET = withAuth(...
const declRe = new RegExp(`export\\s+const\\s+${method}\\s*=\\s*(.+?);`, 's');
const m = declRe.exec(src);
if (!m) continue;
const block = m[1] ?? '';
const hasAuth = /withAuth\s*\(/.test(block);
const hasPerm = /withPermission\s*\(/.test(block);
const allow = isAllowListed(file);
if (!hasAuth) {
findings.push({ file, method, reason: 'no-withAuth' });
continue;
}
if (!hasPerm) {
if (allow.allowed) {
findings.push({ file, method, reason: 'allow-listed', allowReason: allow.reason });
} else {
findings.push({ file, method, reason: 'no-withPermission' });
}
}
}
return findings;
}
async function main() {
const files: string[] = [];
for await (const f of walk(ROOT)) files.push(f);
files.sort();
const all: Finding[] = [];
for (const f of files) all.push(...(await auditFile(f)));
const violations = all.filter(
(f) => f.reason === 'no-withPermission' || f.reason === 'no-withAuth',
);
const allowListed = all.filter((f) => f.reason === 'allow-listed');
// Markdown report
const lines: string[] = [];
lines.push('# Permission Matrix Audit');
lines.push('');
lines.push(`Scanned ${files.length} route files under \`src/app/api/v1/\`.`);
lines.push('');
if (violations.length === 0) {
lines.push('**No violations.** Every internal v1 handler is permission-gated.');
} else {
lines.push(`**${violations.length} violation(s):**`);
lines.push('');
lines.push('| File | Method | Issue |');
lines.push('| --- | --- | --- |');
for (const v of violations) {
const rel = relative(process.cwd(), v.file);
lines.push(`| \`${rel}\` | ${v.method} | ${v.reason} |`);
}
}
lines.push('');
lines.push(
`**Allow-listed:** ${allowListed.length} handler(s) intentionally skip \`withPermission\`.`,
);
if (allowListed.length > 0) {
lines.push('');
lines.push('| File | Method | Reason |');
lines.push('| --- | --- | --- |');
for (const a of allowListed) {
const rel = relative(process.cwd(), a.file);
lines.push(`| \`${rel}\` | ${a.method} | ${a.allowReason} |`);
}
}
process.stdout.write(lines.join('\n') + '\n');
process.exit(violations.length > 0 ? 1 : 0);
}
main().catch((err) => {
console.error(err);
process.exit(2);
});

View File

@@ -0,0 +1,135 @@
/**
* One-shot: backfill `interests.source` for legacy NocoDB-imported rows.
*
* Why this exists: the legacy NocoDB Interests table left the `Source`
* column null for ~95 % of rows. The migration mapped null → null, so the
* Lead Source Attribution chart shows them as "Unspecified". Per the
* operator's best knowledge, almost all of those legacy rows came in
* through the website (web form / portal) — the few that didn't are the
* ones that already carry an explicit `Source` value (Form / portal /
* External). Defaulting null → 'website' is therefore the closest
* truth we can reconstruct without per-row sales notes review.
*
* Idempotent: only updates rows where `source IS NULL` AND the row has a
* `migration_source_links` entry tying it back to the legacy NocoDB import,
* so net-new manually-created interests with null source aren't touched.
*
* Usage:
* pnpm tsx scripts/backfill-legacy-lead-source.ts --port-slug port-nimara [--dry-run]
*/
import 'dotenv/config';
import { eq, and, isNull, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { interests } from '@/lib/db/schema/interests';
import { migrationSourceLinks } from '@/lib/db/schema/migration';
interface CliArgs {
portSlug: string | null;
dryRun: boolean;
}
function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = { portSlug: null, dryRun: false };
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i]!;
if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
else if (a === '--dry-run') args.dryRun = true;
else if (a === '-h' || a === '--help') {
console.log(
'Usage: pnpm tsx scripts/backfill-legacy-lead-source.ts --port-slug <slug> [--dry-run]',
);
process.exit(0);
}
}
if (!args.portSlug) {
console.error('Missing required --port-slug');
process.exit(1);
}
return args;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const [port] = await db
.select({ id: ports.id, name: ports.name })
.from(ports)
.where(eq(ports.slug, args.portSlug!))
.limit(1);
if (!port) {
console.error(`No port found with slug "${args.portSlug}"`);
process.exit(1);
}
console.log(`[backfill] target: ${port.name} (${port.id})`);
// Pull every interest id this port owns that has a NULL source.
const candidateInterests = await db
.select({ id: interests.id })
.from(interests)
.where(and(eq(interests.portId, port.id), isNull(interests.source)));
console.log(`[backfill] interests with NULL source in this port: ${candidateInterests.length}`);
if (candidateInterests.length === 0) {
console.log('Nothing to backfill.');
return;
}
// Filter to ONLY those that came in via the legacy migration — preserves
// null on net-new rows where the operator hasn't picked a source yet.
const candidateIds = candidateInterests.map((r) => r.id);
const legacyLinks = await db
.select({ targetEntityId: migrationSourceLinks.targetEntityId })
.from(migrationSourceLinks)
.where(
and(
eq(migrationSourceLinks.sourceSystem, 'nocodb_interests'),
eq(migrationSourceLinks.targetEntityType, 'interest'),
inArray(migrationSourceLinks.targetEntityId, candidateIds),
),
);
const legacyIds = new Set(legacyLinks.map((l) => l.targetEntityId));
const toUpdate = candidateIds.filter((id) => legacyIds.has(id));
console.log(
`[backfill] of those, ${toUpdate.length} are legacy migration rows (will set source='website')`,
);
console.log(
`[backfill] ${candidateInterests.length - toUpdate.length} are net-new rows (left untouched)`,
);
if (args.dryRun) {
console.log('[backfill] --dry-run set; no writes.');
return;
}
if (toUpdate.length === 0) {
console.log('Nothing to write.');
return;
}
// Update in chunks of 500 to keep query size sane.
const CHUNK = 500;
let updated = 0;
for (let i = 0; i < toUpdate.length; i += CHUNK) {
const chunk = toUpdate.slice(i, i + CHUNK);
// Belt-and-suspenders: re-assert `source IS NULL` in the WHERE so
// a concurrent process that set source on one of these rows
// between SELECT and UPDATE doesn't get its value clobbered.
const result = await db
.update(interests)
.set({ source: 'website' })
.where(and(inArray(interests.id, chunk), isNull(interests.source)))
.returning({ id: interests.id });
updated += result.length;
}
console.log(`[backfill] updated ${updated} rows.`);
}
main().catch((err) => {
console.error('FATAL', err);
process.exit(1);
});

View File

@@ -0,0 +1,144 @@
/**
* Backfill `client_contacts.value_e164` from `value` for phone / whatsapp
* contacts where it's null or empty.
*
* The legacy seed (and pre-normalization production data) stored phone
* numbers in `value` as free text — "+33 4 93 00 0002" — but `value_e164`
* is what every UI surface and dedup matcher reads. This script runs the
* raw `value` through libphonenumber-js (via the script-safe wrapper to
* avoid the Node 25 metadata-loader bug) and writes the canonical E.164
* form back.
*
* Usage:
* pnpm tsx scripts/backfill-phone-e164.ts # dry-run report
* pnpm tsx scripts/backfill-phone-e164.ts --apply # actually write
*
* The dry-run report prints, for each unparseable row, the contact id +
* raw value so you can hand-clean before re-running.
*/
import 'dotenv/config';
import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clientContacts } from '@/lib/db/schema/clients';
import { parsePhoneScriptSafe } from '@/lib/dedup/phone-parse';
import type { CountryCode } from '@/lib/i18n/countries';
const APPLY = process.argv.includes('--apply');
interface PhoneRow {
id: string;
channel: string;
value: string | null;
valueCountry: string | null;
}
async function main() {
console.log(`Phone E.164 backfill — ${APPLY ? 'APPLY MODE' : 'dry-run'}`);
console.log('');
// Find candidate rows: phone or whatsapp contacts with a `value` set but
// `value_e164` null/empty.
const rows: PhoneRow[] = await db
.select({
id: clientContacts.id,
channel: clientContacts.channel,
value: clientContacts.value,
valueCountry: clientContacts.valueCountry,
})
.from(clientContacts)
.where(
and(
inArray(clientContacts.channel, ['phone', 'whatsapp']),
or(isNull(clientContacts.valueE164), eq(clientContacts.valueE164, '')),
sql`${clientContacts.value} IS NOT NULL AND ${clientContacts.value} <> ''`,
),
);
console.log(` found ${rows.length} candidate rows`);
let parsedFull = 0;
let parsedE164Only = 0;
let unparseable = 0;
const updates: Array<{
id: string;
valueE164: string;
valueCountry: CountryCode | null;
}> = [];
const fails: Array<{ id: string; value: string; reason: string }> = [];
for (const row of rows) {
if (!row.value) continue;
const defaultCountry = (row.valueCountry as CountryCode | null) ?? undefined;
const parsed1 = parsePhoneScriptSafe(row.value, defaultCountry);
if (parsed1.e164 && parsed1.country) {
// Both e164 + country resolved — best case.
updates.push({ id: row.id, valueE164: parsed1.e164, valueCountry: parsed1.country });
parsedFull++;
} else if (parsed1.e164) {
// E.164 came back but country didn't (e.g. UK +44 7700 900xxx
// fictional/reserved range — libphonenumber returns the e164 form
// but refuses to assign a country). Still safe to write — the e164
// is canonical. Country stays null.
updates.push({
id: row.id,
valueE164: parsed1.e164,
valueCountry: (row.valueCountry as CountryCode | null) ?? null,
});
parsedE164Only++;
} else {
fails.push({
id: row.id,
value: row.value,
reason: row.value.trim().startsWith('+')
? 'has + prefix but parse failed'
: 'no leading + and no country hint',
});
unparseable++;
}
}
console.log('');
console.log(' ✓ parsed cleanly (e164 + country)', parsedFull);
console.log(' ✓ parsed e164 only (no country) ', parsedE164Only);
console.log(' ✗ unparseable ', unparseable);
console.log('');
if (fails.length > 0) {
console.log('Failures (first 10):');
for (const f of fails.slice(0, 10)) {
console.log(` [${f.id}] "${f.value}" — ${f.reason}`);
}
console.log('');
}
if (!APPLY) {
console.log('Dry-run only. Re-run with --apply to write the updates.');
return;
}
if (updates.length === 0) {
console.log('No updates to write.');
return;
}
console.log(`Writing ${updates.length} updates...`);
for (const u of updates) {
await db
.update(clientContacts)
.set({
valueE164: u.valueE164,
valueCountry: u.valueCountry,
})
.where(eq(clientContacts.id, u.id));
}
console.log(` ✓ wrote ${updates.length} rows`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Hourly MinIO mirror for Port Nimara CRM.
#
# Mirrors the live `MINIO_BUCKET` to the backup destination. `mc mirror`
# is incremental — only changed objects transfer — so this is cheap.
#
# Versioning on the destination bucket is what protects against object
# deletes / overwrites; we don't try to roll our own.
set -euo pipefail
: "${MINIO_ENDPOINT:?MINIO_ENDPOINT not set}"
: "${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY not set}"
: "${MINIO_SECRET_KEY:?MINIO_SECRET_KEY not set}"
: "${MINIO_BUCKET:?MINIO_BUCKET not set}"
: "${BACKUP_S3_BUCKET:?BACKUP_S3_BUCKET not set}"
: "${BACKUP_S3_ENDPOINT:?BACKUP_S3_ENDPOINT not set}"
: "${BACKUP_S3_ACCESS_KEY:?BACKUP_S3_ACCESS_KEY not set}"
: "${BACKUP_S3_SECRET_KEY:?BACKUP_S3_SECRET_KEY not set}"
# Default scheme: live MinIO is plain HTTP unless MINIO_USE_SSL=true.
LIVE_URL="${MINIO_ENDPOINT}"
if [[ "${MINIO_USE_SSL:-false}" == "true" ]]; then
LIVE_URL="https://${MINIO_ENDPOINT}:${MINIO_PORT:-443}"
else
LIVE_URL="http://${MINIO_ENDPOINT}:${MINIO_PORT:-9000}"
fi
LIVE_ALIAS="live-$$"
BACKUP_ALIAS="bk-$$"
trap 'mc alias remove "$LIVE_ALIAS" 2>/dev/null || true; mc alias remove "$BACKUP_ALIAS" 2>/dev/null || true' EXIT
mc alias set "$LIVE_ALIAS" "$LIVE_URL" \
"$MINIO_ACCESS_KEY" "$MINIO_SECRET_KEY" --api S3v4 >/dev/null
mc alias set "$BACKUP_ALIAS" "$BACKUP_S3_ENDPOINT" \
"$BACKUP_S3_ACCESS_KEY" "$BACKUP_S3_SECRET_KEY" --api S3v4 >/dev/null
SOURCE="${LIVE_ALIAS}/${MINIO_BUCKET}/"
DEST="${BACKUP_ALIAS}/${BACKUP_S3_BUCKET}/minio/"
echo "[$(date -u +%FT%TZ)] Mirroring $SOURCE$DEST"
# `--remove` would delete objects from the destination that no longer
# exist in source — we DON'T pass it, because that would let an
# accidental delete on the live bucket cascade into permanent loss on
# the backup side. Versioning + lifecycle handle stale-object cleanup.
mc mirror --quiet --overwrite "$SOURCE" "$DEST"
# Print byte / count diff for the operator.
echo "[$(date -u +%FT%TZ)] Done. Destination summary:"
mc du "$DEST"

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Hourly PostgreSQL backup for Port Nimara CRM.
#
# Reads DATABASE_URL and BACKUP_S3_* from the environment. Dumps to a
# tmpfile, gzips, optionally GPG-encrypts to BACKUP_GPG_RECIPIENT, and
# uploads to s3://${BACKUP_S3_BUCKET}/pg/<hostname>/<UTC-date>/<hour>.dump.gz[.gpg].
#
# Designed to fail loud: any non-zero exit halts the script and propagates
# to the cron / CI runner so the operator sees the failure.
set -euo pipefail
: "${DATABASE_URL:?DATABASE_URL not set}"
: "${BACKUP_S3_BUCKET:?BACKUP_S3_BUCKET not set}"
: "${BACKUP_S3_ENDPOINT:?BACKUP_S3_ENDPOINT not set}"
: "${BACKUP_S3_ACCESS_KEY:?BACKUP_S3_ACCESS_KEY not set}"
: "${BACKUP_S3_SECRET_KEY:?BACKUP_S3_SECRET_KEY not set}"
HOST="${BACKUP_HOST_OVERRIDE:-$(hostname -s)}"
DATE_UTC="$(date -u +%Y-%m-%d)"
HOUR_UTC="$(date -u +%H)"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
DUMP_FILE="$WORKDIR/${HOUR_UTC}.dump"
ARCHIVE_NAME="${HOUR_UTC}.dump.gz"
echo "[$(date -u +%FT%TZ)] Dumping $DATABASE_URL$DUMP_FILE"
pg_dump --format=custom --compress=9 --no-owner --no-privileges \
--file="$DUMP_FILE" "$DATABASE_URL"
# pg_dump's `custom` format is already compressed, but we wrap in gzip so
# the file looks the same regardless of the dump format on disk.
gzip -n "$DUMP_FILE"
GZ_FILE="${DUMP_FILE}.gz"
# Optional GPG layer. Only encrypt if the recipient is configured.
if [[ -n "${BACKUP_GPG_RECIPIENT:-}" ]]; then
echo "[$(date -u +%FT%TZ)] Encrypting for $BACKUP_GPG_RECIPIENT"
gpg --batch --yes --trust-model always \
--recipient "$BACKUP_GPG_RECIPIENT" \
--encrypt --output "${GZ_FILE}.gpg" "$GZ_FILE"
rm "$GZ_FILE"
GZ_FILE="${GZ_FILE}.gpg"
ARCHIVE_NAME="${ARCHIVE_NAME}.gpg"
fi
# Configure mc client for the backup destination.
MC_ALIAS="bk-$$"
mc alias set "$MC_ALIAS" "$BACKUP_S3_ENDPOINT" \
"$BACKUP_S3_ACCESS_KEY" "$BACKUP_S3_SECRET_KEY" \
--api S3v4 >/dev/null
REMOTE_PATH="${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/${DATE_UTC}/${ARCHIVE_NAME}"
echo "[$(date -u +%FT%TZ)] Uploading → $REMOTE_PATH"
mc cp --quiet "$GZ_FILE" "$REMOTE_PATH"
# Tag with retention metadata so lifecycle rules can decide what to expire.
mc tag set "$REMOTE_PATH" "kind=hourly&host=${HOST}&date=${DATE_UTC}" >/dev/null
mc alias remove "$MC_ALIAS" >/dev/null
echo "[$(date -u +%FT%TZ)] OK ${ARCHIVE_NAME} ($(du -h "$GZ_FILE" | cut -f1))"

121
scripts/backup/restore.sh Normal file
View File

@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# Cold-restore script for Port Nimara CRM.
#
# Two modes:
# --drill Restore to a sandbox DB ($DRILL_DATABASE_URL) + a tagged
# sandbox path on the live MinIO bucket. Used by the weekly
# cron drill so the runbook stays accurate.
# (no --drill) Interactive production restore. Prompts before each
# destructive step; refuses to run if the live DB has
# non-empty tables (caller is expected to drop first).
#
# Common args:
# --snapshot YYYY-MM-DD/HH Specific dump to restore. Defaults to "latest".
set -euo pipefail
DRILL=0
SNAPSHOT="latest"
while [[ $# -gt 0 ]]; do
case "$1" in
--drill) DRILL=1; shift ;;
--snapshot) SNAPSHOT="$2"; shift 2 ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
: "${BACKUP_S3_BUCKET:?BACKUP_S3_BUCKET not set}"
: "${BACKUP_S3_ENDPOINT:?BACKUP_S3_ENDPOINT not set}"
: "${BACKUP_S3_ACCESS_KEY:?BACKUP_S3_ACCESS_KEY not set}"
: "${BACKUP_S3_SECRET_KEY:?BACKUP_S3_SECRET_KEY not set}"
if [[ "$DRILL" -eq 1 ]]; then
: "${DRILL_DATABASE_URL:?DRILL_DATABASE_URL not set}"
TARGET_DB="$DRILL_DATABASE_URL"
echo "[drill] target DB = $TARGET_DB"
else
: "${DATABASE_URL:?DATABASE_URL not set}"
TARGET_DB="$DATABASE_URL"
read -rp "About to overwrite $TARGET_DB. Type 'restore' to continue: " confirm
[[ "$confirm" == "restore" ]] || { echo "aborted"; exit 1; }
fi
HOST="${BACKUP_HOST_OVERRIDE:-$(hostname -s)}"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
MC_ALIAS="bk-$$"
mc alias set "$MC_ALIAS" "$BACKUP_S3_ENDPOINT" \
"$BACKUP_S3_ACCESS_KEY" "$BACKUP_S3_SECRET_KEY" --api S3v4 >/dev/null
trap 'rm -rf "$WORKDIR"; mc alias remove "$MC_ALIAS" 2>/dev/null || true' EXIT
# Resolve the snapshot path.
if [[ "$SNAPSHOT" == "latest" ]]; then
REMOTE=$(mc ls --recursive "${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/" \
| awk '{print $NF}' | sort | tail -1)
if [[ -z "$REMOTE" ]]; then
echo "no snapshots found under ${BACKUP_S3_BUCKET}/pg/${HOST}/" >&2
exit 1
fi
REMOTE="${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/${REMOTE}"
else
REMOTE="${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/${SNAPSHOT}.dump.gz"
# If GPG was used, the file lives at .dump.gz.gpg. Try both.
if ! mc stat "$REMOTE" >/dev/null 2>&1; then
REMOTE="${REMOTE}.gpg"
fi
fi
echo "[$(date -u +%FT%TZ)] Pulling $REMOTE"
LOCAL="$WORKDIR/$(basename "$REMOTE")"
mc cp --quiet "$REMOTE" "$LOCAL"
# Decrypt if needed.
if [[ "$LOCAL" == *.gpg ]]; then
echo "[$(date -u +%FT%TZ)] Decrypting"
gpg --batch --yes --decrypt --output "${LOCAL%.gpg}" "$LOCAL"
rm "$LOCAL"
LOCAL="${LOCAL%.gpg}"
fi
# Decompress.
gunzip "$LOCAL"
LOCAL="${LOCAL%.gz}"
echo "[$(date -u +%FT%TZ)] Restoring into $TARGET_DB"
# Drop & recreate to guarantee no half-state from a prior run.
DB_NAME=$(echo "$TARGET_DB" | sed -E 's|.*/([^?]+).*|\1|')
ADMIN_URL=$(echo "$TARGET_DB" | sed -E "s|/${DB_NAME}|/postgres|")
psql "$ADMIN_URL" -v ON_ERROR_STOP=1 <<SQL
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname = '${DB_NAME}' AND pid <> pg_backend_pid();
DROP DATABASE IF EXISTS "${DB_NAME}";
CREATE DATABASE "${DB_NAME}";
SQL
pg_restore --no-owner --no-privileges --dbname "$TARGET_DB" "$LOCAL"
# Drill mode: compare row counts vs the live producer for parity.
if [[ "$DRILL" -eq 1 ]]; then
echo "[$(date -u +%FT%TZ)] Drill row-count diff (live vs restored):"
TABLES=$(psql -At "$TARGET_DB" -c \
"SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename;")
diff_count=0
while IFS= read -r tbl; do
[[ -z "$tbl" ]] && continue
live=$(psql -At "${LIVE_DATABASE_URL:-$DATABASE_URL}" -c "SELECT count(*) FROM \"$tbl\";")
restored=$(psql -At "$TARGET_DB" -c "SELECT count(*) FROM \"$tbl\";")
delta=$((live - restored))
if [[ "$delta" -ne 0 ]]; then
echo "$tbl: live=$live restored=$restored delta=$delta"
diff_count=$((diff_count + 1))
fi
done <<< "$TABLES"
if [[ "$diff_count" -eq 0 ]]; then
echo " ✓ row counts match across all tables"
fi
fi
echo "[$(date -u +%FT%TZ)] Restore complete."

97
scripts/db-reset.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* Wipe all data from the database, preserving schema + drizzle migration
* history. Run before swapping seed fixtures.
*
* pnpm tsx scripts/db-reset.ts (refuses without --confirm)
* pnpm tsx scripts/db-reset.ts --confirm
*
* Truncates every table in the `public` schema except the drizzle
* migration tracker, then resets sequences. Wraps the loop in a single
* transaction so a mid-wipe failure rolls back cleanly.
*
* Refuses to run when DATABASE_URL points at anything that doesn't look
* like a local/dev host. Override with --i-know-what-im-doing.
*/
import 'dotenv/config';
import postgres from 'postgres';
const url: string = process.env.DATABASE_URL ?? '';
if (!url) {
console.error('DATABASE_URL is not set; aborting.');
process.exit(1);
}
const args = new Set(process.argv.slice(2));
if (!args.has('--confirm')) {
console.error('Refusing to wipe without --confirm');
console.error('Run again as: pnpm tsx scripts/db-reset.ts --confirm');
process.exit(1);
}
// Best-effort safety: refuse for anything that doesn't look like a local DB.
function looksLocal(u: string): boolean {
try {
const parsed = new URL(u);
return (
parsed.hostname === 'localhost' ||
parsed.hostname === '127.0.0.1' ||
parsed.hostname === '::1' ||
parsed.hostname.endsWith('.local') ||
parsed.hostname.endsWith('.internal') ||
parsed.hostname === 'host.docker.internal' ||
// Docker compose service names commonly used here
parsed.hostname === 'postgres' ||
parsed.hostname === 'db'
);
} catch {
return false;
}
}
if (!looksLocal(url) && !args.has('--i-know-what-im-doing')) {
console.error(
`DATABASE_URL host doesn't look local. Refusing to wipe a remote DB without --i-know-what-im-doing.`,
);
process.exit(1);
}
const sql = postgres(url, { max: 1 });
async function main() {
console.log('Resetting database...');
console.log(` url: ${url.replace(/:[^:@]*@/, ':***@')}`);
const tables = await sql<{ tablename: string }[]>`
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
AND tablename NOT LIKE 'drizzle_%'
AND tablename != '__drizzle_migrations'
`;
if (tables.length === 0) {
console.log(' no user tables found, nothing to do.');
await sql.end();
return;
}
// Single TRUNCATE … CASCADE is faster than per-table loops and handles
// FK ordering for us. Quote table names defensively.
const tableList = tables.map((t) => `"public"."${t.tablename}"`).join(', ');
console.log(` truncating ${tables.length} tables...`);
await sql.unsafe(`TRUNCATE ${tableList} RESTART IDENTITY CASCADE`);
console.log(' done.');
await sql.end();
console.log('');
console.log('Database reset complete. Run a seed script next:');
console.log(' pnpm db:seed # realistic NocoDB-shaped fixture');
console.log(' pnpm db:seed:synthetic # one client per pipeline stage');
}
main().catch(async (err) => {
console.error('Reset failed:', err);
await sql.end().catch(() => undefined);
process.exit(1);
});

View File

@@ -0,0 +1,83 @@
/**
* Launch a headed Chromium with NO viewport override so it adopts the
* host monitor's natural size — useful when you want to drive the CRM
* manually and have full-screen real estate.
*
* Pre-fills the login form for the synthetic admin (admin@portnimara.test
* / SuperAdmin12345!) but does not submit; press Enter when ready.
*
* The script keeps running until the browser window is closed by the
* user or until you Ctrl-C.
*
* pnpm tsx scripts/dev-open-browser.ts # super_admin
* pnpm tsx scripts/dev-open-browser.ts sales_agent
* pnpm tsx scripts/dev-open-browser.ts viewer
* pnpm tsx scripts/dev-open-browser.ts --no-prefill
*/
import 'dotenv/config';
// @playwright/test re-exports the same chromium driver and is already
// installed as a dev dep; using it avoids needing to add the standalone
// `playwright` package as a separate dependency.
import { chromium } from '@playwright/test';
const USERS: Record<string, { email: string; password: string }> = {
super_admin: { email: 'admin@portnimara.test', password: 'SuperAdmin12345!' },
sales_agent: { email: 'agent@portnimara.test', password: 'SalesAgent12345!' },
viewer: { email: 'viewer@portnimara.test', password: 'ViewerUser12345!' },
};
const BASE_URL = process.env.DEV_BASE_URL ?? 'http://localhost:3000';
async function main() {
const args = process.argv.slice(2);
const noPrefill = args.includes('--no-prefill');
const role =
args.find((a) => !a.startsWith('--')) && USERS[args.find((a) => !a.startsWith('--'))!]
? args.find((a) => !a.startsWith('--'))!
: 'super_admin';
const user = USERS[role]!;
console.log(`Launching headed Chromium → ${BASE_URL}`);
console.log(` role: ${role} (${user.email})`);
const browser = await chromium.launch({
headless: false,
args: ['--start-maximized'],
});
// viewport: null lets the page fill the OS window. Combined with
// --start-maximized this matches the host monitor's natural size.
const context = await browser.newContext({ viewport: null });
const page = await context.newPage();
await page.goto(`${BASE_URL}/login`);
if (!noPrefill) {
try {
await page.waitForSelector('#email', { timeout: 5000 });
await page.fill('#email', user.email);
await page.fill('#password', user.password);
console.log(' Login form pre-filled — press Enter in the browser to submit.');
} catch {
console.log(' Could not find login form (page may have redirected).');
}
}
console.log('');
console.log("Browser is open. Close it when you're done; the script will exit.");
console.log('Or Ctrl-C here to force-quit.');
// Keep the process alive until the browser window is closed.
await new Promise<void>((resolve) => {
browser.on('disconnected', () => resolve());
});
await browser.close().catch(() => undefined);
process.exit(0);
}
main().catch((err) => {
console.error('Open-browser failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,52 @@
/**
* Dev-only smoke check for the berth recommender. Resolves the first
* port-nimara interest (with desired dims set) and prints the top-N
* recommendations.
*
* pnpm tsx scripts/dev-recommender-smoke.ts
*/
import 'dotenv/config';
import { eq, isNotNull, and } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { interests } from '@/lib/db/schema/interests';
import { recommendBerths } from '@/lib/services/berth-recommender.service';
async function main() {
const [port] = await db
.select({ id: ports.id })
.from(ports)
.where(eq(ports.slug, 'port-nimara'))
.limit(1);
if (!port) throw new Error('port-nimara not found');
const [interest] = await db
.select({ id: interests.id })
.from(interests)
.where(and(eq(interests.portId, port.id), isNotNull(interests.desiredLengthFt)))
.limit(1);
if (!interest) throw new Error('No interest with desired dims set');
console.log(`> Recommending berths for interest ${interest.id} on port ${port.id}`);
const recs = await recommendBerths({
interestId: interest.id,
portId: port.id,
});
console.log(`> ${recs.length} recommendations:`);
for (const r of recs) {
console.log(
` ${r.mooringNumber.padEnd(5)} tier=${r.tier} fit=${r.fitScore} ` +
`${r.lengthFt}×${r.widthFt}×${r.draftFt} ft buf=${r.sizeBufferPct}% ` +
`${r.reasons.dimensional}; ${r.reasons.pipeline}`,
);
}
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,40 @@
/**
* Dev helper: set a user's password directly (bypasses email reset).
* Usage: pnpm tsx scripts/dev-set-password.ts <email> <password>
*/
import 'dotenv/config';
import { hashPassword } from 'better-auth/crypto';
import { eq, and } from 'drizzle-orm';
import { db } from '@/lib/db';
import { user, account } from '@/lib/db/schema/users';
async function main() {
const [, , email, password] = process.argv;
if (!email || !password) {
console.error('Usage: pnpm tsx scripts/dev-set-password.ts <email> <password>');
process.exit(1);
}
const u = await db.query.user.findFirst({ where: eq(user.email, email) });
if (!u) {
console.error(`User not found: ${email}`);
process.exit(1);
}
const hash = await hashPassword(password);
const result = await db
.update(account)
.set({ password: hash, updatedAt: new Date() })
.where(and(eq(account.userId, u.id), eq(account.providerId, 'credential')))
.returning({ id: account.id });
if (result.length === 0) {
console.error(`No credential account row for ${email}`);
process.exit(1);
}
console.log(`Updated password for ${email} (account id ${result[0]?.id}).`);
process.exit(0);
}
main();

View File

@@ -20,7 +20,15 @@ async function main() {
const isSuperAdmin = args.includes('--super'); const isSuperAdmin = args.includes('--super');
const name = args.find((a, i) => i > 0 && !a.startsWith('--')); const name = args.find((a, i) => i > 0 && !a.startsWith('--'));
const { inviteId, link } = await createCrmInvite({ email, name, isSuperAdmin }); // Dev script runs out-of-band (no HTTP request, no session). The service's
// super-admin gate requires `invitedBy.isSuperAdmin === true` for super
// invites; the script bypasses that with a synthetic caller identity.
const { inviteId, link } = await createCrmInvite({
email,
name,
isSuperAdmin,
invitedBy: { userId: 'cli-script', isSuperAdmin: true },
});
console.log(`✓ Invite created (id=${inviteId})`); console.log(`✓ Invite created (id=${inviteId})`);
console.log(` email: ${email}`); console.log(` email: ${email}`);
console.log(` super_admin: ${isSuperAdmin}`); console.log(` super_admin: ${isSuperAdmin}`);

View File

@@ -0,0 +1,409 @@
/**
* Idempotent NocoDB Berths → CRM `berths` import.
*
* Re-running picks up NocoDB additions/edits without clobbering CRM-side
* overrides: rows where `updated_at > last_imported_at` are treated as
* human-edited and skipped (use `--force` to override). Map Data JSON
* is validated and upserted into `berth_map_data` as a separate step.
*
* Usage:
* pnpm tsx scripts/import-berths-from-nocodb.ts --dry-run [--port-slug port-nimara]
* pnpm tsx scripts/import-berths-from-nocodb.ts --apply [--port-slug port-nimara]
* pnpm tsx scripts/import-berths-from-nocodb.ts --apply --force
* pnpm tsx scripts/import-berths-from-nocodb.ts --apply --update-snapshot
*
* Edge cases mitigated (see plan §14.1):
* - Mooring collisions : unique (port_id, mooring_number) on the table.
* - Concurrent runs : pg_advisory_xact_lock on a stable key.
* - Numeric-with-units : parseDecimalWithUnit() strips trailing units.
* - Metric drift : NocoDB metric formula columns are ignored;
* metric values are recomputed from imperial.
* - Map Data shape : zod-validated; failures are skipped silently
* rather than aborting the whole import.
* - Status enum : NocoDB display strings → CRM snake_case.
* - NocoDB row deleted : reported as "orphaned in CRM"; not auto-deleted.
*/
import 'dotenv/config';
import { eq, sql } from 'drizzle-orm';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { berths, berthMapData } from '@/lib/db/schema/berths';
import { fetchAllRows, loadNocoDbConfig, NOCO_TABLES } from '@/lib/dedup/nocodb-source';
import {
buildPlan,
mapRow,
type Action,
type ImportedBerth,
type PlanEntry,
type ExistingBerthRow,
} from '@/lib/services/berth-import';
// ─── CLI ────────────────────────────────────────────────────────────────────
interface CliArgs {
dryRun: boolean;
apply: boolean;
portSlug: string;
force: boolean;
updateSnapshot: boolean;
}
function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {
dryRun: false,
apply: false,
portSlug: 'port-nimara',
force: false,
updateSnapshot: false,
};
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i]!;
if (a === '--dry-run') args.dryRun = true;
else if (a === '--apply') args.apply = true;
else if (a === '--port-slug') args.portSlug = argv[++i] ?? 'port-nimara';
else if (a === '--force') args.force = true;
else if (a === '--update-snapshot') args.updateSnapshot = true;
else if (a === '-h' || a === '--help') {
printHelp();
process.exit(0);
} else {
console.error(`Unknown argument: ${a}`);
printHelp();
process.exit(1);
}
}
if (!args.dryRun && !args.apply) {
console.error('Must specify either --dry-run or --apply.');
printHelp();
process.exit(1);
}
return args;
}
function printHelp(): void {
console.log(`Usage:
pnpm tsx scripts/import-berths-from-nocodb.ts --dry-run [--port-slug <slug>]
pnpm tsx scripts/import-berths-from-nocodb.ts --apply [--port-slug <slug>] [--force] [--update-snapshot]
Flags:
--dry-run Read NocoDB + diff vs CRM. No writes.
--apply Apply the plan to the DB.
--port-slug <slug> Target port slug (default: port-nimara).
--force Overwrite rows where CRM updated_at > last_imported_at.
--update-snapshot Rewrite src/lib/db/seed-data/berths.json after apply.
-h, --help Show this help.
`);
}
// ─── Stable advisory lock key ───────────────────────────────────────────────
// 64-bit BIGINT - first 4 bytes spell "BRTH" so it's grep-able in pg_locks.
const BERTH_IMPORT_LOCK_KEY = 0x4252544800000001n;
// ─── Apply ──────────────────────────────────────────────────────────────────
interface ApplyResult {
inserted: number;
updated: number;
skipped: number;
mapDataWritten: number;
warnings: string[];
}
async function apply(
portId: string,
plan: PlanEntry[],
orphans: ExistingBerthRow[],
importedAt: Date,
): Promise<ApplyResult> {
const result: ApplyResult = {
inserted: 0,
updated: 0,
skipped: 0,
mapDataWritten: 0,
warnings: [],
};
for (const orphan of orphans) {
result.warnings.push(
`Orphan: CRM has mooring="${orphan.mooringNumber}" but NocoDB no longer does (id=${orphan.id})`,
);
}
await db.transaction(async (tx) => {
// Stable lock so two simultaneous --apply runs serialize.
await tx.execute(sql`SELECT pg_advisory_xact_lock(${BERTH_IMPORT_LOCK_KEY})`);
for (const entry of plan) {
if (entry.action === 'skip-edited' || entry.action === 'noop') {
result.skipped += 1;
result.warnings.push(`Skipped ${entry.imported.mooringNumber}: ${entry.reason ?? 'no-op'}`);
continue;
}
const i = entry.imported;
const n = i.numerics;
const baseValues = {
portId,
mooringNumber: i.mooringNumber,
area: i.area,
status: i.status,
lengthFt: n.lengthFt != null ? String(n.lengthFt) : null,
widthFt: n.widthFt != null ? String(n.widthFt) : null,
draftFt: n.draftFt != null ? String(n.draftFt) : null,
lengthM: n.lengthM != null ? String(n.lengthM) : null,
widthM: n.widthM != null ? String(n.widthM) : null,
draftM: n.draftM != null ? String(n.draftM) : null,
widthIsMinimum: i.widthIsMinimum,
nominalBoatSize: n.nominalBoatSize != null ? String(n.nominalBoatSize) : null,
nominalBoatSizeM: n.nominalBoatSizeM != null ? String(n.nominalBoatSizeM) : null,
waterDepth: n.waterDepth != null ? String(n.waterDepth) : null,
waterDepthM: n.waterDepthM != null ? String(n.waterDepthM) : null,
waterDepthIsMinimum: i.waterDepthIsMinimum,
sidePontoon: i.sidePontoon,
powerCapacity: n.powerCapacity != null ? String(n.powerCapacity) : null,
voltage: n.voltage != null ? String(n.voltage) : null,
mooringType: i.mooringType,
cleatType: i.cleatType,
cleatCapacity: i.cleatCapacity,
bollardType: i.bollardType,
bollardCapacity: i.bollardCapacity,
access: i.access,
price: n.price != null ? String(n.price) : null,
priceCurrency: 'USD' as const,
bowFacing: i.bowFacing,
berthApproved: i.berthApproved,
statusOverrideMode: i.statusOverrideMode,
lastImportedAt: importedAt,
updatedAt: importedAt,
};
let berthId: string;
if (entry.action === 'insert') {
const [inserted] = await tx
.insert(berths)
.values({ ...baseValues, tenureType: 'permanent' })
.returning({ id: berths.id });
berthId = inserted!.id;
result.inserted += 1;
} else {
await tx.update(berths).set(baseValues).where(eq(berths.id, entry.existing!.id));
berthId = entry.existing!.id;
result.updated += 1;
}
if (i.mapData) {
const mapValues = {
berthId,
svgPath: i.mapData.path ?? null,
x: i.mapData.x != null ? String(i.mapData.x) : null,
y: i.mapData.y != null ? String(i.mapData.y) : null,
transform: i.mapData.transform ?? null,
fontSize: i.mapData.fontSize != null ? String(i.mapData.fontSize) : null,
updatedAt: importedAt,
};
await tx
.insert(berthMapData)
.values(mapValues)
.onConflictDoUpdate({
target: berthMapData.berthId,
set: {
svgPath: mapValues.svgPath,
x: mapValues.x,
y: mapValues.y,
transform: mapValues.transform,
fontSize: mapValues.fontSize,
updatedAt: importedAt,
},
});
result.mapDataWritten += 1;
}
}
});
return result;
}
// ─── Snapshot writer (for seed-data refresh) ────────────────────────────────
async function writeSnapshot(imported: ImportedBerth[]): Promise<string> {
// Ordering: idx 0..4 available (small), 5..9 under_offer (medium),
// 10..11 sold (large), then everything else by mooring number. The
// first 12 indexes feed `seed-data.ts` interest/reservation stubs.
const sortByLength = (a: ImportedBerth, b: ImportedBerth) =>
(a.numerics.lengthFt ?? 0) - (b.numerics.lengthFt ?? 0);
const available = imported
.filter((b) => b.status === 'available')
.sort(sortByLength)
.slice(0, 5);
const underOffer = imported
.filter((b) => b.status === 'under_offer')
.sort(sortByLength)
.slice(0, 5);
const sold = imported
.filter((b) => b.status === 'sold')
.sort((a, b) => -sortByLength(a, b))
.slice(0, 2);
const featured = new Set([...available, ...underOffer, ...sold].map((b) => b.mooringNumber));
const rest = imported
.filter((b) => !featured.has(b.mooringNumber))
.sort((a, b) => a.mooringNumber.localeCompare(b.mooringNumber, 'en', { numeric: true }));
const ordered = [...available, ...underOffer, ...sold, ...rest];
const payload = ordered.map((b) => ({
legacyId: b.legacyId,
mooringNumber: b.mooringNumber,
area: b.area,
status: b.status,
lengthFt: b.numerics.lengthFt,
widthFt: b.numerics.widthFt,
draftFt: b.numerics.draftFt,
lengthM: b.numerics.lengthM,
widthM: b.numerics.widthM,
draftM: b.numerics.draftM,
widthIsMinimum: b.widthIsMinimum,
nominalBoatSize: b.numerics.nominalBoatSize,
nominalBoatSizeM: b.numerics.nominalBoatSizeM,
waterDepth: b.numerics.waterDepth,
waterDepthM: b.numerics.waterDepthM,
waterDepthIsMinimum: b.waterDepthIsMinimum,
sidePontoon: b.sidePontoon,
powerCapacity: b.numerics.powerCapacity,
voltage: b.numerics.voltage,
mooringType: b.mooringType,
cleatType: b.cleatType,
cleatCapacity: b.cleatCapacity,
bollardType: b.bollardType,
bollardCapacity: b.bollardCapacity,
access: b.access,
price: b.numerics.price,
bowFacing: b.bowFacing,
berthApproved: b.berthApproved,
statusOverrideMode: b.statusOverrideMode,
}));
const target = path.resolve(process.cwd(), 'src/lib/db/seed-data/berths.json');
await fs.writeFile(target, JSON.stringify(payload, null, 2) + '\n', 'utf8');
return target;
}
// ─── Main ───────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const config = loadNocoDbConfig();
const [port] = await db
.select({ id: ports.id, slug: ports.slug })
.from(ports)
.where(eq(ports.slug, args.portSlug))
.limit(1);
if (!port) {
console.error(`No port found with slug "${args.portSlug}".`);
process.exit(1);
}
console.log(`> Fetching NocoDB Berths…`);
const rows = await fetchAllRows(NOCO_TABLES.berths, config);
console.log(` fetched ${rows.length} rows from NocoDB`);
const imported: ImportedBerth[] = [];
let skippedMalformed = 0;
for (const r of rows) {
const m = mapRow(r);
if (m) imported.push(m);
else skippedMalformed += 1;
}
if (skippedMalformed > 0) {
console.warn(` ${skippedMalformed} rows skipped (missing Mooring Number)`);
}
// De-dup against any same-mooring twins surfacing from NocoDB
// (defensive — the Berths table is keyed on Mooring Number in NocoDB).
const seen = new Set<string>();
const dedup: ImportedBerth[] = [];
for (const b of imported) {
if (seen.has(b.mooringNumber)) {
console.warn(` duplicate mooring "${b.mooringNumber}" in NocoDB — keeping first`);
continue;
}
seen.add(b.mooringNumber);
dedup.push(b);
}
console.log(`> Reading current CRM berths for port "${port.slug}"…`);
const existingRows = await db
.select({
id: berths.id,
mooringNumber: berths.mooringNumber,
updatedAt: berths.updatedAt,
lastImportedAt: berths.lastImportedAt,
})
.from(berths)
.where(eq(berths.portId, port.id));
console.log(` ${existingRows.length} existing rows`);
const existingByMooring = new Map(existingRows.map((r) => [r.mooringNumber, r]));
const { plan, orphans } = buildPlan(dedup, existingByMooring, args.force);
const counts = plan.reduce(
(acc, e) => {
acc[e.action] += 1;
return acc;
},
{ insert: 0, update: 0, 'skip-edited': 0, noop: 0 } as Record<Action, number>,
);
console.log(`> Plan:`);
console.log(` insert : ${counts.insert}`);
console.log(` update : ${counts.update}`);
console.log(` skip-edited : ${counts['skip-edited']}`);
console.log(` no-op : ${counts.noop}`);
console.log(` orphans (CRM): ${orphans.length}`);
if (counts['skip-edited'] > 0) {
console.log(` ↳ Skipped (CRM-edited; pass --force to overwrite):`);
for (const e of plan.filter((p) => p.action === 'skip-edited').slice(0, 10)) {
console.log(` - ${e.imported.mooringNumber} ${e.reason}`);
}
if (counts['skip-edited'] > 10) console.log(` …and ${counts['skip-edited'] - 10} more`);
}
if (orphans.length > 0) {
console.log(` ↳ Orphans (in CRM but missing from NocoDB):`);
for (const o of orphans.slice(0, 10)) console.log(` - ${o.mooringNumber}`);
if (orphans.length > 10) console.log(` …and ${orphans.length - 10} more`);
}
// Snapshot write is independent of DB writes — even in --dry-run mode
// a rep may want to refresh the seed JSON to capture the latest NocoDB
// shape without committing to the DB import. The original gate dropped
// this silently when --dry-run was passed; audit caught it.
if (args.updateSnapshot) {
const written = await writeSnapshot(dedup);
console.log(`> Wrote ${dedup.length} rows to ${path.relative(process.cwd(), written)}`);
}
if (args.dryRun) {
console.log(`\n[dry-run] no DB writes performed.`);
return;
}
console.log(`> Applying…`);
const result = await apply(port.id, plan, orphans, new Date());
console.log(` inserted : ${result.inserted}`);
console.log(` updated : ${result.updated}`);
console.log(` skipped : ${result.skipped}`);
console.log(` map data writes : ${result.mapDataWritten}`);
if (result.warnings.length) {
console.log(` warnings :`);
for (const w of result.warnings.slice(0, 20)) console.log(` - ${w}`);
if (result.warnings.length > 20) console.log(` …and ${result.warnings.length - 20} more`);
}
}
main()
.then(() => process.exit(0))
.catch((err: unknown) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,251 @@
/**
* One-shot migration: legacy NocoDB Interests → new client/interest split.
*
* Usage:
*
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run
* Pulls the live NocoDB base, runs the transform + dedup pipeline,
* writes a report to .migration/<timestamp>/. NO database writes.
*
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run --port-slug port-nimara
* Same, but tags the planned writes with the named port (matters for
* the apply phase — every client/interest belongs to one port).
*
* pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug port-nimara
* Re-fetches NocoDB, re-transforms, then writes the planned rows
* into the target port via the idempotent `migration_source_links`
* ledger. Re-runs are safe — already-imported source IDs are skipped.
* REQUIRES `EMAIL_REDIRECT_TO` to be set in env (safety net) unless
* `--unsafe-skip-redirect-check` is also passed.
*
* Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.
*/
import 'dotenv/config';
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { applyPlan } from '@/lib/dedup/migration-apply';
import { fetchSnapshot, loadNocoDbConfig } from '@/lib/dedup/nocodb-source';
import { transformSnapshot } from '@/lib/dedup/migration-transform';
import { resolveReportPaths, writeReport } from '@/lib/dedup/migration-report';
interface CliArgs {
dryRun: boolean;
apply: boolean;
portSlug: string | null;
reportDir: string | null;
unsafeSkipRedirectCheck: boolean;
}
function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {
dryRun: false,
apply: false,
portSlug: null,
reportDir: null,
unsafeSkipRedirectCheck: false,
};
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i]!;
if (a === '--dry-run') args.dryRun = true;
else if (a === '--apply') args.apply = true;
else if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
else if (a === '--report') args.reportDir = argv[++i] ?? null;
else if (a === '--unsafe-skip-redirect-check') args.unsafeSkipRedirectCheck = true;
else if (a === '-h' || a === '--help') {
printHelp();
process.exit(0);
} else {
console.error(`Unknown argument: ${a}`);
printHelp();
process.exit(1);
}
}
return args;
}
function printHelp(): void {
console.log(`Usage:
pnpm tsx scripts/migrate-from-nocodb.ts --dry-run [--port-slug <slug>]
Pulls NocoDB → transforms → writes report to .migration/<timestamp>/.
No database writes.
pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug <slug>
Re-fetches NocoDB, re-transforms, writes via migration_source_links
ledger. Idempotent — safe to re-run. Requires EMAIL_REDIRECT_TO set
(unless --unsafe-skip-redirect-check is also passed).
Flags:
--dry-run Read NocoDB, write report only.
--apply Actually write rows to the DB.
--port-slug <slug> Port slug to attach to all imported
entities. Defaults to the first
available port if omitted.
--report <dir> Path to a previously-generated report
dir (only used by --apply).
--unsafe-skip-redirect-check Skip the EMAIL_REDIRECT_TO precondition
check. Only use in production cutover.
-h, --help Show this help.
`);
}
/**
* Resolve the target port: use the slug if provided, otherwise the first
* port found. Errors out cleanly if the slug doesn't match any port.
*/
async function resolvePort(slug: string | null): Promise<{ id: string; slug: string }> {
if (slug) {
const [p] = await db
.select({ id: ports.id, slug: ports.slug })
.from(ports)
.where(eq(ports.slug, slug))
.limit(1);
if (!p) {
console.error(`No port found with slug "${slug}".`);
process.exit(1);
}
return { id: p.id, slug: p.slug };
}
const [first] = await db.select({ id: ports.id, slug: ports.slug }).from(ports).limit(1);
if (!first) {
console.error('No ports exist in the target DB. Seed at least one port before applying.');
process.exit(1);
}
return { id: first.id, slug: first.slug };
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
if (!args.dryRun && !args.apply) {
console.error('Must specify --dry-run or --apply');
printHelp();
process.exit(1);
}
// Safety gate: --apply must run with EMAIL_REDIRECT_TO set, unless the
// operator explicitly opts out (production cutover).
if (args.apply && !process.env.EMAIL_REDIRECT_TO && !args.unsafeSkipRedirectCheck) {
console.error(
'--apply requires EMAIL_REDIRECT_TO to be set in the environment as a safety net.',
);
console.error('See docs/operations/outbound-comms-safety.md for the rationale.');
console.error(
'If you are running the production cutover and have read that doc, add ' +
'--unsafe-skip-redirect-check to override.',
);
process.exit(2);
}
// ── Fetch + transform (shared by dry-run and apply) ──────────────────────
console.log('[migrate] Loading NocoDB config…');
const config = loadNocoDbConfig();
console.log(`[migrate] Source: ${config.url}`);
console.log('[migrate] Fetching snapshot from NocoDB…');
const start = Date.now();
const snapshot = await fetchSnapshot(config);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(
`[migrate] Snapshot fetched in ${elapsed}s — ${snapshot.interests.length} interests, ${snapshot.residentialInterests.length} residential, ${snapshot.berths.length} berths.`,
);
console.log('[migrate] Running transform + dedup pipeline…');
const plan = transformSnapshot(snapshot);
// Resolve output paths relative to the worktree root.
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const generatedAt = new Date().toISOString();
const paths = resolveReportPaths(repoRoot);
console.log(`[migrate] Writing report to ${paths.rootDir}`);
await writeReport(paths, plan, generatedAt);
// ── Plan summary ─────────────────────────────────────────────────────────
const s = plan.stats;
console.log('');
console.log('=== Migration Plan Summary ===');
console.log(
` Input: ${s.inputInterestRows} interests, ${s.inputResidentialRows} residential interests`,
);
console.log(` Output: ${s.outputClients} clients, ${s.outputInterests} interests`);
console.log(` ${s.outputContacts} contacts, ${s.outputAddresses} addresses`);
console.log(
` ${s.outputDocuments} EOI documents, ${s.outputDocumentSigners} signers`,
);
console.log(
` ${s.outputResidentialClients} residential clients (with default-stage interests)`,
);
console.log(
` Dedup: ${s.autoLinkedClusters} auto-linked clusters, ${s.needsReviewPairs} pairs flagged for review`,
);
console.log(` Quality: ${s.flaggedRows} rows flagged (see report.csv)`);
console.log('');
console.log(` Full report: ${paths.summaryPath}`);
if (args.dryRun) {
console.log('');
console.log('Dry-run complete. Re-run with --apply to write rows.');
return;
}
// ── Apply path ───────────────────────────────────────────────────────────
const port = await resolvePort(args.portSlug);
const applyId = randomUUID();
console.log('');
console.log(`[migrate] Applying to port "${port.slug}" (id=${port.id})`);
console.log(`[migrate] Apply id: ${applyId}`);
console.log('[migrate] Inserting…');
const applyStart = Date.now();
const result = await applyPlan(plan, { port, applyId });
const applyElapsed = ((Date.now() - applyStart) / 1000).toFixed(1);
console.log('');
console.log('=== Apply Result ===');
console.log(` Time: ${applyElapsed}s`);
console.log(
` Clients: ${result.clientsInserted} inserted, ${result.clientsSkipped} already linked`,
);
console.log(` Contacts: ${result.contactsInserted} inserted`);
console.log(` Addresses: ${result.addressesInserted} inserted`);
console.log(` Yachts: ${result.yachtsInserted} inserted`);
console.log(
` Interests: ${result.interestsInserted} inserted, ${result.interestsSkipped} already linked`,
);
console.log(
` Documents: ${result.documentsInserted} inserted, ${result.documentsSkipped} already linked`,
);
console.log(` Signers: ${result.documentSignersInserted} inserted`);
console.log(
` Res-Clt: ${result.residentialClientsInserted} inserted, ${result.residentialClientsSkipped} already linked`,
);
console.log(` Res-Int: ${result.residentialInterestsInserted} inserted`);
if (result.warnings.length > 0) {
console.log('');
console.log('Warnings:');
for (const w of result.warnings.slice(0, 20)) {
console.log(` - ${w}`);
}
if (result.warnings.length > 20) {
console.log(`${result.warnings.length - 20} more`);
}
}
console.log('');
}
main().catch((err) => {
console.error('[migrate] Fatal error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,29 @@
/**
* Storage backend migration CLI — see §4.7a + §14.9a of
* docs/berth-recommender-and-pdf-plan.md.
*
* pnpm tsx scripts/migrate-storage.ts --from s3 --to filesystem [--dry-run]
* pnpm tsx scripts/migrate-storage.ts --from filesystem --to s3
*
* The actual migration logic lives in `src/lib/storage/migrate.ts` so the
* admin UI's "Switch backend" button can run the exact same code path. This
* file is a thin CLI wrapper.
*/
import { logger } from '@/lib/logger';
import { parseArgs, runMigration } from '@/lib/storage/migrate';
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
logger.info({ args }, 'Starting storage migration');
const result = await runMigration(args);
logger.info({ result }, 'Storage migration complete');
console.log(JSON.stringify(result, null, 2));
process.exit(0);
}
main().catch((err) => {
logger.error({ err }, 'Storage migration failed');
console.error(err);
process.exit(2);
});

View File

@@ -0,0 +1,106 @@
/**
* Live smoke test for EMAIL_REDIRECT_TO.
*
* Actually calls `sendEmail()` (the centralized helper used by every
* outbound email path in the app) with a fake real-client address. The
* SMTP transporter is monkey-patched to capture the message instead of
* actually delivering it, so this is safe to run anywhere.
*
* Prints the captured `to` + `subject` so the operator can see with their
* own eyes that the redirect happened. Exits non-zero if the redirect
* failed for any reason.
*
* Usage:
* pnpm tsx scripts/smoke-test-redirect.ts
*/
import 'dotenv/config';
async function main() {
const expectedRedirect = process.env.EMAIL_REDIRECT_TO;
if (!expectedRedirect) {
console.error('FAIL: EMAIL_REDIRECT_TO is not set in env. Set it before running this test.');
process.exit(1);
}
console.log(`[smoke] EMAIL_REDIRECT_TO = ${expectedRedirect}`);
console.log('');
// Monkey-patch nodemailer's createTransport so we capture the call
// without actually delivering. This is the same pattern the unit
// tests use, but at the live import-time level so we're testing the
// exact code path that runs in production.
const nodemailer = await import('nodemailer');
const captured: Array<{ to: unknown; subject: unknown; from: unknown }> = [];
const originalCreateTransport = nodemailer.default.createTransport;
nodemailer.default.createTransport = (() => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sendMail: async (msg: any) => {
captured.push({ to: msg.to, subject: msg.subject, from: msg.from });
return { messageId: '<smoke@test>', accepted: [msg.to], rejected: [] };
},
})) as unknown as typeof nodemailer.default.createTransport;
// Now import sendEmail (gets the patched transporter).
const { sendEmail } = await import('@/lib/email');
const realClientEmail = 'real-client-DO-NOT-EMAIL@example.test';
const realSubject = 'Important: Your contract is ready';
console.log('[smoke] calling sendEmail(...) with:');
console.log(` to: ${realClientEmail}`);
console.log(` subject: "${realSubject}"`);
console.log('');
await sendEmail(realClientEmail, realSubject, '<p>Body unused for this smoke.</p>');
// Restore the original transport (be a good citizen).
nodemailer.default.createTransport = originalCreateTransport;
console.log('[smoke] captured outbound message:');
console.log(` to: ${captured[0]?.to}`);
console.log(` subject: "${captured[0]?.subject}"`);
console.log(` from: ${captured[0]?.from}`);
console.log('');
// Assertions
let pass = true;
if (captured.length !== 1) {
console.error(`FAIL: expected exactly 1 sendMail call, got ${captured.length}`);
pass = false;
}
if (captured[0]?.to !== expectedRedirect) {
console.error(
`FAIL: outbound "to" was "${captured[0]?.to}", expected the redirect address "${expectedRedirect}"`,
);
pass = false;
}
if (
typeof captured[0]?.subject !== 'string' ||
!captured[0].subject.startsWith(`[redirected from ${realClientEmail}]`)
) {
console.error(
`FAIL: subject did not get the [redirected from <orig>] prefix. Got: "${captured[0]?.subject}"`,
);
pass = false;
}
if (pass) {
console.log('PASS: EMAIL_REDIRECT_TO is intercepting outbound email correctly.');
console.log(
' The "to" header matches the redirect, and the original recipient is preserved in the subject.',
);
process.exit(0);
} else {
console.error('');
console.error('Smoke test FAILED. Do not import production data until this is fixed.');
process.exit(1);
}
}
main().catch((err) => {
console.error('FATAL:', err);
process.exit(1);
});

View File

@@ -0,0 +1,42 @@
/**
* Quick verification: live Frankfurter API → DB upsert → getRate read.
* Run with `pnpm tsx scripts/test-currency-api.ts`.
*/
import { refreshRates, getRate, convert } from '@/lib/services/currency';
async function main() {
console.log('1. Fetching live rates from Frankfurter…');
await refreshRates();
console.log('2. Reading round-trip rates from DB:');
const usdEur = await getRate('USD', 'EUR');
const eurUsd = await getRate('EUR', 'USD');
const usdGbp = await getRate('USD', 'GBP');
const eurGbp = await getRate('EUR', 'GBP');
const usdUsd = await getRate('USD', 'USD');
console.log(` USD→EUR: ${usdEur}`);
console.log(` EUR→USD: ${eurUsd}`);
console.log(` USD→GBP: ${usdGbp}`);
console.log(` EUR→GBP: ${eurGbp ?? '(no direct row, expected)'}`);
console.log(` USD→USD: ${usdUsd}`);
console.log('3. Convert sample amounts:');
const c1 = await convert(1000, 'USD', 'EUR');
console.log(` $1000 → ${c1?.result} EUR @ ${c1?.rate}`);
const c2 = await convert(500, 'EUR', 'USD');
console.log(` €500 → $${c2?.result} @ ${c2?.rate}`);
// Sanity: EUR→USD should be ≈ 1 / (USD→EUR), within rounding
if (usdEur && eurUsd) {
const drift = Math.abs(eurUsd - 1 / usdEur);
console.log(`4. Inverse-rate drift: ${drift.toFixed(6)} (≤0.001 = healthy)`);
}
process.exit(0);
}
main().catch((err) => {
console.error('Currency test failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,135 @@
import Link from 'next/link';
import { Bot, Receipt, FileText, Brain, ExternalLink } from 'lucide-react';
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
const MASTER_FIELDS: SettingFieldDef[] = [
{
key: 'ai_enabled',
label: 'AI features enabled',
description:
'Master switch. When OFF, every AI surface (receipt OCR fallback, berth-PDF AI parse, future embedding-driven recommendations) is bypassed. Provider keys stay configured but unused.',
type: 'boolean',
defaultValue: true,
},
{
key: 'ai_monthly_token_cap',
label: 'Monthly token cap (this port)',
description:
'Soft cap on total AI tokens consumed per calendar month across every feature. When exceeded, AI features fall back to non-AI paths and surface a banner. Set 0 for no cap.',
type: 'number',
defaultValue: 0,
},
];
const PROVIDER_FIELDS: SettingFieldDef[] = [
{
key: 'openai_api_key',
label: 'OpenAI API key',
description:
'Used by Receipt OCR fallback and (future) berth-PDF AI parse. Stored AES-encrypted at rest; the field shows blank after save.',
type: 'password',
placeholder: 'sk-…',
defaultValue: '',
},
{
key: 'openai_default_model',
label: 'Default OpenAI model',
description: 'Used when a feature does not specify an explicit model.',
type: 'select',
defaultValue: 'gpt-4o-mini',
options: [
{ value: 'gpt-4o-mini', label: 'gpt-4o-mini — cheap, fast, vision-capable' },
{ value: 'gpt-4o', label: 'gpt-4o — full-strength multimodal' },
{ value: 'gpt-4-turbo', label: 'gpt-4-turbo — legacy text reasoning' },
],
},
];
interface FeatureLink {
href: string;
icon: typeof Bot;
title: string;
description: string;
}
const FEATURE_LINKS: FeatureLink[] = [
{
href: '../ocr',
icon: Receipt,
title: 'Receipt OCR settings',
description:
'Provider, model, and confidence thresholds for the receipt scanner. AI fallback only runs when the on-device parser is uncertain.',
},
{
href: '../berth-pdf-parser',
icon: FileText,
title: 'Berth PDF parser',
description:
'Three-tier AcroForm → OCR → AI pipeline. The AI pass costs tokens; reps invoke it manually when OCR confidence is low.',
},
{
href: '../recommender',
icon: Brain,
title: 'Berth recommender',
description:
'Rule-based today; future versions will optionally use embeddings for soft preference matching. AI use is gated by the master switch above.',
},
];
export default function AiAdminPage() {
return (
<div className="space-y-6">
<PageHeader
title="AI configuration"
description="One place to manage every AI-using feature. Provider credentials and the master AI switch live here; per-feature thresholds remain in their dedicated pages, linked below."
eyebrow="ADMIN"
/>
<SettingsFormCard
title="Master controls"
description="Hard kill switch + budget guardrails covering every AI surface in this port."
fields={MASTER_FIELDS}
/>
<SettingsFormCard
title="Provider credentials"
description="Shared API keys used by AI-enabled features. Per-feature pages can override the model on a feature-by-feature basis."
fields={PROVIDER_FIELDS}
/>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Bot className="h-4 w-4" /> Per-feature settings
</CardTitle>
<CardDescription>
Feature-specific tuning lives on each feature&apos;s admin page. They all read the
master switch + provider credentials configured above.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{FEATURE_LINKS.map((f) => (
<Link
key={f.href}
href={f.href as never}
className="rounded-md border bg-card p-3 hover:border-primary transition-colors block"
>
<div className="flex items-center gap-2 text-sm font-medium">
<f.icon className="h-4 w-4 text-muted-foreground" />
{f.title}
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
</div>
<p className="mt-1 text-xs text-muted-foreground">{f.description}</p>
</Link>
))}
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,16 +1,15 @@
import { BackupAdminPanel } from '@/components/admin/backup-admin-panel';
import { PageHeader } from '@/components/shared/page-header';
export default function BackupManagementPage() { export default function BackupManagementPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader
<h1 className="text-2xl font-bold text-foreground">Backup Management</h1> title="Backup & Restore"
<p className="text-muted-foreground">Manage system backups and restoration</p> eyebrow="ADMIN"
</div> description="Trigger ad-hoc database snapshots, browse the history, and download a .dump file for offline restore."
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12"> />
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p> <BackupAdminPanel />
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div> </div>
); );
} }

View File

@@ -2,6 +2,29 @@ import {
SettingsFormCard, SettingsFormCard,
type SettingFieldDef, type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card'; } from '@/components/admin/shared/settings-form-card';
import { PageHeader } from '@/components/shared/page-header';
const DEFAULT_EMAIL_HEADER_HTML = `<!-- Optional pre-body header -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
<tr>
<td align="center" style="padding:16px 0;">
<a href="https://example.com" style="text-decoration:none;color:#1e293b;font-family:Arial,sans-serif;font-size:14px;font-weight:600;">
Your brand name
</a>
</td>
</tr>
</table>`;
const DEFAULT_EMAIL_FOOTER_HTML = `<!-- Optional sub-body footer -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
<tr>
<td align="center" style="padding:24px 0;color:#64748b;font-family:Arial,sans-serif;font-size:12px;">
&copy; ${new Date().getFullYear()} Your Company &middot;
<a href="https://example.com" style="color:#64748b;">Visit our website</a> &middot;
<a href="mailto:hello@example.com" style="color:#64748b;">hello@example.com</a>
</td>
</tr>
</table>`;
const FIELDS: SettingFieldDef[] = [ const FIELDS: SettingFieldDef[] = [
{ {
@@ -14,11 +37,11 @@ const FIELDS: SettingFieldDef[] = [
}, },
{ {
key: 'branding_logo_url', key: 'branding_logo_url',
label: 'Logo URL', label: 'Logo',
description: description:
'Public HTTPS URL of the logo used in email headers and the branded auth shell. Recommended size: 240×80 PNG with transparent background.', 'Used in email headers and the branded auth shell. Recommended: square PNG with transparent background.',
type: 'string', type: 'image-upload',
placeholder: 'https://example.com/logo.png', imageAspect: 1,
defaultValue: '', defaultValue: '',
}, },
{ {
@@ -31,9 +54,11 @@ const FIELDS: SettingFieldDef[] = [
{ {
key: 'branding_email_header_html', key: 'branding_email_header_html',
label: 'Email header HTML', label: 'Email header HTML',
description: 'Optional HTML rendered above each email body. Leave blank to use the default.', description:
'Optional HTML rendered above each email body. Leave blank to use the default. Tap "Insert default" to start from the baseline template.',
type: 'html', type: 'html',
defaultValue: '', defaultValue: '',
defaultTemplate: DEFAULT_EMAIL_HEADER_HTML,
}, },
{ {
key: 'branding_email_footer_html', key: 'branding_email_footer_html',
@@ -41,19 +66,17 @@ const FIELDS: SettingFieldDef[] = [
description: 'Optional HTML rendered at the very bottom of each email (above the signature).', description: 'Optional HTML rendered at the very bottom of each email (above the signature).',
type: 'html', type: 'html',
defaultValue: '', defaultValue: '',
defaultTemplate: DEFAULT_EMAIL_FOOTER_HTML,
}, },
]; ];
export default function BrandingSettingsPage() { export default function BrandingSettingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader
<h1 className="text-2xl font-semibold">Branding</h1> title="Branding"
<p className="text-sm text-muted-foreground"> description="Logo, primary color, app name, and email header/footer HTML used by the branded auth shell and outgoing email templates."
Logo, primary color, app name, and email header/footer HTML used by the branded auth shell />
and outgoing email templates.
</p>
</div>
<SettingsFormCard <SettingsFormCard
title="Identity" title="Identity"
description="App name, logo, and primary color." description="App name, logo, and primary color."

View File

@@ -0,0 +1,21 @@
import { PageHeader } from '@/components/shared/page-header';
import { BrochuresAdminPanel } from '@/components/admin/brochures-admin-panel';
/**
* Per-port admin page for managing brochures (Phase 7 §5.8).
*
* Lists brochures, lets per-port admins upload new versions via direct-to-
* storage presigned URLs (so the 20MB+ file never traverses Next.js's
* body-size limit — see §11.1), and toggle the default flag.
*/
export default function BrochuresAdminPage() {
return (
<div className="space-y-6">
<PageHeader
title="Brochures"
description="Port-wide marketing PDFs available to the sales send-out flow. The default brochure is the one /clients picker pre-selects."
/>
<BrochuresAdminPanel />
</div>
);
}

View File

@@ -3,6 +3,7 @@ import {
type SettingFieldDef, type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card'; } from '@/components/admin/shared/settings-form-card';
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button'; import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
import { PageHeader } from '@/components/shared/page-header';
const API_FIELDS: SettingFieldDef[] = [ const API_FIELDS: SettingFieldDef[] = [
{ {
@@ -20,6 +21,55 @@ const API_FIELDS: SettingFieldDef[] = [
type: 'password', type: 'password',
defaultValue: '', defaultValue: '',
}, },
{
key: 'documenso_api_version_override',
label: 'API version',
description:
'Which Documenso REST API to call against this port. v1 supports Documenso 1.x (per-field PIXEL placement, /api/v1/templates and /api/v1/documents). v2 unlocks the envelope/embed endpoints introduced in Documenso 2.x. Use the test-connection button below after switching to confirm the chosen version actually works against this ports instance.',
type: 'select',
options: [
{ value: 'v1', label: 'v1 — Documenso 1.x (legacy stable)' },
{ value: 'v2', label: 'v2 — Documenso 2.x (envelope + embedded signing)' },
],
defaultValue: 'v1',
},
];
const SIGNER_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_developer_name',
label: 'Developer signer — name',
description:
'The party who signs after the client (typically the marina developer or owner). Used as the static "developer" recipient in templated documents (EOI). Was hardcoded as "David Mizrahi" in the legacy single-tenant system.',
type: 'string',
placeholder: 'David Mizrahi',
defaultValue: '',
},
{
key: 'documenso_developer_email',
label: 'Developer signer — email',
description: 'Email used to send the developer signing request via Documenso.',
type: 'string',
placeholder: 'dm@portnimara.com',
defaultValue: '',
},
{
key: 'documenso_approver_name',
label: 'Approver — name',
description:
'The final approver who signs after the developer (typically a sales/legal lead). Was hardcoded as "Abbie May" in the legacy system.',
type: 'string',
placeholder: 'Abbie May',
defaultValue: '',
},
{
key: 'documenso_approver_email',
label: 'Approver — email',
description: 'Email used to route the final approval signing request.',
type: 'string',
placeholder: 'sales@portnimara.com',
defaultValue: '',
},
]; ];
const EOI_FIELDS: SettingFieldDef[] = [ const EOI_FIELDS: SettingFieldDef[] = [
@@ -43,18 +93,60 @@ const EOI_FIELDS: SettingFieldDef[] = [
], ],
defaultValue: 'documenso-template', defaultValue: 'documenso-template',
}, },
{
key: 'eoi_send_mode',
label: 'Initial signing-invitation email behaviour',
description:
'Auto = the system sends our branded "please sign" email immediately when an EOI/contract/reservation is generated. Manual = the document is generated and the signing URL appears in the UI; a rep clicks "Send invitation" to dispatch. Auto is the lower-friction option for high-volume teams; manual lets reps review before sending. Applies to all document types, not just EOI.',
type: 'select',
options: [
{ value: 'manual', label: 'Manual (rep clicks Send after generation)' },
{ value: 'auto', label: 'Auto (send branded email on generate)' },
],
defaultValue: 'manual',
},
];
const CONTRACT_RESERVATION_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_contract_template_id',
label: 'Contract Documenso template ID (optional)',
description:
'Numeric template ID for sales contract generation. Leave blank to use the per-deal upload-and-place-fields flow instead (the typical path for contracts, since they are usually drafted custom per client).',
type: 'string',
placeholder: '',
defaultValue: '',
},
{
key: 'documenso_reservation_template_id',
label: 'Reservation agreement Documenso template ID (optional)',
description:
'Numeric template ID for reservation agreements. Same logic — leave blank to upload per deal.',
type: 'string',
placeholder: '',
defaultValue: '',
},
];
const EMBED_FIELDS: SettingFieldDef[] = [
{
key: 'embedded_signing_host',
label: 'Embedded signing host',
description:
"Origin of the public site that hosts the embedded Documenso signing pages. Outbound emails wrap raw Documenso signing URLs into {host}/sign/<type>/<token> so clients sign on your branded page rather than Documenso's domain. Leave blank to fall back to the app URL. Marketing-website pattern: https://portnimara.com",
type: 'string',
placeholder: 'https://portnimara.com',
defaultValue: '',
},
]; ];
export default function DocumensoSettingsPage() { export default function DocumensoSettingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader
<h1 className="text-2xl font-semibold">Documenso & EOI</h1> title="Documenso & EOI"
<p className="text-sm text-muted-foreground"> description="API credentials, signer identities, and document generation behaviour. Use the test-connection button to verify a saved configuration before relying on it."
API credentials and default EOI generation pathway. Use the test-connection button to />
verify a saved configuration before relying on it.
</p>
</div>
<SettingsFormCard <SettingsFormCard
title="Documenso API" title="Documenso API"
@@ -63,11 +155,29 @@ export default function DocumensoSettingsPage() {
extra={<DocumensoTestButton />} extra={<DocumensoTestButton />}
/> />
<SettingsFormCard
title="Signers (developer + approver)"
description="Identity of the static signers in your Documenso templates. The client is always pulled from the interest's linked client record; these values fill the developer (signing order 2) and approver (signing order 3) slots."
fields={SIGNER_FIELDS}
/>
<SettingsFormCard <SettingsFormCard
title="EOI generation" title="EOI generation"
description="Default pathway and template used when an interest's EOI is generated." description="Default pathway, template, and email behaviour when an interest's EOI is generated."
fields={EOI_FIELDS} fields={EOI_FIELDS}
/> />
<SettingsFormCard
title="Contract & reservation templates (optional)"
description="Most ports leave these blank because contracts/reservations are drafted per deal and uploaded for signing. Set a template ID only if you have a standardised contract/reservation Documenso template."
fields={CONTRACT_RESERVATION_FIELDS}
/>
<SettingsFormCard
title="Embedded signing"
description="Where the public-facing branded signing pages live. The CRM rewrites Documenso signing URLs to point here when sending invitation and reminder emails."
fields={EMBED_FIELDS}
/>
</div> </div>
); );
} }

View File

@@ -0,0 +1,5 @@
import { DuplicatesReviewQueue } from '@/components/admin/duplicates/duplicates-review-queue';
export default function DuplicatesAdminPage() {
return <DuplicatesReviewQueue />;
}

View File

@@ -0,0 +1,5 @@
import { EmailTemplatesAdmin } from '@/components/admin/email-templates-admin';
export default function EmailTemplatesPage() {
return <EmailTemplatesAdmin />;
}

View File

@@ -2,6 +2,8 @@ import {
SettingsFormCard, SettingsFormCard,
type SettingFieldDef, type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card'; } from '@/components/admin/shared/settings-form-card';
import { PageHeader } from '@/components/shared/page-header';
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
const FIELDS: SettingFieldDef[] = [ const FIELDS: SettingFieldDef[] = [
{ {
@@ -33,7 +35,7 @@ const FIELDS: SettingFieldDef[] = [
label: 'Default signature (HTML)', label: 'Default signature (HTML)',
description: 'Appended to the bottom of system-generated emails.', description: 'Appended to the bottom of system-generated emails.',
type: 'html', type: 'html',
placeholder: '<p><br>The Port Nimara team</p>', placeholder: '<p>-<br>The Port Nimara team</p>',
defaultValue: '', defaultValue: '',
}, },
{ {
@@ -70,7 +72,7 @@ const FIELDS: SettingFieldDef[] = [
{ {
key: 'smtp_pass_override', key: 'smtp_pass_override',
label: 'SMTP password override', label: 'SMTP password override',
description: 'Optional. Stored in plain text only set when overriding env credentials.', description: 'Optional. Stored in plain text - only set when overriding env credentials.',
type: 'password', type: 'password',
defaultValue: '', defaultValue: '',
}, },
@@ -79,13 +81,10 @@ const FIELDS: SettingFieldDef[] = [
export default function EmailSettingsPage() { export default function EmailSettingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader
<h1 className="text-2xl font-semibold">Email Settings</h1> title="Email Settings"
<p className="text-sm text-muted-foreground"> description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank."
Per-port outgoing email configuration. SMTP credentials and the From address default to />
environment variables when these fields are blank.
</p>
</div>
<SettingsFormCard <SettingsFormCard
title="From address & signature" title="From address & signature"
description="Identity headers and shared HTML used by system-generated emails." description="Identity headers and shared HTML used by system-generated emails."
@@ -96,6 +95,7 @@ export default function EmailSettingsPage() {
description="Optional per-port SMTP credentials. Leave blank to use the global env defaults." description="Optional per-port SMTP credentials. Leave blank to use the global env defaults."
fields={FIELDS.slice(5)} fields={FIELDS.slice(5)}
/> />
<SalesEmailConfigCard />
</div> </div>
); );
} }

View File

@@ -0,0 +1,246 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { format } from 'date-fns';
import { ArrowLeft, Copy, Wrench } from 'lucide-react';
import { toast } from 'sonner';
import type { Route } from 'next';
import { Badge } from '@/components/ui/badge';
import { ERROR_CODES, isErrorCode } from '@/lib/error-codes';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { apiFetch } from '@/lib/api/client';
import type { ErrorEvent } from '@/lib/db/schema/system';
import type { LikelyCulprit } from '@/lib/error-classifier';
interface DetailResponse {
data: ErrorEvent & { likelyCulprit: LikelyCulprit | null };
}
/**
* Detail view for a single captured error. Shows everything an admin
* needs to triage:
*
* - Request shape: method, path, status, duration, who fired it
* - Error: name, message, full stack head, (sanitized) request body
* - Likely-culprit hint: heuristic-driven plain-English root-cause
* - Raw metadata: pg SQLSTATE codes, internal-message debug strings
*/
export default function ErrorEventDetailPage() {
const params = useParams<{ portSlug: string; requestId: string }>();
const portSlug = params?.portSlug ?? '';
const requestId = params?.requestId ?? '';
const query = useQuery<DetailResponse>({
queryKey: ['admin', 'error-events', requestId],
queryFn: () => apiFetch<DetailResponse>(`/api/v1/admin/error-events/${requestId}`),
enabled: Boolean(requestId),
});
function copy(text: string, label: string) {
if (typeof navigator === 'undefined' || !navigator.clipboard) return;
void navigator.clipboard.writeText(text);
toast.success(`${label} copied`);
}
if (query.isLoading) {
return (
<div className="space-y-3">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
const event = query.data?.data;
if (!event) {
return (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
Error event not found. It may have been pruned or you may not have access.
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<div>
<Button variant="ghost" size="sm" asChild>
<Link href={`/${portSlug}/admin/errors` as Route}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
Back to error list
</Link>
</Button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold">Error {requestId.slice(0, 8)}</h1>
<Badge
variant="outline"
className={
event.statusCode >= 500
? 'border-destructive/40 text-destructive'
: 'border-amber-300 text-amber-800'
}
>
{event.statusCode}
</Badge>
{event.likelyCulprit && (
<Badge variant="secondary" className="gap-1">
<Wrench className="h-3 w-3" />
{event.likelyCulprit.label}
</Badge>
)}
<Button size="sm" variant="ghost" onClick={() => copy(requestId, 'Reference ID')}>
<Copy className="mr-1.5 h-3 w-3" />
Copy ID
</Button>
</div>
{event.likelyCulprit && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Wrench className="h-4 w-4" /> Likely culprit
</CardTitle>
</CardHeader>
<CardContent className="text-sm">
<p className="font-medium">{event.likelyCulprit.label}</p>
<p className="text-muted-foreground mt-1">{event.likelyCulprit.hint}</p>
<p className="text-xs text-muted-foreground mt-2">
Subsystem: <code className="font-mono">{event.likelyCulprit.subsystem}</code>
</p>
</CardContent>
</Card>
)}
{/* If the captured error has a registered code on its metadata,
* surface the canonical user-facing message + status from the
* registry so the admin can compare what the user saw to what
* the system actually did. */}
{(() => {
const meta = (event.metadata ?? {}) as Record<string, unknown>;
const code = typeof meta.code === 'string' ? meta.code : null;
if (!code || !isErrorCode(code)) return null;
const def = ERROR_CODES[code];
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Error code</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<div className="flex items-center gap-2">
<Badge variant="outline">{def.status}</Badge>
<code className="font-mono text-xs font-semibold">{code}</code>
</div>
<p className="mt-2">{def.userMessage}</p>
<p className="text-xs text-muted-foreground">
Compare to the message the user saw in their toast.{' '}
<Link
href={`/${portSlug}/admin/errors/codes` as Route}
className="text-primary hover:underline"
>
All codes
</Link>
</p>
</CardContent>
</Card>
);
})()}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Request</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<KV label="Method" value={event.method} />
<KV label="Path" value={event.path} mono />
<KV label="When" value={format(new Date(event.createdAt), 'PPpp')} />
<KV label="Duration" value={event.durationMs ? `${event.durationMs} ms` : '—'} />
<KV label="Port" value={event.portId ?? '(none)'} mono />
<KV label="User" value={event.userId ?? '(none)'} mono />
<KV label="IP" value={event.ipAddress ?? '—'} mono />
<KV label="User agent" value={event.userAgent ?? '—'} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Error</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<KV label="Name" value={event.errorName ?? '—'} mono />
<div>
<p className="text-xs text-muted-foreground">Message</p>
<p className="mt-0.5 font-mono whitespace-pre-wrap break-words">
{event.errorMessage ?? '—'}
</p>
</div>
{event.errorStack && (
<div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Stack (truncated)</p>
<Button
size="sm"
variant="ghost"
onClick={() => copy(event.errorStack ?? '', 'Stack')}
>
<Copy className="mr-1.5 h-3 w-3" /> Copy
</Button>
</div>
<pre className="mt-1 max-h-96 overflow-auto rounded bg-muted p-2 text-xs font-mono whitespace-pre-wrap break-words">
{event.errorStack}
</pre>
</div>
)}
</CardContent>
</Card>
{event.requestBodyExcerpt && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">
Request body (sanitized, max 1 KB)
</CardTitle>
</CardHeader>
<CardContent>
<pre className="max-h-64 overflow-auto rounded bg-muted p-2 text-xs font-mono whitespace-pre-wrap break-words">
{event.requestBodyExcerpt}
</pre>
</CardContent>
</Card>
)}
{event.metadata !== null &&
typeof event.metadata === 'object' &&
Object.keys(event.metadata as Record<string, unknown>).length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Metadata</CardTitle>
</CardHeader>
<CardContent>
<pre className="overflow-auto rounded bg-muted p-2 text-xs font-mono">
{JSON.stringify(event.metadata, null, 2)}
</pre>
</CardContent>
</Card>
)}
</div>
);
}
function KV({ label, value, mono }: { label: string; value: string | null; mono?: boolean }) {
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className={`mt-0.5 ${mono ? 'font-mono text-xs' : ''}`}>{value ?? '—'}</p>
</div>
);
}

View File

@@ -0,0 +1,134 @@
'use client';
import { useState, useMemo } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { ArrowLeft, BookOpen, Search } from 'lucide-react';
import type { Route } from 'next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { ERROR_CODES } from '@/lib/error-codes';
/**
* Error-code reference page surfaced inside the admin section so an
* admin investigating a captured error_events row can flip to this
* tab, look up the code the user reported, and read the canonical
* plain-language meaning + status code without leaving the app.
*
* Pulls directly from `src/lib/error-codes.ts` so it stays in sync
* automatically — adding an entry to the registry adds a row here.
*/
export default function ErrorCodeReferencePage() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [search, setSearch] = useState('');
const entries = useMemo(() => {
const all = Object.entries(ERROR_CODES) as Array<
[string, (typeof ERROR_CODES)[keyof typeof ERROR_CODES]]
>;
if (!search.trim()) return all;
const q = search.trim().toLowerCase();
return all.filter(
([code, def]) => code.toLowerCase().includes(q) || def.userMessage.toLowerCase().includes(q),
);
}, [search]);
// Group by domain prefix (the part before the first underscore) so
// the table reads naturally — Expenses, Berths, Storage, etc.
const grouped = useMemo(() => {
const groups = new Map<string, typeof entries>();
for (const entry of entries) {
const prefix = entry[0].split('_')[0] ?? 'OTHER';
const bucket = groups.get(prefix) ?? [];
bucket.push(entry);
groups.set(prefix, bucket);
}
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
}, [entries]);
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" asChild>
<Link href={`/${portSlug}/admin/errors` as Route}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
Back to error inspector
</Link>
</Button>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<BookOpen className="h-5 w-5" /> Error code reference
</h1>
<p className="text-muted-foreground text-sm mt-1">
Every error code the platform can return, with its HTTP status and the plain-language
message a user sees. Codes are stable identifiers once shipped, they never get
renamed.
</p>
</div>
</div>
<div className="relative max-w-md">
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search code or message…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
{grouped.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
No codes match &quot;{search}&quot;.
</CardContent>
</Card>
) : (
<div className="space-y-4">
{grouped.map(([prefix, items]) => (
<Card key={prefix}>
<CardHeader>
<CardTitle className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{prefix}
</CardTitle>
</CardHeader>
<CardContent className="divide-y">
{items.map(([code, def]) => (
<div key={code} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
<Badge
variant="outline"
className={
def.status >= 500
? 'border-destructive/40 text-destructive'
: def.status >= 400
? 'border-amber-300 text-amber-800'
: 'border-muted'
}
>
{def.status}
</Badge>
<div className="flex-1 min-w-0">
<p className="font-mono text-xs font-semibold">{code}</p>
<p className="text-sm mt-0.5">{def.userMessage}</p>
{'hint' in def && typeof def.hint === 'string' && (
<p className="text-xs text-muted-foreground mt-0.5">{def.hint}</p>
)}
</div>
</div>
))}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { format, formatDistanceToNow } from 'date-fns';
import { AlertTriangle, BookOpen, Search, Wrench } from 'lucide-react';
import type { Route } from 'next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { apiFetch } from '@/lib/api/client';
import { classifyError } from '@/lib/error-classifier';
import type { ErrorEvent } from '@/lib/db/schema/system';
interface ListResponse {
data: ErrorEvent[];
}
/**
* Super-admin error inspector.
*
* Shows the most recent captured 5xx errors with: when, where (HTTP
* method + path), what (error name + message), and a heuristic
* "likely culprit" badge driven by `classifyError`. Click into any
* row for the full stack + body excerpt + raw metadata.
*/
export default function AdminErrorsPage() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [statusFilter, setStatusFilter] = useState('');
const query = useQuery<ListResponse>({
queryKey: ['admin', 'error-events', { statusFilter }],
queryFn: () => {
const search = new URLSearchParams();
if (statusFilter) search.set('statusCode', statusFilter);
return apiFetch<ListResponse>(
`/api/v1/admin/error-events${search.toString() ? `?${search.toString()}` : ''}`,
);
},
});
const events = query.data?.data ?? [];
return (
<div className="space-y-4">
<PageHeader
title="Error inspector"
description="Captured 5xx errors. Click any row for the full stack, request body excerpt, and likely culprit."
actions={
<Button variant="outline" size="sm" asChild>
<Link href={`/${portSlug}/admin/errors/codes` as Route}>
<BookOpen className="mr-1.5 h-4 w-4" />
Code reference
</Link>
</Button>
}
/>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Search className="h-4 w-4" /> Filters
</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor="status">
Status code
</label>
<Input
id="status"
placeholder="e.g. 500"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value.replace(/\D/g, ''))}
className="h-8 w-32"
/>
</div>
{statusFilter && (
<Button variant="ghost" size="sm" className="h-8" onClick={() => setStatusFilter('')}>
Clear
</Button>
)}
</CardContent>
</Card>
{query.isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : events.length === 0 ? (
<EmptyState
icon={AlertTriangle}
title="No captured errors"
description="Nothing has hit a 5xx in the selected window. That's a good thing."
/>
) : (
<div className="rounded-lg border divide-y">
{events.map((event) => {
const culprit = classifyError(event);
return (
<Link
key={event.requestId}
href={`/${portSlug}/admin/errors/${event.requestId}` as Route}
className="flex items-start gap-3 p-3 hover:bg-muted/40"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={
event.statusCode >= 500
? 'border-destructive/40 text-destructive'
: 'border-amber-300 text-amber-800'
}
>
{event.statusCode}
</Badge>
<span className="text-xs font-mono uppercase text-muted-foreground">
{event.method}
</span>
<span className="text-sm font-medium truncate">{event.path}</span>
{culprit && (
<Badge variant="secondary" className="gap-1 text-xs">
<Wrench className="h-3 w-3" />
{culprit.label}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{event.errorName ? `${event.errorName}: ` : ''}
{event.errorMessage ?? '(no message)'}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{formatDistanceToNow(new Date(event.createdAt), { addSuffix: true })} ·{' '}
{format(new Date(event.createdAt), 'MMM d HH:mm:ss')} · ID{' '}
<code className="font-mono">{event.requestId.slice(0, 12)}</code>
</p>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,15 +1,75 @@
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export default function DataImportPage() { export default function DataImportPage() {
return ( return (
<div className="space-y-6"> <div>
<div> <PageHeader
<h1 className="text-2xl font-bold text-foreground">Data Import</h1> title="Data import"
<p className="text-muted-foreground">Import data from external sources</p> description="What you can import today and what an in-app importer will look like."
</div> />
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p> <div className="grid gap-4 mt-6 lg:grid-cols-2">
<p className="text-sm text-muted-foreground"> <Card>
This feature will be implemented in the next phase. <CardHeader>
</p> <CardTitle>Available imports today</CardTitle>
<CardDescription>Run from the command line until the UI catches up.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div>
<p>
<strong>Berths from NocoDB:</strong>
</p>
<pre className="bg-muted/40 rounded-md p-2 text-xs mt-1 overflow-auto">
pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug port-nimara
</pre>
<p className="text-xs text-muted-foreground mt-1">
Idempotent. Skips rows where <code>updated_at &gt; last_imported_at</code> unless
you pass <code>--force</code>. Add <code>--update-snapshot</code> to also rewrite{' '}
<code>src/lib/db/seed-data/berths.json</code>.
</p>
</div>
<div>
<p>
<strong>Storage backend migration:</strong>
</p>
<pre className="bg-muted/40 rounded-md p-2 text-xs mt-1 overflow-auto">
pnpm tsx scripts/migrate-storage.ts
</pre>
<p className="text-xs text-muted-foreground mt-1">
Run after switching <code>system_settings.storage_backend</code> in System Settings.
</p>
</div>
<div>
<p>
<strong>Seed (rebuild dev fixtures):</strong>
</p>
<pre className="bg-muted/40 rounded-md p-2 text-xs mt-1 overflow-auto">
pnpm db:seed
</pre>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>What this page will become</CardTitle>
<CardDescription>Planned UI for self-serve imports.</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<ul className="list-disc pl-5 space-y-1">
<li>Drag-and-drop CSV / XLSX upload with column-mapping UI.</li>
<li>Dry-run preview that shows new vs. matched-existing rows before commit.</li>
<li>Conflict-resolution choices (skip, update, dedup-by-email) per import type.</li>
<li>Per-port import history with rollback.</li>
<li>Templates for clients, yachts, companies, berths, reservations, expenses.</li>
</ul>
<p className="text-xs text-muted-foreground pt-2">
Imports run against the BullMQ <code>import</code> queue (concurrency 1) so partial
failures don&rsquo;t leave the database half-loaded.
</p>
</CardContent>
</Card>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,5 @@
import { InquiryInbox } from '@/components/admin/inquiry-inbox';
export default function InquiriesPage() {
return <InquiryInbox />;
}

View File

@@ -1,15 +1,13 @@
import { InvitationsManager } from '@/components/admin/invitations/invitations-manager'; import { InvitationsManager } from '@/components/admin/invitations/invitations-manager';
import { PageHeader } from '@/components/shared/page-header';
export default function InvitationsPage() { export default function InvitationsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader
<h1 className="text-2xl font-semibold">Invitations</h1> title="Invitations"
<p className="text-sm text-muted-foreground"> description="Send a single-use invitation to a new CRM user. The recipient sets their own password via the link in the email."
Send a single-use invitation to a new CRM user. The recipient sets their own password via />
the link in the email.
</p>
</div>
<InvitationsManager /> <InvitationsManager />
</div> </div>
); );

View File

@@ -0,0 +1,36 @@
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { eq } from 'drizzle-orm';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { userProfiles } from '@/lib/db/schema/users';
/**
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may access
* any page under /[portSlug]/admin. Everyone else is redirected to their dashboard.
*/
export default async function AdminLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
redirect('/login');
}
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, session.user.id),
});
if (!profile?.isSuperAdmin) {
redirect(`/${portSlug}/dashboard`);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,5 @@
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
export default function OcrSettingsPage() {
return <OcrSettingsForm />;
}

View File

@@ -1,16 +1,14 @@
import { OnboardingChecklist } from '@/components/admin/onboarding-checklist';
import { PageHeader } from '@/components/shared/page-header';
export default function OnboardingPage() { export default function OnboardingPage() {
return ( return (
<div className="space-y-6"> <div>
<div> <PageHeader
<h1 className="text-2xl font-bold text-foreground">Onboarding</h1> title="Port onboarding"
<p className="text-muted-foreground">Guided setup for new port configurations</p> description="Bring a new port live. Each step links to the right admin page; checks update automatically once you've configured the underlying setting."
</div> />
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12"> <OnboardingChecklist />
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div> </div>
); );
} }

View File

@@ -5,6 +5,7 @@ import {
Database, Database,
FileText, FileText,
HardDrive, HardDrive,
Inbox,
Key, Key,
LayoutDashboard, LayoutDashboard,
Mail, Mail,
@@ -16,10 +17,13 @@ import {
Tag, Tag,
Upload, Upload,
Users, Users,
UsersRound,
Webhook, Webhook,
Globe,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { PageHeader } from '@/components/shared/page-header';
interface AdminSection { interface AdminSection {
href: string; href: string;
@@ -28,126 +32,232 @@ interface AdminSection {
icon: typeof Settings; icon: typeof Settings;
} }
const SECTIONS: AdminSection[] = [ interface AdminGroup {
title: string;
description: string;
sections: AdminSection[];
}
const GROUPS: AdminGroup[] = [
{ {
href: 'users', title: 'Access',
label: 'Users', description: 'Who can sign in and what they can do once they do.',
description: 'CRM accounts, role assignments, and per-user residential access toggles.', sections: [
icon: Users, {
href: 'users',
label: 'Users',
description: 'CRM accounts, role assignments, and per-user residential access toggles.',
icon: Users,
},
{
href: 'invitations',
label: 'Invitations',
description: 'Send invitations, track pending invites, and resend or revoke them.',
icon: Mail,
},
{
href: 'roles',
label: 'Roles & Permissions',
description: 'Default permission sets and per-port role overrides.',
icon: Shield,
},
],
}, },
{ {
href: 'invitations', title: 'Configuration',
label: 'Invitations', description: 'Branding, integrations, and per-port settings.',
description: 'Send invitations, track pending invites, and resend or revoke them.', sections: [
icon: Mail, {
href: 'email',
label: 'Email Settings',
description: 'From address, signatures, and per-port SMTP overrides.',
icon: Mail,
},
{
href: 'documenso',
label: 'Documenso & EOI',
description: 'API credentials, EOI template, and default in-app vs Documenso pathway.',
icon: FileText,
},
{
href: 'reminders',
label: 'Reminders',
description: 'Default reminder behaviour and the daily-digest delivery window.',
icon: Bell,
},
{
href: 'branding',
label: 'Branding',
description: 'App name, logo, primary color, and email header/footer HTML.',
icon: Palette,
},
{
href: 'settings',
label: 'System Settings',
description: 'Generic key/value configuration store for advanced flags.',
icon: Settings,
},
{
href: 'webhooks',
label: 'Webhooks',
description: 'Outgoing webhook subscriptions, secrets, and delivery log.',
icon: Webhook,
},
],
}, },
{ {
href: 'roles', title: 'Content',
label: 'Roles & Permissions', description: 'Forms, templates, and labels that users see.',
description: 'Default permission sets and per-port role overrides.', sections: [
icon: Shield, {
href: 'forms',
label: 'Forms',
description: 'Form templates used by client-facing inquiry and intake flows.',
icon: Sliders,
},
{
href: 'templates',
label: 'Document Templates',
description: 'PDF + email templates with merge-field placeholders.',
icon: FileText,
},
{
href: 'email-templates',
label: 'Email Templates',
description: 'Customize subject lines for transactional emails (portal, inquiry, invite).',
icon: Mail,
},
{
href: 'tags',
label: 'Tags',
description: 'Color-coded tags applied to clients, yachts, companies, and interests.',
icon: Tag,
},
{
href: 'custom-fields',
label: 'Custom Fields',
description: 'Tenant-defined fields for clients, yachts, and reservations.',
icon: Key,
},
],
}, },
{ {
href: 'audit', title: 'Data Quality',
label: 'Audit Log', description: 'Cleanup, imports, and the audit trail.',
description: 'Searchable log of every authenticated mutation in the system.', sections: [
icon: ScrollText, {
href: 'inquiries',
label: 'Inquiry Inbox',
description:
'Submissions captured from the public marketing site (berth, residence, contact).',
icon: Inbox,
},
{
href: 'sends',
label: 'Send Log',
description: 'Brochure and per-berth PDF sends, with delivery failures surfaced for retry.',
icon: Mail,
},
{
href: 'duplicates',
label: 'Duplicates',
description: 'Review queue of suspected duplicate clients flagged by the dedup engine.',
icon: UsersRound,
},
{
href: 'import',
label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.',
icon: Upload,
},
{
href: 'audit',
label: 'Audit Log',
description: 'Searchable log of every authenticated mutation in the system.',
icon: ScrollText,
},
],
}, },
{ {
href: 'email', title: 'Operations',
label: 'Email Settings', description: 'Health checks and disaster recovery.',
description: 'From address, signatures, and per-port SMTP overrides.', sections: [
icon: Mail, {
href: 'reports',
label: 'Reports',
description: 'Saved analytics views and ad-hoc query results.',
icon: LayoutDashboard,
},
{
href: 'monitoring',
label: 'Queue Monitoring',
description: 'BullMQ queue health, throughput, and retry diagnostics.',
icon: Database,
},
{
href: 'backup',
label: 'Backup & Restore',
description: 'Backup posture + retention policy (read-only).',
icon: HardDrive,
},
{
href: 'storage',
label: 'Storage Backend',
description:
'Choose between S3-compatible object store or local filesystem; migrate between them.',
icon: HardDrive,
},
],
}, },
{ {
href: 'documenso', title: 'Tenancy',
label: 'Documenso & EOI', description: 'Multi-port and multi-install scaffolding.',
description: 'API credentials, EOI template, and default in-app vs Documenso pathway.', sections: [
icon: FileText, {
href: 'ports',
label: 'Ports',
description: 'Manage the marinas/ports this installation serves.',
icon: Briefcase,
},
{
href: 'onboarding',
label: 'Onboarding checklist',
description: 'Setup checklist for fresh ports (read-only references).',
icon: LayoutDashboard,
},
],
}, },
{ {
href: 'reminders', title: 'Integrations',
label: 'Reminders', description: 'Third-party providers wired into the app.',
description: 'Default reminder behaviour and the daily-digest delivery window.', sections: [
icon: Bell, {
}, href: 'ai',
{ label: 'AI configuration',
href: 'branding', description:
label: 'Branding', 'Master switch + provider credentials shared by every AI surface (OCR, berth-PDF parser, future recommender embeddings).',
description: 'App name, logo, primary color, and email header/footer HTML.', icon: ScrollText,
icon: Palette, },
}, {
{ href: 'ocr',
href: 'settings', label: 'Receipt OCR (per-feature)',
label: 'System Settings', description: 'Provider, model, and confidence thresholds for the receipt scanner.',
description: 'Generic key/value configuration store for advanced flags.', icon: ScrollText,
icon: Settings, },
}, {
{ href: 'website-analytics',
href: 'webhooks', label: 'Website analytics (Umami)',
label: 'Webhooks', description: 'Per-port Umami URL, API token, and Website ID.',
description: 'Outgoing webhook subscriptions, secrets, and delivery log.', icon: Globe,
icon: Webhook, },
}, {
{ href: 'residential-stages',
href: 'forms', label: 'Residential pipeline stages',
label: 'Forms', description:
description: 'Form templates used by client-facing inquiry and intake flows.', 'Configure stages residential interests flow through. Removing a stage with active interests prompts for reassignment.',
icon: Sliders, icon: ScrollText,
}, },
{ ],
href: 'templates',
label: 'Document Templates',
description: 'PDF + email templates with merge-field placeholders.',
icon: FileText,
},
{
href: 'tags',
label: 'Tags',
description: 'Color-coded tags applied to clients, yachts, companies, and interests.',
icon: Tag,
},
{
href: 'custom-fields',
label: 'Custom Fields',
description: 'Tenant-defined fields for clients, yachts, and reservations.',
icon: Key,
},
{
href: 'reports',
label: 'Reports',
description: 'Saved analytics views and ad-hoc query results.',
icon: LayoutDashboard,
},
{
href: 'monitoring',
label: 'Queue Monitoring',
description: 'BullMQ queue health, throughput, and retry diagnostics.',
icon: Database,
},
{
href: 'import',
label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.',
icon: Upload,
},
{
href: 'backup',
label: 'Backup & Restore',
description: 'Database snapshots and on-demand exports.',
icon: HardDrive,
},
{
href: 'ports',
label: 'Ports',
description: 'Manage the marinas/ports this installation serves.',
icon: Briefcase,
},
{
href: 'onboarding',
label: 'Onboarding',
description: 'Initial-setup wizard for fresh ports.',
icon: LayoutDashboard,
}, },
]; ];
@@ -158,39 +268,46 @@ export default async function AdminLandingPage({
}) { }) {
const { portSlug } = await params; const { portSlug } = await params;
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<div> <PageHeader
<h1 className="text-2xl font-semibold">Administration</h1> title="Administration"
<p className="text-sm text-muted-foreground"> description="Per-port configuration and system administration. Each card below opens a dedicated settings page."
Per-port configuration and system administration. Each card below opens a dedicated />
settings page. {GROUPS.map((group) => (
</p> <section key={group.title} className="space-y-3">
</div> <div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{SECTIONS.map((s) => { {group.title}
const Icon = s.icon; </h2>
return ( <p className="text-xs text-muted-foreground/80">{group.description}</p>
<Link </div>
key={s.href} <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
// eslint-disable-next-line @typescript-eslint/no-explicit-any {group.sections.map((s) => {
href={`/${portSlug}/admin/${s.href}` as any} const Icon = s.icon;
className="block group" return (
> <Link
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30"> key={s.href}
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2"> // eslint-disable-next-line @typescript-eslint/no-explicit-any
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" /> href={`/${portSlug}/admin/${s.href}` as any}
<div className="flex-1"> className="block group"
<CardTitle className="text-base">{s.label}</CardTitle> >
</div> <Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
</CardHeader> <CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
<CardContent> <Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" />
<CardDescription>{s.description}</CardDescription> <div className="flex-1">
</CardContent> <CardTitle className="text-base">{s.label}</CardTitle>
</Card> </div>
</Link> </CardHeader>
); <CardContent>
})} <CardDescription>{s.description}</CardDescription>
</div> </CardContent>
</Card>
</Link>
);
})}
</div>
</section>
))}
</div> </div>
); );
} }

View File

@@ -2,6 +2,7 @@ import {
SettingsFormCard, SettingsFormCard,
type SettingFieldDef, type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card'; } from '@/components/admin/shared/settings-form-card';
import { PageHeader } from '@/components/shared/page-header';
const DEFAULT_FIELDS: SettingFieldDef[] = [ const DEFAULT_FIELDS: SettingFieldDef[] = [
{ {
@@ -43,9 +44,8 @@ const DIGEST_FIELDS: SettingFieldDef[] = [
{ {
key: 'reminder_digest_timezone', key: 'reminder_digest_timezone',
label: 'Digest timezone', label: 'Digest timezone',
description: 'IANA timezone name used to interpret the delivery time (e.g. Europe/Warsaw).', description: 'IANA timezone name used to interpret the delivery time.',
type: 'string', type: 'timezone',
placeholder: 'Europe/Warsaw',
defaultValue: 'Europe/Warsaw', defaultValue: 'Europe/Warsaw',
}, },
]; ];
@@ -53,14 +53,10 @@ const DIGEST_FIELDS: SettingFieldDef[] = [
export default function ReminderSettingsPage() { export default function ReminderSettingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader
<h1 className="text-2xl font-semibold">Reminders</h1> title="Reminders"
<p className="text-sm text-muted-foreground"> description="Default reminder behaviour for new interests and the optional daily-digest delivery window. Individual users can still configure their own digest preferences in Notifications → Preferences."
Default reminder behaviour for new interests and the optional daily-digest delivery />
window. Individual users can still configure their own digest preferences in Notifications
Preferences.
</p>
</div>
<SettingsFormCard <SettingsFormCard
title="Defaults for new interests" title="Defaults for new interests"

View File

@@ -1,16 +1,5 @@
export default function ScheduledReportsPage() { import { ReportsDashboard } from '@/components/admin/reports-dashboard';
return (
<div className="space-y-6"> export default function AdminReportsPage() {
<div> return <ReportsDashboard />;
<h1 className="text-2xl font-bold text-foreground">Scheduled Reports</h1>
<p className="text-muted-foreground">Configure and manage automated report delivery</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
} }

View File

@@ -0,0 +1,15 @@
import { ResidentialStagesAdmin } from '@/components/admin/residential-stages-admin';
import { PageHeader } from '@/components/shared/page-header';
export default function ResidentialStagesPage() {
return (
<div className="space-y-6">
<PageHeader
title="Residential pipeline stages"
eyebrow="ADMIN"
description="Configure the stages residential interests flow through. Removing a stage that still has interests prompts you to reassign them before saving."
/>
<ResidentialStagesAdmin />
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { SendsLog } from '@/components/admin/sends-log';
export default function SendsPage() {
return <SendsLog />;
}

View File

@@ -0,0 +1,7 @@
import { StorageAdminPanel } from '@/components/admin/storage-admin-panel';
export const dynamic = 'force-dynamic';
export default function StorageAdminPage() {
return <StorageAdminPanel />;
}

View File

@@ -1,7 +1,9 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/shared/page-header';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
AlertDialog, AlertDialog,
@@ -36,14 +38,18 @@ export default function WebhooksPage() {
const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(null); const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [regenerating, setRegenerating] = useState<string | null>(null); const [regenerating, setRegenerating] = useState<string | null>(null);
const [newSecret, setNewSecret] = useState<{ webhookId: string; secret: string; masked: string } | null>(null); const [newSecret, setNewSecret] = useState<{
webhookId: string;
secret: string;
masked: string;
} | null>(null);
const loadWebhooks = useCallback(async () => { const loadWebhooks = useCallback(async () => {
try { try {
const result = await apiFetch<{ data: Webhook[] }>('/api/v1/admin/webhooks'); const result = await apiFetch<{ data: Webhook[] }>('/api/v1/admin/webhooks');
setWebhooks(result.data); setWebhooks(result.data);
} catch { } catch (err) {
// ignore toast.error(err instanceof Error ? err.message : 'Failed to load webhooks');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -58,9 +64,10 @@ export default function WebhooksPage() {
try { try {
await apiFetch(`/api/v1/admin/webhooks/${deleteTarget.id}`, { method: 'DELETE' }); await apiFetch(`/api/v1/admin/webhooks/${deleteTarget.id}`, { method: 'DELETE' });
setDeleteTarget(null); setDeleteTarget(null);
toast.success('Webhook deleted');
void loadWebhooks(); void loadWebhooks();
} catch { } catch (err) {
// ignore toast.error(err instanceof Error ? err.message : 'Failed to delete webhook');
} }
} }
@@ -73,8 +80,8 @@ export default function WebhooksPage() {
); );
setNewSecret({ webhookId, secret: result.data.secret, masked: result.data.secretMasked }); setNewSecret({ webhookId, secret: result.data.secret, masked: result.data.secretMasked });
void loadWebhooks(); void loadWebhooks();
} catch { } catch (err) {
// ignore toast.error(err instanceof Error ? err.message : 'Failed to regenerate secret');
} finally { } finally {
setRegenerating(null); setRegenerating(null);
} }
@@ -86,9 +93,10 @@ export default function WebhooksPage() {
method: 'PATCH', method: 'PATCH',
body: { isActive: !webhook.isActive }, body: { isActive: !webhook.isActive },
}); });
toast.success(webhook.isActive ? 'Webhook disabled' : 'Webhook enabled');
void loadWebhooks(); void loadWebhooks();
} catch { } catch (err) {
// ignore toast.error(err instanceof Error ? err.message : 'Failed to toggle webhook');
} }
} }
@@ -98,15 +106,20 @@ export default function WebhooksPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <PageHeader
<div> title="Webhooks"
<h1 className="text-2xl font-bold text-foreground">Webhooks</h1> description="Configure outgoing webhook integrations"
<p className="text-muted-foreground">Configure outgoing webhook integrations</p> actions={
</div> <Button
<Button onClick={() => { setEditTarget(null); setFormOpen(true); }}> onClick={() => {
Add Webhook setEditTarget(null);
</Button> setFormOpen(true);
</div> }}
>
Add Webhook
</Button>
}
/>
{loading ? ( {loading ? (
<p className="text-sm text-muted-foreground">Loading...</p> <p className="text-sm text-muted-foreground">Loading...</p>
@@ -116,7 +129,13 @@ export default function WebhooksPage() {
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Add a webhook to receive real-time notifications of CRM events. Add a webhook to receive real-time notifications of CRM events.
</p> </p>
<Button className="mt-4" onClick={() => { setEditTarget(null); setFormOpen(true); }}> <Button
className="mt-4"
onClick={() => {
setEditTarget(null);
setFormOpen(true);
}}
>
Add Webhook Add Webhook
</Button> </Button>
</div> </div>
@@ -141,17 +160,16 @@ export default function WebhooksPage() {
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<Button <Button variant="ghost" size="sm" onClick={() => handleToggleActive(webhook)}>
variant="ghost"
size="sm"
onClick={() => handleToggleActive(webhook)}
>
{webhook.isActive ? 'Disable' : 'Enable'} {webhook.isActive ? 'Disable' : 'Enable'}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { setEditTarget(webhook); setFormOpen(true); }} onClick={() => {
setEditTarget(webhook);
setFormOpen(true);
}}
> >
Edit Edit
</Button> </Button>
@@ -163,11 +181,7 @@ export default function WebhooksPage() {
> >
Delete Delete
</Button> </Button>
<Button <Button variant="ghost" size="sm" onClick={() => toggleExpand(webhook.id)}>
variant="ghost"
size="sm"
onClick={() => toggleExpand(webhook.id)}
>
{expandedId === webhook.id ? 'Collapse' : 'Details'} {expandedId === webhook.id ? 'Collapse' : 'Details'}
</Button> </Button>
</div> </div>
@@ -228,18 +242,26 @@ export default function WebhooksPage() {
onSuccess={loadWebhooks} onSuccess={loadWebhooks}
/> />
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}> <AlertDialog
open={!!deleteTarget}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete Webhook</AlertDialogTitle> <AlertDialogTitle>Delete Webhook</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Delete &quot;{deleteTarget?.name}&quot;? This will also delete all delivery history. This action Delete &quot;{deleteTarget?.name}&quot;? This will also delete all delivery history.
cannot be undone. This action cannot be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground"> <AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground"
>
Delete Delete
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View File

@@ -0,0 +1,74 @@
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { UmamiTestButton } from '@/components/admin/website-analytics/umami-test-button';
import { PageHeader } from '@/components/shared/page-header';
/**
* Per-port Umami credentials. We deliberately keep all three values
* port-scoped (per the operator decision) so different ports can point at
* different Umami instances if needed. The /website-analytics dashboard
* page reads these settings via the umami.service layer at request time.
*/
const FIELDS: SettingFieldDef[] = [
{
key: 'umami_api_url',
label: 'Umami API URL',
description:
'Base URL of the Umami instance, e.g. https://analytics.portnimara.com (no trailing slash, no /api).',
type: 'string',
placeholder: 'https://analytics.portnimara.com',
defaultValue: '',
},
{
key: 'umami_api_token',
label: 'API token',
description:
'Long-lived API token if your Umami install supports one (Umami Cloud or v2 self-hosted with API keys enabled). Leave blank if you only have username/password - the service falls back to the JWT login flow using the credentials below. Stored in plain text in system_settings.',
type: 'password',
defaultValue: '',
},
{
key: 'umami_username',
label: 'Username',
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
type: 'string',
placeholder: 'admin',
defaultValue: '',
},
{
key: 'umami_password',
label: 'Password',
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
type: 'password',
defaultValue: '',
},
{
key: 'umami_website_id',
label: 'Website ID',
description:
'UUID of this ports website inside Umami. Find it in Umami → Settings → Websites → Edit → Website ID.',
type: 'string',
placeholder: '00000000-0000-0000-0000-000000000000',
defaultValue: '',
},
];
export default function WebsiteAnalyticsSettingsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Website analytics (Umami)"
description="Connect this port to its Umami website to display traffic, top pages, referrers, and conversion data on the Website Analytics dashboard."
/>
<SettingsFormCard
title="Umami connection"
description="Per-port credentials. Each port can point at its own Umami instance; or share one instance with different website IDs."
fields={FIELDS}
extra={<UmamiTestButton />}
/>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell';
export default function AlertsPage() {
return <AlertsPageShell />;
}

View File

@@ -0,0 +1,5 @@
import { BerthReservationsList } from '@/components/reservations/berth-reservations-list';
export default function BerthReservationsPage() {
return <BerthReservationsList />;
}

View File

@@ -1,4 +1,5 @@
import { BerthDetail } from '@/components/berths/berth-detail'; import { BerthDetail } from '@/components/berths/berth-detail';
import { TrackEntityView } from '@/components/search/track-entity-view';
interface BerthPageProps { interface BerthPageProps {
params: Promise<{ portSlug: string; berthId: string }>; params: Promise<{ portSlug: string; berthId: string }>;
@@ -6,5 +7,10 @@ interface BerthPageProps {
export default async function BerthPage({ params }: BerthPageProps) { export default async function BerthPage({ params }: BerthPageProps) {
const { berthId } = await params; const { berthId } = await params;
return <BerthDetail berthId={berthId} />; return (
<>
<TrackEntityView type="berth" id={berthId} />
<BerthDetail berthId={berthId} />
</>
);
} }

View File

@@ -0,0 +1,41 @@
import { Skeleton } from '@/components/ui/skeleton';
import { CardSkeleton } from '@/components/shared/loading-skeleton';
/**
* Route-level loading UI for the client detail page. Renders while the
* server component resolves the session and the client component bootstraps
* its initial query - replaces the previous empty-header flash on direct
* URL visits.
*/
export default function Loading() {
return (
<div className="space-y-6">
{/* Header strip - title, badges, action buttons */}
<div className="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-56" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<div className="flex flex-wrap gap-2">
<Skeleton className="h-9 w-20 rounded-md" />
<Skeleton className="h-9 w-20 rounded-md" />
<Skeleton className="h-9 w-24 rounded-md" />
<Skeleton className="h-9 w-32 rounded-md" />
</div>
</div>
{/* Tab strip */}
<div className="flex gap-2 border-b border-border pb-1">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-20 rounded-md" />
))}
</div>
{/* Two-column overview */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CardSkeleton />
<CardSkeleton />
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { ClientDetail } from '@/components/clients/client-detail'; import { ClientDetail } from '@/components/clients/client-detail';
import { TrackEntityView } from '@/components/search/track-entity-view';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
@@ -12,5 +13,10 @@ export default async function ClientDetailPage({ params }: ClientDetailPageProps
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
const currentUserId = session?.user?.id; const currentUserId = session?.user?.id;
return <ClientDetail clientId={clientId} currentUserId={currentUserId} />; return (
<>
<TrackEntityView type="client" id={clientId} />
<ClientDetail clientId={clientId} currentUserId={currentUserId} />
</>
);
} }

View File

@@ -1,4 +1,5 @@
import { CompanyDetail } from '@/components/companies/company-detail'; import { CompanyDetail } from '@/components/companies/company-detail';
import { TrackEntityView } from '@/components/search/track-entity-view';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
@@ -12,5 +13,10 @@ export default async function CompanyDetailPage({ params }: CompanyDetailPagePro
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
const currentUserId = session?.user?.id; const currentUserId = session?.user?.id;
return <CompanyDetail companyId={companyId} currentUserId={currentUserId} />; return (
<>
<TrackEntityView type="company" id={companyId} />
<CompanyDetail companyId={companyId} currentUserId={currentUserId} />
</>
);
} }

View File

@@ -1,4 +1,5 @@
import { DocumentDetail } from '@/components/documents/document-detail'; import { DocumentDetail } from '@/components/documents/document-detail';
import { TrackEntityView } from '@/components/search/track-entity-view';
interface PageProps { interface PageProps {
params: Promise<{ portSlug: string; id: string }>; params: Promise<{ portSlug: string; id: string }>;
@@ -6,5 +7,10 @@ interface PageProps {
export default async function DocumentDetailPage({ params }: PageProps) { export default async function DocumentDetailPage({ params }: PageProps) {
const { portSlug, id } = await params; const { portSlug, id } = await params;
return <DocumentDetail documentId={id} portSlug={portSlug} />; return (
<>
<TrackEntityView type="document" id={id} />
<DocumentDetail documentId={id} portSlug={portSlug} />
</>
);
} }

View File

@@ -5,6 +5,7 @@ import { useParams, useRouter } from 'next/navigation';
import { ExpenseDetail } from '@/components/expenses/expense-detail'; import { ExpenseDetail } from '@/components/expenses/expense-detail';
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog'; import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
import { TrackEntityView } from '@/components/search/track-entity-view';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import type { ExpenseRow } from '@/components/expenses/expense-columns'; import type { ExpenseRow } from '@/components/expenses/expense-columns';
@@ -22,6 +23,7 @@ export default function ExpenseDetailPage() {
return ( return (
<div className="max-w-3xl mx-auto"> <div className="max-w-3xl mx-auto">
<TrackEntityView type="expense" id={params.id} />
<ExpenseDetail <ExpenseDetail
expenseId={params.id} expenseId={params.id}
onEdit={() => setEditOpen(true)} onEdit={() => setEditOpen(true)}
@@ -29,11 +31,7 @@ export default function ExpenseDetailPage() {
/> />
{data?.data && ( {data?.data && (
<ExpenseFormDialog <ExpenseFormDialog open={editOpen} onOpenChange={setEditOpen} expense={data.data} />
open={editOpen}
onOpenChange={setEditOpen}
expense={data.data}
/>
)} )}
</div> </div>
); );

View File

@@ -20,6 +20,7 @@ import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog'; import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog'; import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
import { ExpenseCard } from '@/components/expenses/expense-card';
import { expenseFilterDefinitions } from '@/components/expenses/expense-filters'; import { expenseFilterDefinitions } from '@/components/expenses/expense-filters';
import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns'; import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
@@ -60,8 +61,7 @@ export default function ExpensesPage() {
}); });
const archiveMutation = useMutation({ const archiveMutation = useMutation({
mutationFn: (id: string) => mutationFn: (id: string) => apiFetch(`/api/v1/expenses/${id}`, { method: 'DELETE' }),
apiFetch(`/api/v1/expenses/${id}`, { method: 'DELETE' }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expenses'] }); queryClient.invalidateQueries({ queryKey: ['expenses'] });
setArchiveExpense(null); setArchiveExpense(null);
@@ -151,6 +151,14 @@ export default function ExpensesPage() {
onSortChange={setSort} onSortChange={setSort}
isLoading={isFetching && !isLoading} isLoading={isFetching && !isLoading}
getRowId={(row) => row.id} getRowId={(row) => row.id}
cardRender={(row) => (
<ExpenseCard
expense={row.original}
portSlug={portSlug}
onEdit={setEditExpense}
onArchive={setArchiveExpense}
/>
)}
emptyState={ emptyState={
<EmptyState <EmptyState
title="No expenses found" title="No expenses found"

View File

@@ -1,9 +1,11 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { Upload, Loader2, ScanLine } from 'lucide-react'; import { Camera, Loader2, ScanLine, Upload, X } from 'lucide-react';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -28,13 +30,32 @@ interface ScanResult {
confidence: number; confidence: number;
} }
interface UploadedFileMeta {
id: string;
filename: string;
}
export default function ScanReceiptPage() { export default function ScanReceiptPage() {
const params = useParams<{ portSlug: string }>(); const params = useParams<{ portSlug: string }>();
const router = useRouter(); const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null); const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
// After OCR succeeds we also upload the receipt to /api/v1/files/upload
// so the expense links to the actual image. The legacy scanner skipped
// this step and saved expenses without their receipt — which silently
// disqualified them from parent-company reimbursement (the warning the
// PDF export now surfaces).
const [uploadedFile, setUploadedFile] = useState<UploadedFileMeta | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'Scan Receipt', showBackButton: true });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
// Editable fields from scan // Editable fields from scan
const [establishment, setEstablishment] = useState(''); const [establishment, setEstablishment] = useState('');
@@ -65,6 +86,29 @@ export default function ScanReceiptPage() {
}, },
}); });
// Uploads the receipt image to /api/v1/files/upload (category=receipt)
// so the new expense row can link to it via receiptFileIds. Runs in
// parallel with the OCR scan so the rep can keep editing fields while
// the upload completes.
const uploadMutation = useMutation({
mutationFn: async (file: File): Promise<UploadedFileMeta> => {
const formData = new FormData();
formData.append('file', file);
formData.append('category', 'receipt');
const res = await fetch('/api/v1/files/upload', {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!res.ok) throw new Error('Receipt upload failed');
const json = (await res.json()) as { data: { id: string; filename: string } };
return { id: json.data.id, filename: json.data.filename };
},
onSuccess: (meta) => {
setUploadedFile(meta);
},
});
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: () => mutationFn: () =>
apiFetch('/api/v1/expenses', { apiFetch('/api/v1/expenses', {
@@ -76,6 +120,9 @@ export default function ScanReceiptPage() {
category: category || undefined, category: category || undefined,
expenseDate: date ? new Date(date) : new Date(), expenseDate: date ? new Date(date) : new Date(),
paymentStatus: 'unpaid', paymentStatus: 'unpaid',
receiptFileIds: uploadedFile ? [uploadedFile.id] : undefined,
// The scanner path always has a receipt (we wouldn't have OCR'd
// it otherwise), so we never need the no-receipt flag here.
}, },
}), }),
onSuccess: () => { onSuccess: () => {
@@ -86,15 +133,35 @@ export default function ScanReceiptPage() {
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
setPendingFile(file);
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
setPreviewUrl(url); setPreviewUrl(url);
// Kick off OCR scan + storage upload concurrently. The two are
// independent server calls and the rep is staring at the preview
// while both run.
scanMutation.mutate(file); scanMutation.mutate(file);
uploadMutation.mutate(file);
} }
function handleClearReceipt() {
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
setUploadedFile(null);
setPendingFile(null);
setScanResult(null);
// Reset in-flight mutations so a late onSuccess doesn't repopulate
// the form against an already-cleared UI (audit finding: stale
// receipt could land on the next Save).
scanMutation.reset();
uploadMutation.reset();
if (fileInputRef.current) fileInputRef.current.value = '';
if (cameraInputRef.current) cameraInputRef.current.value = '';
}
void pendingFile;
return ( return (
<div className="max-w-2xl mx-auto space-y-6"> <div className="max-w-2xl mx-auto space-y-6">
<div> <div className="hidden sm:block">
<h1 className="text-2xl font-bold">Scan Receipt</h1> <h1 className="text-2xl font-bold">Scan Receipt</h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">
Upload a receipt image and we will extract the expense details automatically. Upload a receipt image and we will extract the expense details automatically.
@@ -109,32 +176,88 @@ export default function ScanReceiptPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div {previewUrl ? (
className="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:bg-muted/50 transition-colors" <div className="space-y-2">
onClick={() => fileInputRef.current?.click()} <div className="relative border-2 border-dashed rounded-lg p-4 text-center bg-muted/20">
> <img
{previewUrl ? ( src={previewUrl}
<img alt="Receipt preview"
src={previewUrl} className="max-h-64 mx-auto rounded object-contain"
alt="Receipt preview" />
className="max-h-64 mx-auto rounded object-contain" <button
/> type="button"
) : ( onClick={handleClearReceipt}
<div className="space-y-2"> aria-label="Remove receipt"
<Upload className="h-8 w-8 mx-auto text-muted-foreground" /> className="absolute top-2 right-2 rounded-full bg-background/80 hover:bg-background border p-1.5 shadow-sm"
<p className="text-sm text-muted-foreground"> >
Click to upload or drag and drop <X className="h-4 w-4" />
</p> </button>
<p className="text-xs text-muted-foreground">
JPEG, PNG, WebP up to 10MB
</p>
</div> </div>
)} <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
</div> {uploadMutation.isPending && (
<span className="inline-flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" /> Uploading receipt&hellip;
</span>
)}
{uploadedFile && (
<span className="text-emerald-600">
Receipt uploaded ({uploadedFile.filename})
</span>
)}
{uploadMutation.isError && (
<span className="text-destructive">
Receipt upload failed save will still create the expense without an image.
</span>
)}
</div>
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{/* Camera button — available on mobile devices that surface the
built-in capture flow when an `image/*` input has the
`capture` attribute. Hidden on desktop where it's a no-op. */}
<Button
type="button"
size="lg"
className="w-full h-14 sm:hidden"
onClick={() => cameraInputRef.current?.click()}
>
<Camera className="mr-2 h-5 w-5" />
Take photo
</Button>
{/* File picker — works on every platform. Phrased so the copy
fits both mobile (library/files) and desktop (drag and drop). */}
<Button
type="button"
variant="outline"
size="lg"
className="w-full h-14"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="mr-2 h-5 w-5" />
<span className="sm:hidden">Choose from device</span>
<span className="hidden sm:inline">Choose from device or drag and drop</span>
</Button>
<p className="text-xs text-muted-foreground sm:col-span-2 text-center">
JPEG, PNG, HEIC, WebP up to 10 MB
</p>
</div>
)}
{/* `image/*` is the broadest accept — includes HEIC on iOS,
JPEG/PNG/WebP everywhere. The capture attribute on the second
input invokes the native camera flow on mobile. */}
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/*,application/pdf"
className="hidden"
onChange={handleFileChange}
/>
<input
ref={cameraInputRef}
type="file"
accept="image/*" accept="image/*"
capture="environment"
className="hidden" className="hidden"
onChange={handleFileChange} onChange={handleFileChange}
/> />
@@ -145,10 +268,18 @@ export default function ScanReceiptPage() {
<span className="text-sm">Scanning receipt...</span> <span className="text-sm">Scanning receipt...</span>
</div> </div>
)} )}
{scanMutation.isError && (
<div className="mt-4 rounded-md border border-amber-300 bg-amber-50 p-3 text-xs text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
<span className="font-medium">Couldn&apos;t read this receipt automatically.</span>{' '}
You can still fill in the details manually below the receipt image will save with
the expense.
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
{(scanResult || scanMutation.isSuccess) && ( {(scanResult || scanMutation.isSuccess || scanMutation.isError || uploadedFile) && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base"> <CardTitle className="text-base">
@@ -222,26 +353,29 @@ export default function ScanReceiptPage() {
</div> </div>
{saveMutation.isError && ( {saveMutation.isError && (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">{(saveMutation.error as Error).message}</p>
{(saveMutation.error as Error).message}
</p>
)} )}
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<Button <Button variant="outline" onClick={() => router.push(`/${params.portSlug}/expenses`)}>
variant="outline"
onClick={() => router.push(`/${params.portSlug}/expenses`)}
>
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={() => saveMutation.mutate()} onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !amount} disabled={
saveMutation.isPending ||
!amount ||
// Block save while the receipt upload is still in flight —
// otherwise the rep can hit Save before the storage round
// trip finishes and the expense lands without `receiptFileIds`,
// silently re-creating the legacy receipt-loss bug.
uploadMutation.isPending
}
> >
{saveMutation.isPending && ( {(saveMutation.isPending || uploadMutation.isPending) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
)} )}
Save as Expense {uploadMutation.isPending ? 'Uploading…' : 'Save as Expense'}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>

View File

@@ -1,4 +1,5 @@
import { InterestDetail } from '@/components/interests/interest-detail'; import { InterestDetail } from '@/components/interests/interest-detail';
import { TrackEntityView } from '@/components/search/track-entity-view';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
@@ -12,5 +13,10 @@ export default async function InterestDetailPage({ params }: InterestDetailPageP
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
const currentUserId = session?.user?.id; const currentUserId = session?.user?.id;
return <InterestDetail interestId={interestId} currentUserId={currentUserId} />; return (
<>
<TrackEntityView type="interest" id={interestId} />
<InterestDetail interestId={interestId} currentUserId={currentUserId} />
</>
);
} }

View File

@@ -1,5 +1,6 @@
import { use } from 'react'; import { use } from 'react';
import { InvoiceDetail } from '@/components/invoices/invoice-detail'; import { InvoiceDetail } from '@/components/invoices/invoice-detail';
import { TrackEntityView } from '@/components/search/track-entity-view';
interface InvoiceDetailPageProps { interface InvoiceDetailPageProps {
params: Promise<{ portSlug: string; id: string }>; params: Promise<{ portSlug: string; id: string }>;
@@ -9,6 +10,7 @@ export default function InvoiceDetailPage({ params }: InvoiceDetailPageProps) {
const { id } = use(params); const { id } = use(params);
return ( return (
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
<TrackEntityView type="invoice" id={id} />
<InvoiceDetail invoiceId={id} /> <InvoiceDetail invoiceId={id} />
</div> </div>
); );

View File

@@ -1,11 +1,13 @@
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useForm, FormProvider } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { ChevronLeft, ChevronRight, Check, Loader2 } from 'lucide-react'; import { ChevronLeft, ChevronRight, Check, Loader2, Wallet } from 'lucide-react';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -43,9 +45,35 @@ export default function NewInvoicePage() {
const params = useParams<{ portSlug: string }>(); const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? ''; const portSlug = params?.portSlug ?? '';
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const prefilledInterestId = searchParams.get('interestId') ?? undefined;
const prefilledKind =
searchParams.get('kind') === 'deposit' ? ('deposit' as const) : ('general' as const);
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'New Invoice', showBackButton: true });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
// When the form is launched from an interest detail with `?interestId=…&kind=deposit`,
// fetch enough of the interest to display "Deposit for {client} - Berth {n}" in
// the review step. Doubles as the source of truth for the billing entity prefill.
const { data: prefilledInterest } = useQuery<{
data: {
id: string;
clientId: string;
clientName: string | null;
berthMooringNumber: string | null;
};
}>({
queryKey: ['interest-prefill', prefilledInterestId],
queryFn: () => apiFetch(`/api/v1/interests/${prefilledInterestId}`),
enabled: !!prefilledInterestId,
});
const methods = useForm<CreateInvoiceInput>({ const methods = useForm<CreateInvoiceInput>({
resolver: zodResolver(createInvoiceSchema), resolver: zodResolver(createInvoiceSchema),
defaultValues: { defaultValues: {
@@ -53,6 +81,8 @@ export default function NewInvoicePage() {
currency: 'USD', currency: 'USD',
lineItems: [], lineItems: [],
expenseIds: [], expenseIds: [],
interestId: prefilledInterestId,
kind: prefilledKind,
}, },
}); });
@@ -65,6 +95,43 @@ export default function NewInvoicePage() {
} = methods; } = methods;
const watchedValues = watch(); const watchedValues = watch();
const isDepositInvoice = watchedValues.kind === 'deposit';
// Resolve the selected billing entity to a human name so the review step
// shows "Acme Yacht Charters" instead of "company 4f2a1b…".
const billingEntityRef = watchedValues.billingEntity ?? null;
const { data: billingEntityName } = useQuery<{ name: string }>({
queryKey: ['billing-entity-name', billingEntityRef?.type, billingEntityRef?.id],
queryFn: async () => {
if (!billingEntityRef) return { name: '' };
const path =
billingEntityRef.type === 'company'
? `/api/v1/companies/${billingEntityRef.id}`
: `/api/v1/clients/${billingEntityRef.id}`;
const res = await apiFetch<{
data: { fullName?: string; name?: string };
}>(path);
return {
name: res?.data?.fullName ?? res?.data?.name ?? '',
};
},
enabled: !!billingEntityRef?.id,
staleTime: 60_000,
});
// Pre-fill the billing entity from the linked interest's client on launch.
useEffect(() => {
if (prefilledInterest?.data && !watchedValues.billingEntity) {
setValue(
'billingEntity',
{ type: 'client', id: prefilledInterest.data.clientId },
{ shouldValidate: true },
);
}
// We only want this to run when the interest data first arrives.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [prefilledInterest?.data?.clientId]);
const lineItems = watchedValues.lineItems ?? []; const lineItems = watchedValues.lineItems ?? [];
const subtotal = lineItems.reduce( const subtotal = lineItems.reduce(
(sum, li) => sum + (Number(li.quantity) || 0) * (Number(li.unitPrice) || 0), (sum, li) => sum + (Number(li.quantity) || 0) * (Number(li.unitPrice) || 0),
@@ -117,8 +184,8 @@ export default function NewInvoicePage() {
return ( return (
<div className="max-w-2xl mx-auto space-y-6"> <div className="max-w-2xl mx-auto space-y-6">
{/* Header */} {/* Header - desktop only; mobile gets the title from the topbar */}
<div className="flex items-center gap-3"> <div className="hidden sm:flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}> <Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
@@ -157,6 +224,23 @@ export default function NewInvoicePage() {
<CardTitle className="text-base">Client Information</CardTitle> <CardTitle className="text-base">Client Information</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{isDepositInvoice ? (
<div className="flex items-start gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
<Wallet className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">Deposit invoice</p>
<p className="text-xs text-amber-800">
{prefilledInterest?.data
? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${
prefilledInterest.data.berthMooringNumber
? ` - Berth ${prefilledInterest.data.berthMooringNumber}`
: ''
}. Marking this invoice as paid will advance the interest to "Deposit 10%".`
: 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'}
</p>
</div>
</div>
) : null}
<div className="space-y-2"> <div className="space-y-2">
<Label> <Label>
Billing entity <span className="text-destructive">*</span> Billing entity <span className="text-destructive">*</span>
@@ -294,9 +378,13 @@ export default function NewInvoicePage() {
<p className="font-medium mt-0.5"> <p className="font-medium mt-0.5">
{watchedValues.billingEntity ? ( {watchedValues.billingEntity ? (
<> <>
<span className="capitalize">{watchedValues.billingEntity.type}</span>{' '} {billingEntityName?.name ? (
<span className="text-xs opacity-60"> <span>{billingEntityName.name}</span>
{watchedValues.billingEntity.id.slice(0, 12)} ) : (
<span className="text-muted-foreground">Loading</span>
)}{' '}
<span className="text-xs text-muted-foreground capitalize">
({watchedValues.billingEntity.type})
</span> </span>
</> </>
) : ( ) : (

View File

@@ -12,6 +12,7 @@ import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state'; import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton'; import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { InvoiceCard } from '@/components/invoices/invoice-card';
import { invoiceFilterDefinitions } from '@/components/invoices/invoice-filters'; import { invoiceFilterDefinitions } from '@/components/invoices/invoice-filters';
import { getInvoiceColumns, type InvoiceRow } from '@/components/invoices/invoice-columns'; import { getInvoiceColumns, type InvoiceRow } from '@/components/invoices/invoice-columns';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
@@ -63,8 +64,7 @@ export default function InvoicesPage() {
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => mutationFn: (id: string) => apiFetch(`/api/v1/invoices/${id}`, { method: 'DELETE' }),
apiFetch(`/api/v1/invoices/${id}`, { method: 'DELETE' }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices'] }); queryClient.invalidateQueries({ queryKey: ['invoices'] });
setDeleteTarget(null); setDeleteTarget(null);
@@ -72,8 +72,7 @@ export default function InvoicesPage() {
}); });
const sendMutation = useMutation({ const sendMutation = useMutation({
mutationFn: (id: string) => mutationFn: (id: string) => apiFetch(`/api/v1/invoices/${id}/send`, { method: 'POST' }),
apiFetch(`/api/v1/invoices/${id}/send`, { method: 'POST' }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices'] }); queryClient.invalidateQueries({ queryKey: ['invoices'] });
}, },
@@ -82,8 +81,7 @@ export default function InvoicesPage() {
const columns = getInvoiceColumns({ const columns = getInvoiceColumns({
portSlug, portSlug,
onSend: (invoice) => sendMutation.mutate(invoice.id), onSend: (invoice) => sendMutation.mutate(invoice.id),
onRecordPayment: (invoice) => onRecordPayment: (invoice) => router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`),
router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`),
onDelete: (invoice) => setDeleteTarget(invoice), onDelete: (invoice) => setDeleteTarget(invoice),
}); });
@@ -141,6 +139,17 @@ export default function InvoicesPage() {
onSortChange={setSort} onSortChange={setSort}
isLoading={isFetching && !isLoading} isLoading={isFetching && !isLoading}
getRowId={(row) => row.id} getRowId={(row) => row.id}
cardRender={(row) => (
<InvoiceCard
invoice={row.original}
portSlug={portSlug}
onSend={(invoice) => sendMutation.mutate(invoice.id)}
onRecordPayment={(invoice) =>
router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`)
}
onDelete={setDeleteTarget}
/>
)}
emptyState={ emptyState={
<EmptyState <EmptyState
title="No invoices found" title="No invoices found"
@@ -161,15 +170,11 @@ export default function InvoicesPage() {
<h3 className="font-semibold">Delete Invoice?</h3> <h3 className="font-semibold">Delete Invoice?</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
This will permanently delete invoice{' '} This will permanently delete invoice{' '}
<span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>. <span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>. This
This action cannot be undone. action cannot be undone.
</p> </p>
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
<Button <Button variant="outline" size="sm" onClick={() => setDeleteTarget(null)}>
variant="outline"
size="sm"
onClick={() => setDeleteTarget(null)}
>
Cancel Cancel
</Button> </Button>
<Button <Button

View File

@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { UploadReceiptsGuide } from '@/components/invoices/upload-receipts-guide';
export const metadata: Metadata = {
title: 'How to upload receipts',
};
export default async function UploadReceiptsPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
return <UploadReceiptsGuide portSlug={portSlug} />;
}

View File

@@ -1,4 +1,5 @@
import { ResidentialClientDetail } from '@/components/residential/residential-client-detail'; import { ResidentialClientDetail } from '@/components/residential/residential-client-detail';
import { TrackEntityView } from '@/components/search/track-entity-view';
interface Props { interface Props {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -6,5 +7,10 @@ interface Props {
export default async function ResidentialClientDetailPage({ params }: Props) { export default async function ResidentialClientDetailPage({ params }: Props) {
const { id } = await params; const { id } = await params;
return <ResidentialClientDetail clientId={id} />; return (
<>
<TrackEntityView type="residential-client" id={id} />
<ResidentialClientDetail clientId={id} />
</>
);
} }

View File

@@ -1,4 +1,5 @@
import { ResidentialInterestDetail } from '@/components/residential/residential-interest-detail'; import { ResidentialInterestDetail } from '@/components/residential/residential-interest-detail';
import { TrackEntityView } from '@/components/search/track-entity-view';
interface Props { interface Props {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -6,5 +7,10 @@ interface Props {
export default async function ResidentialInterestDetailPage({ params }: Props) { export default async function ResidentialInterestDetailPage({ params }: Props) {
const { id } = await params; const { id } = await params;
return <ResidentialInterestDetail interestId={id} />; return (
<>
<TrackEntityView type="residential-interest" id={id} />
<ResidentialInterestDetail interestId={id} />
</>
);
} }

View File

@@ -0,0 +1,5 @@
import { UserProfile } from '@/components/settings/user-profile';
export default function ProfilePage() {
return <UserProfile />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from 'next';
import { WebsiteAnalyticsShell } from '@/components/website-analytics/website-analytics-shell';
export const metadata: Metadata = {
title: 'Website analytics',
};
export default function WebsiteAnalyticsPage() {
return <WebsiteAnalyticsShell />;
}

Some files were not shown because too many files have changed in this diff Show More