Commit Graph

251 Commits

Author SHA1 Message Date
4d6a293534 fix(berths): natural-sort SQL ordering for mooring numbers
Drizzle SQL template was running `\d+$` through JS string-literal
escape rules, eating the backslash and matching every character class
\d alias instead of digits. Berths sorted lexically (A1, A10, A11,
A2, …) instead of by trailing number. Switch to `[0-9]+$` POSIX form
which survives the escape pass intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:10:04 +02:00
e01a87ff2e fix(auth): forward in checks through better-auth Proxy
better-auth's `toNextJsHandler` does `"handler" in auth` and falls back
to calling `auth(req)` if false. The default `has` trap looks at the
empty target and returns false, so without the override we hit the
fallback and crash because the target isn't callable. Add a `has` trap
that delegates to the real instance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:09:17 +02:00
2b1024ff7a fix(types): unblock catch-all routes under stricter Next 15.5 typing + Phase 2B deps
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m26s
Build & Push Docker Images / build-and-push (push) Has been cancelled
Two changes bundled (build was failing on the type fix; deps came along
on the same branch).

1. RouteHandler / withAuth / withPermission are now generic over the
   route's params shape. Default stays `Record<string, string>` for the
   common `[id]`-style routes (no caller changes needed). Catch-all
   routes like `[...path]` declare their narrow shape via a type-arg:

       export const PATCH = withAuth<{ path: string[] }>(
         withPermission<{ path: string[] }>('files', 'manage_folders',
           async (req, ctx, params) => { /* params.path: string[] */ }
         ),
       );

   Without this, Next.js 15.5+'s stricter route-type checking rejected
   the build because the inferred `params: Promise<{ path: string[] }>`
   for `[...path]` doesn't satisfy `Promise<Record<string, string>>`.

   Updated `src/app/api/v1/files/folders/[...path]/route.ts` (the only
   catch-all in the tree right now) to use the new generic.

2. Phase 2B deps (within-major-jump where the API didn't actually break):
   - @pdfme/common, @pdfme/generator, @pdfme/schemas: 5.5.10 → 6.1.2
     (closes 3 mod XSS/SSRF/decompression-bomb advisories)
   - lucide-react: 0.460.0 → 1.14.0
   - sonner: 1.7.4 → 2.0.7
   - tailwind-merge: 2.6.1 → 3.5.0

Tests: 1185/1185 vitest. tsc clean. Local `next build` succeeds.

Reverted (deferred to a focused PR):
- @hookform/resolvers 5: Resolver<T> typing change requires per-form
  useForm migration
- eslint 10: incompatible with @rushstack/eslint-patch (pulled in by
  eslint-config-next)
- react-day-picker 10: ClassNames removed `table`; needs calendar.tsx
  migration
- zod 4: 94 type errors cascading through drizzle insert types; needs
  comprehensive migration

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:07:07 +02:00
2f9bcf00b1 fix(build): make auth + storage modules side-effect-free at import
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m27s
Build & Push Docker Images / build-and-push (push) Failing after 14m25s
Two top-level eager initializers were breaking pnpm build during Next.js
"collect page data" phase under SKIP_ENV_VALIDATION=1:

- src/lib/auth/index.ts created the better-auth singleton at module load,
  triggering its "default secret" check against the unset BETTER_AUTH_SECRET.
- src/lib/minio/index.ts constructed `new Client({...})` at module load with
  env.MINIO_ENDPOINT === undefined, throwing InvalidEndpointError.

Storage config now lives in system_settings (read at runtime by
getStorageBackend()), so the legacy @/lib/minio module's MinIO-client
exports were already unused — only buildStoragePath had real consumers.
Stripped the module to that single pure helper; deleted the dead
minioClient / ensureBucket / getPresignedUrl exports.

For better-auth, kept the existing call-site syntax (`auth.api.foo(...)`
and `typeof auth.$Infer.Session`) by wrapping the singleton in a Proxy
that lazy-instantiates on first property access. Build-time import never
touches env; first runtime request constructs as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:38:04 +02:00
8dc16dcd2e fix(audit): non-Documenso backlog sweep — port-binding, NULLS NOT DISTINCT, custom merge tokens, company docs
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m36s
Build & Push Docker Images / build-and-push (push) Failing after 4m27s
Wave through the remaining audit-final-deferred items that aren't blocked
on the back-burnered Documenso work.

Multi-tenant isolation:
- Storage proxy ProxyTokenPayload gains optional `p` (port slug) claim;
  verifier asserts `key.startsWith(${p}/)`. Defense-in-depth against a
  buggy issuer in some future code path that mixes port scopes — every
  storage key generated by generateStorageKey() already prefixes the
  slug. document-sends opts in for 24h emailed download links; other
  callers continue working unchanged via the optional field.

DB schema reconciliation:
- Migration 0047 rebuilds system_settings unique index with NULLS NOT
  DISTINCT (Postgres 15+) so global settings (port_id IS NULL) are
  uniquely keyed by `key` alone. Surfaced + dedupe'd 65 duplicate
  (storage_backend, NULL) rows that had accumulated from race-prone
  delete-then-insert patterns in ocr-config / settings / residential-
  stages / ai-budget services. All four services converted to true
  onConflictDoUpdate upserts so the race window is closed.

API uniformity:
- Response shape standardization: 16 routes converted from
  `{ success: true }` to 204 No Content. CLAUDE.md documents the
  convention (`{ data: <T> }` for content, 204 for empty mutations,
  portal-auth retains `{ success: true }` for the frontend's auth chain).
- req.json() → parseBody() migration across 9 admin/CRM routes
  (custom-fields, expenses/export ×3, currency convert,
  search/recently-viewed, admin/duplicates, berths/pdf-{upload-url,
  versions, parse-results}). Uniform 400 error shapes for
  ZodError-flagged bodies.

Custom-fields merge tokens (shipped end-to-end):
- merge-fields.ts gains CUSTOM_MERGE_TOKEN_RE + helpers for the
  `{{custom.<fieldName>}}` shape.
- document-templates validator accepts the dynamic shape alongside
  the static catalog tokens.
- document-sends.service mergeCustomFieldValues resolver fetches
  per-port custom_field_definitions for client/interest/berth contexts
  and substitutes stored values keyed by `{{custom.fieldName}}`.
- custom-fields-manager amber banner updated to reflect that merge
  tokens now expand (search index + entity-diff remain documented
  design limitations).

/api/v1/files cross-entity filtering:
- Validator + listFiles + uploadFile accept companyId AND yachtId
  alongside clientId. file-upload-zone propagates both.
- New CompanyFilesTab component mirrors ClientFilesTab; restored as a
  visible Documents tab in company-tabs.tsx (was a hidden stub).

Inline TODOs:
- Reviewed remaining two TODOs (per-user reminder schedule, import
  worker handlers). Both are placeholders for future feature surfaces,
  not bugs — per-port digest works for every customer; nothing
  currently enqueues import jobs (verified). Annotated in BACKLOG.

BACKLOG.md updated to reflect what landed and what's still pending
(Documenso-related items still bundled with the back-burnered phases).

Tests: 1185/1185 vitest, tsc clean.
2026-05-08 02:20:27 +02:00
60365dc3de fix(audit): backlog sweep — partial archived indexes, custom-fields per-entity gate, polish
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m37s
Build & Push Docker Images / build-and-push (push) Failing after 24s
Wave through the 2026-05-07 backlog of small/concrete audit-final-deferred
items (deferring the Documenso Phases 2-7 build and items needing design
decisions or live external instances).

DB schema:
- Migration 0046 converts 5 composite (port_id, archived_at) indexes to
  partial WHERE archived_at IS NULL — clients, interests, yachts, and
  both residential tables. Smaller, faster planner choice for the
  dominant list-query shape.

Multi-tenant isolation:
- document_sends now verifies recipient.interestId belongs to the port
  before landing on the audit row (the surrounding clientId check was
  already port-scoped; interestId pollution was the gap).

Routes / API:
- /api/v1/custom-fields/[entityId] requires entityType query param and
  gates on the matching resource permission (clients/interests/berths/
  yachts/companies). Fixes the cross-resource gap where a user with
  clients.view could read company custom-field values.
- Admin user list trash button wrapped in PermissionGate (edit was
  already gated; remove was not).

Service polish:
- berth-recommender accepts string-shaped JSONB booleans
  ('true'/'false') so admin UIs that wrap values as strings don't
  silently fall through to defaults.
- expense-pdf renderReceiptHeader anchors all text positions to a
  captured baseY rather than reading mutating doc.y after rect+stroke.
  Headers no longer drift on the first receipt page after a soft page
  break.
- berth-pdf apply: collect non-finite numeric coercion drops + warn-log
  them so partial silent drops are observable (was invisible because
  the no-fields-supplied check only fires when ALL drop).
- Storage cache fingerprint comment documenting the encrypted-secret
  invariant + the explicit invalidation hook.

UI polish:
- invoice-detail typed: replaced two `any` casts with a proper
  InvoiceDetailData / LineItem / LinkedExpense interface set.
- YachtForm now accepts initialOwner prop. Wired through:
  - client-yachts-tab passes { type: 'client', id: clientId }
  - interest-form passes { type: 'client', id: selectedClientId }
- Interest-form yacht picker now includes company-owned yachts where
  the selected client is a member (fetches client.companies and feeds
  YachtPicker an array filter). Plus an inline "Add new" button that
  opens YachtForm pre-bound to the client.
- YachtPicker accepts ownerFilter as single OR array for "match any"
  semantics.

BACKLOG.md updated with what landed vs what's still deferred (and why
each deferred item is genuinely larger than this push warrants).

Tests: 1185/1185 vitest, tsc clean.
2026-05-07 21:45:42 +02:00
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
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
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
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
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
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
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
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
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
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