Commit Graph

209 Commits

Author SHA1 Message Date
fe863a588e feat(backup): full DR bundle export + admin-configurable offsite destinations
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m52s
Build & Push Docker Images / build-and-push (push) Successful in 11m59s
Backend-agnostic disaster-recovery backup engine that runs on the current
storage backend (no storage cutover required):

- Full-bundle export: db.dump (pg_dump custom) + every storage blob +
  manifest.json with per-object SHA-256, streamed as a tar. Entry points:
  admin UI download, GET /api/v1/admin/backup/export, scripts/create-full-backup.ts.
- Admin-configurable push destinations (backup_destinations table, migration
  0091): SFTP/SSH, S3-compatible (reuses the minio client), and mounted
  path/NAS behind one transport interface (test/push/prune). Secrets AES-GCM
  at rest; API returns only *IsSet markers.
- Opt-in per-destination AES-256 bundle encryption (scrypt KDF, streamed) +
  scripts/decrypt-backup.ts for restore.
- Wired the previously-dead database-backup cron to runScheduledBackupPush
  (push to enabled destinations, prune to retention, alert super-admins on
  failure).

Tests: 1608 unit/integration pass; tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:23:42 +02:00
eff57af571 fix(storage): make S3 server-side-encryption optional (default off)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m46s
Build & Push Docker Images / build-and-push (push) Successful in 7m53s
Prod MinIO has no KMS/KES, so the unconditional
`x-amz-server-side-encryption: AES256` header on every PutObject was
rejected with `NotImplemented` ("KMS not configured") — breaking ALL
server-side uploads on prod: avatars, the signed-PDF deposit on
Documenso completion, GDPR exports, the nightly DB backup, generated
EOI/contract PDFs, report renders. Reads/presigned downloads were
unaffected, so the cutover walkthrough missed it.

The SSE header is now sent only when explicitly configured via the
per-port `storage_s3_sse` setting (or the STORAGE_S3_SSE env fallback);
the default is off so a vanilla S3-compatible backend accepts uploads.
This also resolves the put()-encrypts-but-presignUpload-doesn't
asymmetry — presigned PUTs never sent SSE, so both paths now match by
default.

Extracted buildPutObjectMetadata() as a pure, unit-tested helper.

Interim fix; the planned filesystem-storage migration removes SSE from
the prod path entirely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:08:41 +02:00
15a139e86f feat(berths): website auto-promote toggle + manual-override soft-pin priority
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m47s
Build & Push Docker Images / build-and-push (push) Successful in 6m49s
- website_berth_autopromote_enabled (default OFF): a website registration for a
  specific, currently-available berth auto-creates a prospect (client + optional
  yacht + interest) and links the berth is_specific_interest=true, flipping the
  public map to Under Offer; general/residence/contact submissions stay
  capture-only. Marks the submission converted so a rep never double-creates it.
- derivePublicStatus now honours a manual pin (soft pin): a manually-set status
  wins over the interest-derived Under Offer, but a real permanent tenancy or an
  explicit sold still override it.
- berth rules engine respects a manual pin EXCEPT for sale triggers (-> sold),
  so a confirmed sale still wins but soft auto-changes never stomp a pin.
- Reset-to-automatic action (service + API POST /berths/[id]/status/reset + UI)
  to drop a manual pin; lock badge on every manual override (list + detail);
  divergence banner prompting reset when a pinned-Available berth has a deal.
- migration stage map updated to the §4b signed-off mapping: GQI -> enquiry
  unless it named a berth/size marker (-> qualified); SQI -> qualified.

Tests: +public-berths soft-pin cases, +website-intake-promote helpers,
+migration GQI marker rule. 1582 unit/integration green; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:10:04 +02:00
5ea0c75fff feat(intake): structured notification-recipient resolver (emails/users/roles/everyone)
parseRecipientConfig (backward-compat: legacy string[] -> emails) + resolveRecipientEmails (expands userIds/roleIds/everyone-with-interests.view into deduped addresses) + resolveNotificationRecipients (load setting, fallback to inquiry_contact_email). Wired into the website-intake email path so berth/contact/residential staff alerts honor the richer recipients. TDD: parseRecipientConfig unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:28:48 +02:00
990b566eff feat(intake): CRM-owned website inquiry emails + in-app notifications
Flag-gated (website_intake_email_enabled, default OFF) sending of registrant confirmation + staff alert for inquiries captured at /api/public/website-inquiries, reusing the branded berth + residential templates and adding contact-form client-confirmation + sales-alert templates. In-app (bell) notifications fire on every fresh capture, independent of the flag. Recipients resolve from the existing inquiry_/residential_notification_recipients settings; fires only on a fresh (non-deduped) insert so retries never re-send.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:22:08 +02:00
e7fdf75a6c fix(audit): residential/tenancies — M28 (unified stage validation), M29 (explicit-disable wins), L31 (active-tenancy warning), L32 (socket event + saveStages tx)
Updated tenancy-auto-create integration test to assert M29 (explicit disable
respected) instead of the old re-enable behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:18:28 +02:00
3d9084c94b feat(reports): parseOperationalFilters pure parser (Area scope)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:08:16 +02:00
b690fb8d56 feat(reports): Financial report (Initiative 1 Phase 4)
Builds the Financial report on the canonical payments + expenses tables
(the CRM records money received; it does not invoice — invoices module
is off, dev DB has zero invoice rows). The invoice-centric spec is
reframed onto the payments model: "outstanding AR" → expected-deposit
shortfall on active deals; "AR aging" → outstanding deposits bucketed by
deal age.

Service (financial.service.ts):
- 7 KPIs: revenue collected (net of refunds), deposits, balance,
  pipeline expected, outstanding deposits, expenses, net contribution
- 6 chart datasets: revenue by month (deposit/balance), collection
  funnel (EOI→deposit→contract→won), expected-deposit aging, cash flow
  (inflow vs outflow), expense breakdown by category
- 4 tables: outstanding deposits, recent payments, refund log, expense
  ledger
- every money figure normalised to port currency via a shared
  resolvePortCurrency/normalizeAmount helper (new reports/currency.ts)

UI (financial-report-client.tsx): KPI strip + recharts (stacked bar /
horizontal bar / line / donut) + month/quarter/year toggle + branded
empty states; DateRangePicker + Templates + Export wired. Un-hidden the
Financial card on the reports landing.

Plumbing: added '1y' (trailing 12mo) preset to the shared range system
(financial trends want a year); added 'financial'/'marketing' to the
report-template kind enum for template parity.

TDD: 6 financial-math unit tests (aging buckets, month keys/range, net
contribution). tsc clean; full unit suite green except pre-existing
Redis/storage-dependent integration tests. Browser-verified against live
data: API 200, KPIs correct ($5,849 expenses / -$5,849 net, $0 revenue
correct given 0 payment rows), expense ledger + breakdown populate,
payment-derived sections show graceful empty states.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:43:36 +02:00
b97f6e945c feat(reports): rep + source multi-select filters on Sales report
Closes the two cross-cutting filter gaps in launch-readiness (rep
multi-select + source multi-select). The Sales detail tables can now be
narrowed by assigned rep and lead source alongside the existing stage /
lead-category / outcome filters.

- service: thread `assignedTo` + `sources` through the 5 filtered Sales
  queries (rep-performance, stalled, closing-this-month, recent-wins,
  lost-reason); add `getRepFilterOptions` for the rep dropdown's stable
  option list (distinct assigned reps port-wide, window-independent).
- route: extract param parsing into a pure, unit-tested
  `parseSalesFilters` helper (source allowlisted against SOURCES;
  assignedTo passed through as free user-id list); return `repOptions`
  in the payload.
- ui: static Source filter (SOURCES) + dynamic "Assigned to" filter
  (from payload repOptions, hidden until loaded); decouple the query
  builder from dynamic options via a stable FILTER_KEYS list.

TDD: 8 new parseSalesFilters unit tests (allowlist drop, free-list
passthrough, combine). tsc clean; 12/12 reports unit tests; browser-
verified both filters fire `source=`/`assignedTo=` → 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:24:27 +02:00
c7325010e6 feat(import): commit runner + undo + wired BullMQ worker
Third importer increment — the write path, fully testable without UI.

- commit.ts: commitBatch streams classified rows, applies insert/update per
  the conflict policy via the adapter (each row in its own try/catch so valid
  rows still land), records every action in import_batch_rows, and keeps live
  counts on the batch header. undoBatch hard-deletes a batch's inserted rows
  (port-scoped); a delete blocked by a dependent FK is reported, not forced,
  and the batch flips to `undone` only when every inserted row was removed.
- import worker: replaced the no-op placeholder with the real processor —
  loads the batch, re-reads the uploaded file from storage, parses, and runs
  commitBatch under the batch's mapping + policy. Marks the batch failed on
  error. Concurrency 1 so imports don't race each other's dedup lookups.

Tests: commit (skip/insert/error counts + per-row ledger + real inserted
entity), undo (removes exactly the inserted row, flips status), and
update-matches overwrite. 2 passing.

Engine is now functional end-to-end at the service layer: parse → map →
dry-run → commit → undo. Remaining: 4 FK adapters, API routes + permission,
wizard UI + history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:36:42 +02:00
3cf12b3015 feat(import): engine core + companies/clients/berths adapters
Second importer increment: the generic engine + the three no-FK adapters,
fully unit + integration tested.

- types: ImportAdapter contract (targetFields, matchKey, findExisting,
  resolveForeignKeys, insert/update) + engine types.
- mapping: fuzzy header → target-field auto-suggest (exact / alias / edit
  distance, one header per field) + applyMapping (drops empty cells).
- classify: per-field zod + cross-field extraValidate, FK resolution hook,
  natural-key dedup, and the conflict-policy matrix
  (skip-matches / update-matches / error-on-match) → row outcomes + summary.
- engine: CSV (papaparse) + XLSX (ExcelJS) parse into a uniform
  {headers, rows} of trimmed strings.
- adapters (delegating to existing create/update services for audit +
  validation): companies (name dedup, update), clients (flat email/phone →
  contacts[], email-or-phone dedup, insert-only), berths (canonical mooring
  dedup, numeric coercion, update).
- registry: implemented adapters in dependency order.

Tests: 11 unit (mapping/validation/matchKey/parse) + 3 integration
(dedup + all three conflict policies on a seeded DB). 14 passing.

Next increments: FK adapters (yachts/interests/tenancies/expenses),
commit runner + worker, API routes + permission, wizard UI + undo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:32:19 +02:00
8be7a6e29d feat(berths): ship Waiting List + Maintenance Log tabs
Both berth-detail surfaces were stubbed/hidden behind a comment in
berth-tabs.tsx. Their backing schema already existed; this wires the UI
and fills the service gaps.

Maintenance Log (was ~60% built: schema/migration/add+get service/route):
- new edit + delete: updateMaintenanceLog / deleteMaintenanceLog service
  (port-scoped tenant guard), PATCH/DELETE at maintenance/[logId], plus
  updateMaintenanceLogSchema. add schema now accepts null for cost /
  responsibleParty so the shared add+edit dialog sends one body shape.
- BerthMaintenanceTab: list (newest first) + add/edit dialog + delete
  confirm, realtime invalidation. New berth:maintenanceUpdated/Removed
  socket events.

Waiting List (un-hide the orphaned manager + next-in-line notify):
- getWaitingList now left-joins the client so the queue renders names,
  not raw ids.
- WaitingListManager rewritten: ClientPicker instead of free-text id,
  client names, manage_waiting_list gating on add/reorder/remove, and a
  "Next in line" marker on position 1.
- notifyWaitlistNextInLine: when a berth transitions to available,
  surface the #1 client to staff who hold berths.manage_waiting_list
  (mirrors the interest-based notifyNextInLine; dedupeKey-suppressed).
  Hooked into updateBerthStatus on any -> available transition.

Tests: maintenance add/get/update/delete + cross-port guard; waitlist
notify recipient-resolution / payload / empty + no-permission no-ops.
Verified end-to-end in the browser (create/render/delete for both).

Also adds scripts/dev-reset-admin-pw.ts (reset a synthetic user's
password via the better-auth hasher after a dev reseed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:55:04 +02:00
d98aa5cc8a fix(signing): route paper-signed reservation/contract uploads to the right doc type
The Reservation and Contract tabs reused ExternalEoiUploadDialog, but the
service hard-coded the EOI document type, status columns, stage target, and
berth rule. A signed contract uploaded from the Contract tab filed as an
`eoi`, flipped `eoi_status`, and advanced the stage to `eoi` - wrong doc
kind, wrong sub-state, wrong stage.

- external-eoi.service: UPLOAD_CONFIG keyed off docType (eoi | reservation
  | contract) parameterises documentType, file category, storage prefix,
  doc-status column, signed-date column, target stage, advance-from set,
  and berth rule. eoi_status is written only for docType=eoi.
- route: parse docType from the form (default eoi).
- dialog: docType prop; generalised copy; EOI-only UI (active-EOI replace
  banner, public-map flip, cancelActiveDocumentId) gated to docType=eoi.
- reservation/contract tabs: pass docType; drop the coming-soon comments.
- test: docType routing cases (reservation -> reservation_agreement +
  reservation cols; contract -> contract + contract cols; eoi_status stays
  null on both; contract idempotent at/past contract stage).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:28:04 +02:00
7dba1a47bb fix(migration): modernize stale NocoDB→CRM pipeline stage map to current 7 stages
The 2026-05-03 migration pipeline (src/lib/dedup/*) predates the 9→7
pipeline-stage refactor; its STAGE_MAP emitted invalid stages
(open/details_sent/eoi_sent/…) that would write bad pipeline_stage values
on --apply. Remap to the current PIPELINE_STAGES (enquiry/qualified/
nurturing/eoi/reservation/deposit_paid/contract) + a deposit-received →
deposit_paid override. Frozen-fixture test expectations updated (17/17 pass).

Validated: live --dry-run = 239 clients / 255 interests / 41 EOI docs
(matches independent snapshot analysis; pipeline is more conservative and
flags 3 borderline pairs for review).

Adds the migration design spec (source map, scope lock to Port Nimara +
Expenses bases, EOI coverage 48/48, in-flight Documenso state, remaining
gaps: interest eoiStatus, expenses, doc-blob backfill).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:03:32 +02:00
681b94a8ef feat(reports): prior-period comparison toggle on the Sales report
Adds a "Compare to prior period" toggle to the Sales report header.
When on, the API recomputes the KPI window for the equal-length window
immediately preceding the selected range (previousPeriodBounds) behind
`?compare=1`, and the five window-derived KPI tiles (Won, Lost, Win
rate, Avg time-to-close, New leads) render colour-correct "vs prior"
deltas. Point-in-time tiles (Active interests, Pipeline value) have no
prior-window analogue and intentionally show no delta. The prior-window
query runs in parallel with the main batch and resolves to null when the
toggle is off (zero cost). Toggle state persists in the saved-template
config.

Closes the spec's "period comparison on every report" gap for Sales;
Operational already rendered period-start deltas.

Pure helpers TDD'd: previousPeriodBounds (range.ts) +
computeSalesKpiComparison (sales-comparison.ts), 7 unit tests. tsc +
lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:49:35 +02:00
172af02f81 feat(residential-toggle): port-level module gate for Residential
Adds a `residential_module_enabled` port setting (default ON) that
hides/disables the entire Residential surface when an admin turns it
off, mirroring the Tenancies / Invoices / Expenses module-toggle
pattern. Disabling is a soft hide — residential clients/interests are
preserved and reappear on re-enable.

Surfaces gated:
- Route guard: new residential/layout.tsx renders ModuleDisabledPage
  (covers all 5 residential pages)
- Sidebar "Residential" section + mobile more-sheet tile (SSR-resolved
  residentialModuleByPort threaded layout → app-shell → sidebar)
- Global search: residential client/interest buckets early-return at
  the shared chokepoint so disabled-port records don't dead-end
- Public intake: /api/public/residential-inquiries 404s when off
- Admin Switch in settings-manager (writes via settings PUT)

Service TDD'd (residential-module.test.ts, 6 tests) plus a
disabled-port rejection test on the public endpoint. tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:49:16 +02:00
fe5f98db23 feat(automate-signing): one-click invitation kickoff + auto cascade + completion broadcast
Phase 3 of the comprehensive UAT round. Implements the Automate
Signing feature per the 2026-05-26 locked decisions.

P3.1 — documents.automation_mode schema
Migration 0088 adds the column with a CHECK constraint enforcing
the three-value enum: manual / sequential_auto / concurrent_auto.
Drizzle schema picks it up; default 'manual' preserves existing
behaviour.

P3.2 — Automate Signing orchestrator service
New src/lib/services/signing-automation.service.ts. enableSigningAutomation
resolves the mode from the envelope's signing order (SEQUENTIAL ->
sequential_auto fires first signer only; PARALLEL -> concurrent_auto
fires all signers in one parallel dispatch), updates documents.automationMode,
and dispatches invitations via the same sendSigningInvitation path
the manual route uses (so the email a recipient sees is identical
regardless of trigger). ensureSigningUrls recovers v2 signing URLs
if they're missing on the local signer rows. Hard guards: envelope
must exist, status in {draft, sent, partially_signed}, ≥2 signers.
disableSigningAutomation reverts to manual; idempotent.

P3.3 — Webhook cascade
The existing sendCascadingInviteForNextSigner in documents.service.ts
already fires the next pending signer on every recipient_signed event
(mode-independent). handleDocumentCompleted already sends the signed
PDF to all recipients via sendSigningCompleted on completion. So
"automate" really means "kick off the first invitation"; the rest
is mode-independent existing behaviour. Doc comment in the new
service explains the interaction.

P3.4 — ActiveEoiCard Automate signing button + banner
- DocumentRow type extended with automationMode + documensoId.
- New automateMutation hits POST /api/v1/documents/[id]/automate;
  pauseAutomationMutation hits DELETE.
- "Automate signing" button visible when totalCount ≥ 2 AND doc has
  documensoId AND envelope is in-flight AND mode === 'manual'.
- "Automating sequentially/concurrently · N of M signed" banner
  renders when automation is active, with a Pause button that
  reverts to manual.
- Per-row Send invitation / Send reminder buttons in SigningProgress
  stay visible per the locked decision (manual override during auto).

P3.5 — Automate Signing API route + tests
- POST /api/v1/documents/[id]/automate (enables) + DELETE (disables).
- Permission: documents.send_for_signing (mirrors the manual
  send-invitation route).
- vitest covering: NotFound on missing doc, Conflict on missing
  envelope, Conflict on completed status, Conflict on already-
  automated, Conflict on <2 signers, disable is idempotent when
  already manual. All 7 cases pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:29:05 +02:00
b6c27b506d feat(documenso-audit-phase-1): persist documensoId early + preflight + state machine + reconciliation + tests
Phase 1 of the comprehensive Documenso upload audit per the
2026-05-26 locked-decisions block in docs/superpowers/audits/active-uat.md.

P1.1 — persist documensoId immediately after create
Was set only at the late `status: 'sent'` commit. Any throw between
documensoCreate and the late update left an orphaned Documenso
envelope the CRM had no link to. Now the UPDATE runs right after
documensoCreate succeeds; rollback paths can find and void the
envelope.

P1.2 — pre-flight validation hard-blocks Submit
UploadForSigningDialog computes a submissionErrors memo over
recipients + fields. Submit button disabled when errors > 0. Inline
amber summary lists every issue (missing email, invalid email,
missing name, field assigned to non-existent recipient, no fields
placed). Service layer mirrors the same email + name checks so
direct API hits reject early. No override path per locked decision.

P1.3 — cancel/delete affordance audit + sweep
Document-list per-row Delete + Send for Signing actions now:
- Wrapped in PermissionGate (documents.delete + send_for_signing).
- Surface toast on success + toastError on failure (were silently
  swallowing errors).
- Use a broader predicate-based query invalidation so every doc
  list across the app refreshes, not just the local key.
EOI tab Regenerate + Cancel EOI buttons + reservation/contract
tab Cancel buttons wrapped in PermissionGate (documents.edit, the
cancel route's auth check).

P1.4 — Documenso webhook URL auto-PATCH (env-gated)
scripts/update-documenso-webhook.ts written. Reads
DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK env flag (when 1, runs; otherwise
no-op). Lists every webhook on the Documenso instance via v2 (with
v1 fallback), identifies webhooks pointing at trycloudflare.com
hosts OR /api/webhooks/documenso paths, PATCHes them to the new
tunnel URL. scripts/tunnel-url.sh chains the script after the URL
print so a re-tunnel auto-rotates the webhook (when flag set).

P1.5 — state-machine refactor with rollbackTo() helper
custom-document-upload.service.ts:
- Single try around create → send → place steps.
- state.step tracks which step is current; state.documensoDocId
  records the envelope id once we have it.
- rollbackTo(reason) composes the recovery: status='cancelled' on
  the CRM row, documensoVoidSafe on the envelope when applicable.
  Idempotent — calling twice is safe.
- Removes three independent try/catches.

P1.6 — recipient ↔ Documenso identity reconciliation
After documensoSend, validates every distinct email we sent
appears in sentDoc.recipients. If Documenso silently dropped one,
a ConflictError fires before field placement so the rollback path
triggers. Explicit message names the missing emails for the rep.

P1.7 — vitest extension + per-failure audit-log entries
- 5 new vitest cases (blank email, whitespace email, malformed
  email, blank name, duplicate-emails-OK semantic).
- rollbackTo writes a structured audit_log entry with failedStep,
  documensoEnvelopeId, errorClass, errorMessage. Post-mortem
  investigation has structured data instead of just logger lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:09:50 +02:00
dd25ccfb53 fix(tenancies-audit): resolve findings from 7-agent system-wide rename audit
MUST-FIX:
- src/app/api/v1/admin/users/[id]/permission-overrides/route.ts:70 — the
  PUT allowlist still gated `reservations: {view,create,activate,cancel}`.
  Stale: would reject valid `tenancies.{view,manage,cancel}` writes and
  silently accept ghost `reservations.*` writes that never land. Replaced.
- src/lib/services/alert-rules.ts:68 — `reservation.no_agreement` alert
  emitted `entityType: 'reservation'`. Every other tenancy-related
  audit/socket/dashboard label is `'berth_tenancy'`. Inconsistent dedupe
  + activity-feed label miss.
- tests/e2e/exhaustive/08-portal.spec.ts:6 — hardcoded /portal/my-reservations
  navigates to a 404 every run.
- tests/e2e/exhaustive/03-reservations.spec.ts — entire spec renamed to
  03-tenancies.spec.ts; tab + button locators updated to match renamed UI.

SHOULD-FIX (consistency):
- src/components/clients/client-detail.tsx — useRealtimeInvalidation only
  caught 3 of the 4 berth_tenancy:* events; added the `:created` listener.
- src/lib/services/client-merge.service.ts — MergeResult.movedRows.reservations
  + snapshot.reservations + local loserReservations / movedReservations
  renamed to tenancies / loserTenancies / movedTenancies. No external
  consumers grep-confirmed.
- src/lib/services/gdpr-bundle-builder.ts — GdprBundle.reservations field
  renamed to .tenancies; user-facing HTML section "Reservations" → "Tenancies";
  local reservationRows → tenancyRows.
- 6 UI copy strings: gdpr-export-button, bulk-archive-wizard,
  bulk-hard-delete-dialog, hard-delete-dialog, admin-sections-browser ×2,
  admin/import/page, won-status-panel — all "reservations" prose updated
  to "tenancies" (occupancy-record sense).
- tests/integration/api/tenancies.test.ts — handler import aliases
  `createReservationHandler` etc renamed to `createTenancyHandler` etc.
- tests/unit/services/berth-tenancies.test.ts — local helper makeReservation
  → makeTenancyLocal (avoids shadow of the renamed factory).
- scripts/audit-permissions.ts — stale allowlist entry for
  /berth-reservations/[id]/route.ts removed (path no longer exists).
- docs/runbooks/permission-audit.md — stale row for same path removed.
- docs/tenancies-design.md — fixed factual error
  ("tenancies.service.ts" → "berth-tenancies.service.ts").

Verified: tsc clean, 1493/1493 vitest.

Dev-server note: the running `next dev` process started before P2 and
shows Turbopack cached compile errors against the renamed schema files.
Source is correct (./tenancies); restart `next dev` to clear the cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:03:14 +02:00
3a48150d13 feat(tenancies-p5): sidebar entry + 404 top-level page + API module gate
- Dashboard layout resolves tenanciesModuleByPort server-side (one
  isTenanciesModuleEnabled call per port the user has access to) and
  passes the map through AppShell → Sidebar. Atomic SSR — no
  flicker of the nav entry in/out after hydration.
- Sidebar gains NavItemGated.requiresTenanciesModule. The Tenancies
  entry (KeyRound icon, immediately below Berths) only renders when
  the currently-active port has the flag flipped on. Per-port live
  switch fires when the rep toggles ports without reload.
- /[portSlug]/tenancies + /[portSlug]/tenancies/[id] both call
  isTenanciesModuleEnabled and notFound() when disabled — guards
  against direct URL access even when the sidebar is hidden.
- API routes (/api/v1/tenancies, /[id], /berths/[id]/tenancies)
  prepended with assertTenanciesModuleEnabled — matches design §
  "All routes ... return 404 when off". NotFoundError maps to 404.
- Existing tenancy API tests get a makePortWithTenancies() helper
  (calls enableTenanciesModule after makePort) so the gate is
  satisfied. Affects 2 test files (16 tests retargeted).

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:23:06 +02:00
bfb29ab619 feat(tenancies-p4): public-map status flip via active permanent tenancy
- derivePublicStatus gains optional hasActivePermanentTenancy flag;
  precedence updated to "sold > under_offer > available" where
  Sold can come from EITHER berths.status='sold' (admin set) OR an
  active permanent-class tenancy (only when module enabled).
- Permanent-class tenure types defined in one place
  (isPermanentTenureType): permanent | fee_simple | strata_lot.
  Seasonal / fixed_term tenancies do NOT flip — they fall through to
  the existing under_offer / available precedence.
- /api/public/berths (list) + /api/public/berths/[mooringNumber]
  (single) both gate the lookup on isTenanciesModuleEnabled(portId).
  Disabled module = lookup skipped entirely, preserving pre-module
  behaviour for ports that haven't opted in.
- 8 new unit tests covering: flip from available, flip from under_offer,
  explicit sold idempotency, false-flag fallthrough, default-omit pre-
  module behaviour, permanent-class membership for each tenure type,
  and null/undefined/unknown rejection.

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:17:06 +02:00
20549fb22e feat(tenancies-p3): webhook auto-create on signed Reservation Agreement + first-insert flip
- berth-tenancies.service.ts: autoCreatePendingTenancies(portId, interestId, opts)
  loops over interest_berths WHERE is_in_eoi_bundle=true and mints ONE
  pending tenancy per in-bundle berth. Wrapped in pg_advisory_xact_lock
  per port + idempotent skip when a (pending|active) tenancy already
  exists for the berth (webhook retry-safe). Each insert audit-logged
  + emits berth_tenancy:created socket event.
- createPending: same advisory-lock + tx pattern, additionally calls
  enableTenanciesModule(portId) so the FIRST manual tenancy in a port
  lazily flips tenancies_module_enabled=true (idempotent UPSERT, no-op
  on subsequent inserts).
- handleDocumentCompleted: branch on reservation_agreement completion
  gates on isTenanciesModuleEnabled, then calls autoCreatePendingTenancies
  with the just-committed signedFileId. Per design §"When disabled":
  stage advance + reservationDocStatus flip still fire when the module
  is off; only the tenancy mint is skipped.
- 5-case integration test covering bundle expansion, idempotent retry,
  empty-bundle no-op, missing-interest no-op, and the first-insert
  module-enable side effect.

Verified: tsc clean, 1485/1485 vitest (5 new cases).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:14:37 +02:00
ccc775dc66 feat(tenancies-p2): rename berth_reservations → berth_tenancies (schema + perms + UI)
73-file atomic rename per docs/tenancies-design.md:

- Migration 0085: rename table + indexes + FK constraints; rename
  documents.reservation_id → tenancy_id; migrate jsonb permission maps
  (reservations resource → tenancies; collapse create+activate → manage);
  rewrite historical audit_logs.entity_type='berth_reservation' →
  'berth_tenancy'. FK renames wrapped in DO blocks so dev DBs that pre-date
  the FK additions don't abort.
- Schema: berthReservations → berthTenancies; BerthReservation type →
  BerthTenancy; indexes idx_br_* / idx_brr_* → idx_bt_*.
- RolePermissions: resource { view, create, activate, cancel } collapses to
  { view, manage, cancel }; all 8 default seed bundles + role-form + matrix
  updated.
- Service: berth-reservations.service.ts → berth-tenancies.service.ts;
  endReservation → endTenancy; listReservations → listTenancies.
- API: /api/v1/berth-reservations → /api/v1/tenancies (+ nested [id]);
  /api/v1/berths/[id]/reservations → /api/v1/berths/[id]/tenancies.
- Validators: reservations.ts → tenancies.ts; RESERVATION_STATUSES →
  TENANCY_STATUSES; endReservationSchema → endTenancySchema.
- Routes: /{portSlug}/berth-reservations → /{portSlug}/tenancies;
  /portal/my-reservations → /portal/my-tenancies.
- Components: src/components/reservations/* → src/components/tenancies/*;
  BerthReservationsTab → BerthTenanciesTab; ClientReservationsTab →
  ClientTenanciesTab; ReservationList → TenancyList.
- Socket events: berth_reservation:* → berth_tenancy:*; payload
  reservationId → tenancyId.
- Webhook events: berth_reservation.* → berth_tenancy.*.
- Portal: getPortalUserReservations → getPortalUserTenancies;
  PortalReservation → PortalTenancy; PortalDashboard.counts.activeReservations
  → activeTenancies; PortalNav label "Reservations" → "Tenancies".
- Dossier: DossierReservation → DossierTenancy; reservationDecisions →
  tenancyDecisions across smart-archive-dialog + bulk-archive routes.
- Documents schema: documents.reservationId → documents.tenancyId
  (TS + DB column + index + FK constraint).
- Activity feed label berth_reservation → berth_tenancy (matched against
  migrated historical audit rows).

KEPT (separate concepts):
- Reservation Agreement document type (the contract sent to clients).
- "Reservation" pipeline stage name.
- {{reservation.*}} merge tokens in template authoring.
- interest.reservationStatus / reservationDocStatus / dateReservationSent
  fields (track agreement signing on the deal).
- reservation-agreement-context.ts service (builds merge context for the
  Reservation Agreement doc; only its DB imports were renamed).

Verified: tsc clean, 1480/1480 vitest passing, migration applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:09:35 +02:00
1e31ed66f1 feat(reports-p2): CRUD layer for report_runs + report_schedules
Builds the API + service layer the P1 schema migration 0084 set up:

- src/lib/validators/reports.ts: new schemas for list/create on runs +
  full CRUD on schedules. Locked enums for kind / output / cadence /
  status so the route layer can reject invalid combinations early.
- src/lib/services/report-runs.service.ts: list with kind/status/template
  filters, create with cross-port template guard + config.kind
  discriminator check, updateReportRunStatus for the future P3 worker to
  flip status through pending/rendering/complete/failed.
- src/lib/services/report-schedules.service.ts: full CRUD plus
  nextRunFor() deterministic cadence math. nextRunAt is recomputed on
  cadence change or on re-enable (off->on) but left untouched on no-op
  edits so a mid-cycle recipient swap doesn't slip the fire-time.
- /api/v1/reports/runs (GET + POST) + /api/v1/reports/runs/[id] (GET)
- /api/v1/reports/schedules (GET + POST) +
  /api/v1/reports/schedules/[id] (GET + PATCH + DELETE)
- tests/integration/report-runs-schedules.test.ts: 9 cases covering the
  cross-port FK guard, the config.kind cross-check, listing filters,
  cadence math for all three v1 cadences, the no-op-doesn't-slip rule,
  and the ON DELETE SET NULL contract on schedule deletion.

Permission gating: list/get on reports.view_dashboard (read), all mutations
on reports.export (write). Matches the existing /reports/templates routes.

P3 (the BullMQ render+email queue) is the next slice; it'll consume the
pending rows produced here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:26:18 +02:00
65b92cace1 fix(b4-bugs): external-EOI cache collision + stage-gate regression test + search popover opacity
Three B4 bug fixes shipped together:

- **#4 Upload-signed-copy blank body** — ExternalEoiUploadDialog used
  queryKey=['interests', interestId] but didn't unwrap the {data} envelope
  while the parent InterestDetail (same key) does, so opening the dialog
  clobbered the cache with a wrapped shape and blanked the detail page
  ("Unknown Client" + empty tab body). Dialog now unwraps to match.

- **#2 Legacy-stage canonicalization regression test** — new integration
  test locks in the external-EOI advance gate: canonical pre-EOI stages
  (enquiry/qualified/nurturing) advance to 'eoi' on upload; at-or-past-EOI
  stages stay put while metadata still writes. 7/7 passing. Backfill
  script intentionally not shipped — dev DB is test data, prod cutover
  is manual.

- **#3 Global-search dropdown translucent rows** — defensive opaque
  background on the popover wrapper (bg-white dark:bg-popover) guards
  against the subtle transparency UAT captured on the Berths page.
  Live-browser repro still needed to identify the exact bleeding row;
  this defense makes the surface unambiguously solid in light mode
  regardless of which class wins tailwind-merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:59:25 +02:00
14ae41d0fa feat(uat-b1): ship Wave A-E of Bucket 1 audit findings
Wave A (Interest+EOI form quick wins):
- Auto-select yacht after inline-create from interest form
- EOI generate dialog: "View EOI" action toast
- Interest form berth picker: formatBerthRange compact label
- Remove "Generate EOI" button from Documents tab (clean removal)
- Interest auto-assign: only sales_agent/sales_manager auto-claim
  ownership on create (explicit role check via user_port_roles join)
- LinkedBerthRowItem dims: drop "D" suffix + "L × W" format
- ExternalEoiUploadDialog: prefillSignatories prop threaded from
  active EOI signers
- EOI signature progress on Overview milestone card footer

Wave B (a11y + i18n sweeps):
- aria-live on supplemental-info error state
- text-[10px] -> text-xs in client-pipeline-summary
- Currency formatter: locale default removed (Intl uses runtime)
- en-US/en-GB hardcoded toLocaleString swept across 13 components

Wave C (Primary berth always in EOI bundle):
- Service guard strengthened on update path
- Migration 0083 backfills historical primary rows

Wave D (Onboarding super_admin discoverability):
- /api/v1/admin/onboarding/status endpoint + shared service
- Topbar OnboardingBanner (super_admin, session-dismissible)
- OnboardingTile dashboard widget (rail group, self-hides at 100%)
- Celebration toast + invalidate of shared status on last tick

Wave E (Branded post-completion email idempotency):
- Verified handleDocumentCompleted already owns the email fan-out
- Added regression test for the polling path + idempotency

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:40:37 +02:00
41737fa950 feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish
Critical data-correctness fixes
- external-eoi.service: stage-advance list rewritten against canonical
  7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to
  legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so
  EOI uploads from 'qualified' silently skipped the stage flip. Now also
  writes eoiDocStatus='signed' alongside eoiStatus='signed'.
- public-interest.service + api/public/interests/route: pipelineStage
  'open' → 'enquiry' for new public interests.
- interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker
  comments updated.
- Display fallbacks canonicalized: dashboard.service, dashboard-report-data,
  pdf/templates/{interest,client}-summary, interest-picker, timeline route
  all route through canonicalizeStage / stageLabelFor.

Multi-berth interest label sweep
- New helper src/lib/templates/interest-berth-label.ts with 9 unit tests
  (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments,
  falls back to 'first + N more').
- New batched aggregator getAllBerthMooringsForInterests on the
  interest-berths service.
- BoardInterestRow + listInterests + getInterest extended with
  berthMoorings: string[].
- Swept render sites: interest-detail-header, pipeline-card +
  pipeline-column (kanban), interest-columns (list), interest-card,
  interest-detail (breadcrumb), client-pipeline-summary +
  client-interests-tab, yacht-tabs, shared interest-picker.
- PDF report "New interests (in period)" Source column → Berth column.

Dashboard PDF report fixes
- Hardcoded EUR → reads ports.default_currency once at the top of
  resolveDashboardReportData. Falls back to USD.
- 'maintenance' berth-status bucket removed everywhere (wasn't in
  canonical BERTH_STATUSES); cleaned from dashboard.service,
  dashboard-report-data, occupancy-report, berth-status-chart, fixture.
- Berth demand ranking: dropped placeholder Tier column (resolver
  hardcoded 'A' — heat-tier never plumbed through).
- Deal pulse distribution: tier values capitalized (hot → Hot etc.).
- Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing
  "Validation failed" when all sections checked).
- Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no
  more 2-line wraps on "needs date range"); accepts initialRange?:
  DateRange so the dashboard's active range pre-fills dateFrom/dateTo via
  rangeToBounds.

Interest banner overcounts fix
- interest-berth-status-banner: filters out self-caused under-offer
  berths (where the only active deal touching the berth IS this same
  interest). Waits for all competing-queries before committing the
  count. Was showing "3 berths unavailable" when only 1 actually had a
  competitor.

Sessions list ordering
- sessions-list: client-side sort by lastAt desc + displays lastAt
  instead of firstAt so visible timestamp matches the sort key.

Audit log polish
- Details button: side Sheet → Popover anchored to the button (in-place
  inline dropdown). Works with the virtualized table.
- From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3.

EntityFolderView (Documents Hub entity view)
- Per-row Download button (hover-reveal icon).
- File-type icon prefix + tighter row layout.
- Per-row interest-berth badge: files.ts attaches interestBerthLabel via
  one batched getAllBerthMooringsForInterests call across all groups.
  AggregatedFile type + EntityFolderView render the badge linking back
  to the parent interest.

External EOI upload dialog
- Title input pre-fills from the derived default via controlled
  displayTitle = title || defaultTitle (no setState-in-effect).

EOI Generate dialog
- Success toast on mutation success.
- Primary berth's "Include in EOI" checkbox is now forced-on + disabled
  with tooltip: the primary IS the canonical "berth for this deal",
  excluding it is semantically nonsense.

Primary berth must always be in EOI bundle (service + backfill)
- interest-berths.service: insert path forces is_in_eoi_bundle=true
  whenever is_primary=true; update path coerces back to true when the
  caller tries to set false on a primary. Backfilled 7 existing rows.

Documenso redirect URL fallback
- port-config getPortDocumensoConfig: resolution chain extended to
  documenso_redirect_url → public_site_url → null. Operators with
  public_site_url configured (most ports) now get sensible signer
  landing without setting two settings.

World-map click → navigate
- website-analytics-shell: country click navigates to the nationality-
  filtered Clients page via router.push instead of copying a URL to
  clipboard.

Documents Hub: subfolder grid in main panel
- Subfolder cards rendered above the documents list when the current
  folder has children. Lets reps drill into subfolders from the main
  content area, not only via the sidebar tree.

Interest list initial sort
- usePaginatedQuery gains initialSort option (used when URL has no sort
  param). Interest list passes updatedAt desc so the table header
  surfaces the active sort visibly + most-recently-added/edited bubble
  to the top.

Interest auto-assign on create
- interests.service createInterest: three-tier owner resolution chain
  — explicit input → port's default_new_interest_owner setting →
  creator (when not super-admin). Super-admins skipped since they often
  create on behalf of other reps.

Backfills
- 12 interests with eoi_status='signed' + missing eoi_doc_status='signed'
  aligned.
- 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false
  flipped to true.

Verified
- pnpm tsc --noEmit: clean
- pnpm exec vitest run: 1463 / 1463 passed

Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md
across all 4 buckets, including two OPEN QUESTIONS (Reservations module
re-imagine, Reports dedicated page promotion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00
c14f80a4f7 feat(uat-batch): Group Q — platform refactors
Q58, Q59, Q61 from the 2026-05-21 plan. Q57 + Q60 (sweep-scope) parked.

Shipped:
  Q58  SelectTrigger size variant. <SelectTrigger> now accepts
       `size?: 'default' | 'sm'`. Default = `h-11` so the trigger
       matches <Input>'s h-11 default and the 8px height mismatch
       called out in the UAT vanishes platform-wide. Existing call
       sites that need the legacy compact look (FilterBar, dense
       table headers) opt back in via `size="sm"`. Nothing breaks —
       the default render flips height without touching any other
       styling.
  Q59  Table density min-widths + nowrap. DataTable cells now
       default to `whitespace-nowrap` so long values (URLs, names,
       addresses) don't wrap into 4-5 lines and inflate row height.
       Columns that need wrapping override via the column def's
       `meta.wrap = true`. Min-width comes from
       `column.getSize?.()` when set so a column doesn't shrink-
       wrap below readability — opt-in per column rather than a
       sweeping width change.
  Q61  Error message audit foundation — Documenso 401/403 path
       enriched. <PortDocumensoConfig> gains `apiKeySource` +
       `apiUrlSource` ('port' | 'global' | 'env' | 'default' |
       'none'). `getPortDocumensoConfig` populates them based on
       which layer of the resolver chain produced the value.
       documenso-client's <ResolvedCreds> exposes the source flags;
       the 401/403 branch surfaces them in the
       `DOCUMENSO_AUTH_FAILURE` internalMessage so operators see
       "api key source: env, port: <id>" instead of the prior
       generic `path → 401` body. Solves the Documenso diagnosis
       loop that prompted the platform-wide error audit. Same
       pattern can extend to other integration error paths in
       follow-ups (S3, Redis, IMAP) — the resolver-source helper
       lives on PortConfig now.
  Q60  Tooltip audit primitive already shipped — <FieldLabel> in
       `ui/field-label.tsx` is the canonical surface with an Info
       icon + Tooltip slot. One adopter live (custom-field-form);
       remaining admin-form sweep is the lift that's parked.

Deferred:
  Q57  recharts → ECharts migration (6-10h). Pure visual port of
       8 chart components; safer as a focused session with
       per-chart visual review. Pre-reqs (ECharts deps + the
       transpilePackages config + the d3-geo install) are in place
       so the migration can be picked up cleanly.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:49:22 +02:00
47c2ba9a99 feat(reports): client / berth / interest list-export PDF reports (phase B)
Extends the report exporter with three list-style report kinds —
clients, berths, interests. Each shares the BrandedReportDocument
layout + the new ReportTable primitive (zebra-striped rows,
proportional widths, no-break rows to keep records together across
page boundaries).

Data fetchers in `src/lib/services/list-report-data.service.ts`:
  - resolveClientReportData: clients table joined to per-client
    primary email + phone via DISTINCT-style subqueries (matches the
    canonical listClients ordering: is_primary DESC, created_at DESC
    per channel).
  - resolveBerthReportData: berths table, default sort by mooring
    number for printed familiarity.
  - resolveInterestReportData: interests left-joined to clients +
    primary berth, sort by updatedAt desc.

All three cap at 1 000 rows per export with a clear "Showing top N
of <total>" notice rendered when the cap is hit. Above that, the PDF
becomes unreadable (hundreds of pages); reps wanting larger exports
use CSV.

Route schema widened to a 4-arm discriminated union; the dispatch
switch in render-report.ts uses `satisfies` for compile-time variant
narrowing and a `_exhaustive: never` check at the bottom.

UI: each list page (BerthList, ClientList, InterestList) gains an
ExportListPdfButton next to the existing ColumnPicker. Permission-
gated client-side on reports.export; server route re-enforces.

Tests: 3 new render fixtures (1 per kind), all hit the same
%PDF-magic + byte-length assertions. Total render tests now 6/6;
full vitest sweep 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:42:55 +02:00
3b199c245c feat(reports): PDF report exporter foundation + dashboard report (phase A)
Production-grade PDF reporting for the CRM. Phase A ships the
foundation (branded layout, render pipeline, API route) plus the
first report kind — the dashboard summary. Phases B, C, D add the
remaining report kinds, saved templates, and the preview modal.

Stack: @react-pdf/renderer (already in package.json). Single primary
font (Helvetica/Helvetica-Bold), per-port primary color + logo,
table-based section layout. Charts will become tables here on
purpose; reports are for printed reference and review, where
exact numbers beat at-a-glance shapes. We can revisit Recharts-as-
SVG embedding if a stakeholder asks for chart visuals.

New files:
  - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig
    covering dashboard / clients / berths / interests kinds. Only
    dashboard is wired in phase A; the others throw a clear
    not-implemented error from pickDocument().
  - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off
    branding.primaryColor. Computes a readable foreground color
    (luminance check) for the accent stripe so dark-brand ports
    still read at AA.
  - src/lib/pdf/reports/branded-document.tsx: page wrapper with
    fixed footer (port name, generated-at timestamp, page numbers
    via react-pdf's render-prop pattern).
  - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget
    SimpleTable sections. Each section gated on the widget id being
    present in config.widgetIds AND data being supplied.
  - src/lib/pdf/reports/render-report.ts: single entry point that
    resolves branding (logoUrl + primaryColor + portName from
    getPortBrandingConfig + ports.name), dispatches via
    discriminated-union switch, returns Buffer via renderToBuffer.
    Exhaustiveness check at the bottom catches unhandled variants
    at compile time.
  - src/lib/services/dashboard-report-data.service.ts: server-side
    data resolver. PDF_DASHBOARD_WIDGETS is the public widget list
    for the dialog picker; each id maps to a dashboard.service.ts
    fetcher invoked only when the rep selected that widget.
  - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod
    discriminated-union body schema, withAuth + withPermission
    'reports.export' gating, audit-log write on success, RFC 5987
    Content-Disposition for unicode-safe filenames.
  - src/components/reports/export-dashboard-pdf-button.tsx: dialog
    with section checkboxes + title input. Permission-gated client-
    side (server re-checks). Raw fetch (not apiFetch) to pull the
    binary blob with X-Port-Id header attached manually.
  - tests/unit/pdf-report-renderer.test.ts: renders three fixture
    cases — full set / sparse / no-logo — and asserts the buffer
    starts with the `%PDF-` magic bytes and is non-trivial in size.

DashboardShell gains an Export PDF button between the date-range
picker and the Customize widgets menu (gated on reports.export).

Verified: tsc clean, vitest 1451/1451 (3 new render tests included).
The first end-to-end manual test (export a real dashboard) is in
Phase D after the preview modal lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
8e9efe5ae8 fix(yachts): ft↔m round-trip is lossless (4dp + canonical helpers)
Three copies of the imperial/metric conversion logic existed:
  - src/components/yachts/yacht-dimensions.ts   (canonical, used by
    read-side `formatYachtDimensionsBothUnits`)
  - src/components/yachts/yacht-form.tsx        (create/edit sheet —
    local `ftToM`/`mToFt` with 2dp precision)
  - src/components/yachts/yacht-tabs.tsx        (detail-tab inline
    edit — local arithmetic with 2dp precision)

The 2dp rounding lost precision on the round-trip: `1 ft → 0.30 m →
0.98 ft`. Whenever a rep entered ft, then later touched the m field,
the ft column silently shifted off. Same for sub-meter draft values.

Consolidate both surfaces onto `feetToMeters` / `metersToFeet` from
yacht-dimensions.ts and bump display precision to 4dp. After
trimZero strips trailing zeros the rendered string stays clean
("3.81" not "3.8100") but the round-trip now lands back on the
original value:

  1 ft → 0.3048 m → 1 ft
  12.5 ft → 3.81 m → 12.5 ft
  50 ft → 15.24 m → 50 ft
  0.5 m → 1.6404 ft → 0.5 m

New unit test (`tests/unit/yacht-dimensions.test.ts`) covers the
helpers + the form-shape round-trip, including the canonical
12.5 ft ↔ 3.81 m case from the UAT bug report.

29/29 new tests pass; full vitest 1448/1448.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:25:28 +02:00
449b9497ab fix(uat): batch — timeline overshoot, name-sync, reset-password, dashboard cleanup, queue/seed hygiene + alpha UAT findings doc
UAT findings landed across the last few Playwright + React Grab passes;
single grouped commit so the index doesn't fragment into 30 one-liners.

User & auth:
- `user-settings`: name now updates the avatar + topbar menu after save
  (was reading stale session).
- `me/password-reset`: 3 bugs (token validation, error response shape,
  redirect chain).
- Admin user permission-overrides route honours the same envelope as
  the rest of the admin surface.

Dashboard:
- Removed obsolete `revenue-breakdown-chart` + `dashboard-widgets-card`
  (replaced by the customisable widget grid).
- Strip `revenue_breakdown` from analytics route + use-analytics +
  service + integration test so nothing renders an empty card.
- Activity log timeline overshoot fix (`interest-timeline` +
  `entity-activity-feed`).
- Tightened tiles: active-deals, berth-heat-widget, pipeline-value, kpi-tile.
- `dev-mode-banner`: derive dismissed state synchronously instead of
  via an effect (set-state-in-effect lint rule).

Forms & lists (assorted polish):
- client / company / yacht / interest / reminder forms — validation +
  empty-state copy + tab transitions.
- companies/yachts list tweaks; berth recommender panel; qualification
  checklist; supplemental info request button.

Infra & misc:
- Queue workers (ai / email / notifications) — log shape +
  per-job timeout consistency.
- Auth / brochures / users schema small adjustments; seeds reflect
  permissions matrix changes.
- Scan shell + scanner manifest + AI admin page small fixes.
- `next.config.transpilePackages` adds `echarts`/`zrender`/`echarts-for-react`
  (recommended config from echarts-for-react inside Next).

Docs:
- `docs/superpowers/audits/alpha-uat-master.md` — single rolling
  cross-cutting UAT findings doc (per CLAUDE.md convention).
- `docs/BACKLOG.md`: dashboard stats cards (§I) + activity-log
  normalization (§J).
- 2026-05-18 audit log updated with this batch.
- `CLAUDE.md` — small manual UAT scaffold notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:56:11 +02:00
b4bf9cca3f feat(branding): multi-tenant brand naming + per-port email shell + auth UI continuity
Removes the last hardcoded "Port Nimara" references so a tenant cloning
the deploy with a fresh slug sees their own brand throughout.

Browser + native chrome:
- `generateMetadata` reads `branding_app_name` from the first port row
  so the browser tab title, apple-web-app title, and template literal
  reflect the tenant (fallback "CRM" until DB is seeded).
- Mobile topbar derives the brand-mark initials from the port slug
  ("port-nimara" → "PN", "marina-alpha" → "MA") — no code edit on clone.
- `documenso-payload` default redirect URL is `""` so Documenso falls
  back to its own post-sign page instead of routing every tenant's
  signers to portnimara.com; per-port `redirectUrl` setting still wins.
- Server-startup log uses generic "CRM server listening".

Email + auth shell:
- New `auth-shell-branding.ts` resolves logo / background / appName once
  per request from `system_settings`; used by both the email shell and
  the auth-pages SSR layout.
- `auth-branding-provider` wraps `/login`, `/reset-password`, `/set-password`,
  portal `/portal/*` so the branded shell hydrates with the same assets
  the inbox sees.
- `me/email` change email uses the branded shell instead of inline HTML
  with "Port Nimara CRM" baked into copy.
- Admin branding page adds an email-preview card (POSTs to
  `/api/v1/admin/branding/email-preview`) so an admin can spot-check
  their templates before going live.
- `/api/public/files/[id]` exposes branding-category files anonymously
  so inbox images (no session cookie) can render; any other category
  still flows through authenticated `/api/v1/files/[id]/preview`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:54:10 +02:00
b3f87563c6 feat(audit-cleanup): finish all 15 outstanding items from verified backlog
Audit cleanup completion plan, all tiers shipped:

Tier 1 (security + data integrity)
- A.7 RTBF true wipe: redact email_messages body/subject/addresses for
  threads owned by deleted client; redact document_sends.recipient_email;
  collect file storage keys + delete blobs post-commit.
- A.8 user_permission_overrides FK: documented inline why cascade is
  correct (not set-null as audit suggested) — overrides have no value
  without their user.
- W2.14 PII redaction: camelCase normalization in audit.ts +
  error-events.service.ts isSensitiveKey; added city/postal/country/
  birth fragments. firstName/lastName/dateOfBirth/postalCode etc. now
  caught in BOTH masker paths. 12 new test cases lock the coverage.

Tier 2 (Documenso completion + refactor)
- C.2: documentEvents.recipient_email column + partial unique index for
  per-recipient webhook dedup (migration 0075). handleDocumentSigned
  now sets recipient_email on insert.
- Phase 2: completion_cc_emails distribution. handleDocumentCompleted
  reads documents.completionCcEmails, filters out signer-duplicates
  case-insensitively, fans signed PDF out to non-signer recipients.
- C.4: extracted createPublicInterest() service from the 346-line
  api/public/interests route. Route becomes a thin shell (rate-limit,
  port resolution, audit log, email fan-out). The trio creation logic
  is now unit-testable without an HTTP fixture.
- Phase 4: POST /api/v1/document-templates/[id]/detect-fields wired
  to document-field-detector.detectFields(). Sparkles "Auto-detect"
  button added to template-editor.tsx — maps DetectedField → marker
  with best-guess merge token (DATE / NAME / EMAIL); user retags.

Tier 3 (reporting + recommender snapshot lockfiles)
- W7.reports: extracted rollupStageRevenue / rollupStageCounts /
  computeTotalForecast / computeOccupancyRate / rollupBerthStatusCounts
  into src/lib/services/report-math.ts (pure functions). 16 new tests
  including an inline-snapshot lockfile on a representative 7-stage
  forecast. report-generators.ts now delegates.
- W7.recommender: 18 new toMatchSnapshot tripwires on classifyTier
  boundaries + computeHeat at canonical input points.

Tier 4 (rolling)
- W6.attach: fixed outdated CLAUDE.md claim — threshold banner is
  informational and never depended on IMAP; bounce monitoring (the
  IMAP poller) is separate.
- D.1 + D.2: documented deferral inline with full why-not-build-it
  reasoning so a future engineer sees the rationale.
- G.1: representative formatDate sweep (audit-log-list, user-list,
  document-templates merge tokens, document-signing email). Rest of
  the ~100 sites stay rolling.

Quality gates: 1420/1420 vitest (46 new tests above baseline of 1374),
tsc clean, 0 lint errors.

Plan: docs/superpowers/plans/2026-05-18-audit-cleanup-completion.md
Migration: 0075_c2_document_events_recipient_email.sql (applied to dev DB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:22:36 +02:00
4b5f85cb7d fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish
Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).

CRITICAL (3):
 - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
   no longer silently drop interest links
 - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
 - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
   callers must go through /stage with the override-guard chain

HIGH (14/15):
 - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
   interests/documents/reservations/reminders/invoices (migration 0070)
 - H-02 login page reads ?redirect= param with same-origin guard
 - H-03 CRM invite token moves to URL fragment so it never lands in
   nginx access logs / Referer headers
 - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
 - H-05 toggleAccount writes an audit row
 - H-06 upsertSetting masks any value whose key ends with _encrypted
 - H-07 archiveClient cascade fires per-interest audit rows
 - H-08 createSalesTransporter applies SMTP_TIMEOUTS
 - H-09 AppShell stable children — viewport flip across breakpoint no
   longer destroys in-progress form drafts
 - H-10 portal documents page swaps Unicode glyph status icons for
   Lucide CheckCircle2/XCircle/Circle + aria-labels
 - H-12 list components swap alert(...) for toast.warning(...)
 - H-13 5 icon-only buttons gain aria-label
 - H-14 parseBody treats empty bodies as {}
 - H-15 admin layout renders a 403 panel instead of silent bounce
 - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet

MEDIUM (28+):
 - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
   WHEREs across custom-fields, notes (all 6 entity types x update +
   delete), client-contacts, yacht ownerClient lookup, webhook reads
 - M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
 - M-EM01 portal-auth emails thread through portId
 - M-EM02 sendEmail accepts cc/bcc params
 - M-EM04 notification_digest catalog key
 - M-IN01 portal presigned download URLs use 4h TTL
 - M-IN02 OpenAI client lazy-instantiated
 - M-IN04 stale pdfme refs updated to pdf-lib AcroForm
 - M-IN05 umami.testConnection returns tagged union
 - M-L01 reservations tenure_type unified with berths
 - M-L02 report-generators canonicalize stage values
 - M-AU01 audit log placeholder copy fixed
 - M-AU04 outcome_set / outcome_cleared distinct audit verbs
 - M-NEW-2 activity feed entity name+type separator
 - M-R01 portal allowlist narrowed + portal_session backstop in proxy
 - M-SC02 companies archived partial index
 - M-SC04 audit_logs.searchText documented as DB-managed
 - M-S01 storage_s3_access_key_encrypted admin field
 - M-U01 audit log empty state uses <EmptyState>
 - M-U09 invoice delete dialog -> <AlertDialog>
 - M-U10 toast.success on ClientForm + InterestForm create/edit
 - M-U11 settings-form-card logo preview alt text
 - M-U14 mobile topbar title on clients/yachts/interests/berths
 - M-U15 Invoices in mobile More-sheet

LOW (6/8):
 - L-AU01 severity defaults for security-relevant verbs
 - L-AU02 +13 missing actions in admin audit filter
 - L-AU03 +7 missing entity types in admin audit filter
 - L-AU04 dead listAuditLogs stubbed
 - L-D02 CLAUDE.md Owner-wins chain tightened

Bonus — Document detail polish (#67 partial, 3/6 deliverables):
 - state-aware action button per signer
 - watcher Add UI with display-name resolution
 - cleanSignerName cleanup

Prior session work bundled in:
 - Documenso v2 webhook + envelope-ID normalization + sequential signing
 - SigningProgress UI redesign (avatars, per-signer state, timestamps)
 - env->admin settings registry + RegistryDrivenForm + encrypted creds
 - Embedded-signing card + Test connection + setup help
 - Dev-mode EMAIL_REDIRECT_TO banner
 - Pipeline rules admin page
 - Sales email config card
 - Audit log details Sheet
 - EOI tab: Finalising badge, absolute timestamps, sequential indicator
 - Notes pipeline_stage_at_creation (migration 0069)
 - Documenso numeric ID dual-key webhook (migration 0068)
 - Dimensions criterion copy (migration 0067)

Tests: 1374/1374 vitest pass. tsc clean. lint clean.

See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
98211066a5 fix(legacy-stage): purge 9-stage enum keys from rank tables and stale copy
Some checks failed
Build & Push Docker Images / lint (push) Successful in 2m4s
Build & Push Docker Images / build-and-push (push) Has been cancelled
L-001 hunt landed these:

  - src/lib/services/clients.service.ts — stageRank used pre-refactor
    9-stage names exclusively (`contract_signed`, `deposit_10pct`, …).
    Every modern 7-stage interest fell to rank 0, making client-list
    "most-progressed deal" sort effectively random. Modern values now
    own the canonical ranks; legacy aliases map to their 7-stage
    equivalents so historical audit data still sorts.

  - src/lib/services/berth-recommender.service.ts — STAGE_ORDER had
    the same 9-stage shape. LATE_STAGE_THRESHOLD pointed at the (now
    nonexistent) `deposit_10pct` slot. Reworked to the 7-stage scale;
    threshold now at `deposit_paid` (5).

  - Stale comments referencing `deposit_10pct` in schema (clients,
    financial) and client-archive services updated to current copy.

  - Smart-archive dialog rendered `i.pipelineStage` as raw enum; now
    routes through `stageLabelFor` (the new helper added with A2).

Test fixture updates: berth-recommender.test.ts numeric inputs
re-mapped to the new 7-stage scale (eoi_signed=5 → eoi=3, etc.).
1373/1373 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:18:13 +02:00
0d9208a052 fix(audit): A1/A2/A4/A6/A8/A9/A16/A17/A19/A20 from 2026-05-15 sweep
Knocks out 10 of the 13 known issues from yesterday's Playwright audit.

A4 — Client form silently rejected submit when a contact row had an
empty value. The F19 filter ran in mutationFn after zod's
handleSubmit had already short-circuited on min(1). Now wraps the
onSubmit to prune empty rows BEFORE handleSubmit/zod sees them.

A16 — File upload to documents hub root 400'd because FormData.get
returns null for absent fields and zod's .optional() rejects null.
Route handler now coerces null/empty → undefined before parse.

A17 — Added /api/v1/me/ports endpoint that any authenticated user
can hit; client.ts now uses it as the bootstrap port-slug→port-id
resolver. Eliminates the wasteful 400s sales-reps and viewers were
firing on every page load against the super-admin-gated /admin/ports.

A1 — Filter permission_denied actions from the dashboard activity
feed. Still in the audit log; just not noise on the dashboard.

A2 — New LEGACY_STAGE_REMAP table + canonicalizeStage / stageLabelFor
helpers in lib/constants. Activity-feed maps legacy 9-stage enum
values (deposit_10pct, contract_sent, etc.) to their 7-stage labels
on the way out, so historical audit rows read as "Deposit Paid" not
"Deposit 10Pct".

A19 — Same-stage write now returns 204 No Content. Service returns
a STAGE_NOOP sentinel; the route handler translates it.

A9 — Catch-up wizard now derives stage from berth status (under_offer
→ EOI, sold → contract) with a stageOverride state for explicit
user picks. Avoids the set-state-in-effect rule violation.

A20 — OwnerPicker shows a "Client / Company" hint chip on the
trigger when no value is set, so users know the trigger opens a
two-tab picker instead of just a client list.

A8 — Migration 0066 normalizes legacy `statusOverrideMode = 'auto'`
to NULL so the column lives at strictly 3 states.

A6 — file-preview-dialog gets a screen-reader DialogDescription so
the Radix "Missing aria-describedby" warning stops firing on every
preview.

A18 closed as not-a-bug: /api/v1/users genuinely doesn't exist
(Next returns 404); /api/v1/admin/audit exists and 403s.

A5 (Socket.IO dev noise) + A3 (react-grab CSP) left for a separate
pass — both are dev-only cosmetic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:12:20 +02:00
58940552be test: update yacht-prereq error message assertion to match F21 copy
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m12s
Build & Push Docker Images / build-and-push (push) Successful in 4m35s
The integration test was pinned to the legacy "yachtId is required before
leaving stage=enquiry" developer-language string. F21 reworded it to
"A yacht must be linked before leaving the Enquiry stage." for the toast
surface — bring the test regex along.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:00:32 +02:00
f85948488d test: update GDPR export test for dashed jobId — companion to F3
The test asserted the old `gdpr-export:${id}` shape that BullMQ rejects.
Mirrors the production fix in 7da3c5b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:50:51 +02:00
e933e32dbd feat(schema): berths.archived_at + clients.source_inquiry_id + email_bounces
Step 3 schema additions per PRE-DEPLOY-PLAN § 1.4.

berths.archived_at (+ archived_by, archive_reason) — soft-delete column
so retired moorings can be hidden from the public feed and admin lists
without losing historical interest joins. Partial index `idx_berths_active`
on (port_id) WHERE archived_at IS NULL keeps the active-only list path
fast. Already wired:
- /api/public/berths and /api/public/berths/[mooringNumber] now filter
  out archived rows.
- berths.service.listBerths defaults to active-only with an
  ?includeArchived=true escape hatch for the archive bin.

clients.source_inquiry_id — text column with ON DELETE SET NULL FK to
website_submissions(id). Preserves the linkage from a website inquiry
to the client that came out of the "Convert to client" triage flow
(P-4.5). Drives the conversion-funnel-by-source chart (Step 6). The
Drizzle column ships without `.references()` to avoid the cross-file
circular import; the FK lives in the migration SQL.

email_bounces table — bounce-monitoring storage. The DSN poller worker
(forthcoming, depends on this table existing) writes one row per parsed
bounce; consumers join via (original_send_type, original_send_id).
Three secondary indexes cover the expected access patterns (port +
recent bounces; lookup by bounced address; lookup by original send).

Schema additions plus the migration SQL are ready for `pnpm db:push`
(or the migration runner once its journal is backfilled — separate
concern, journal currently stops at 0042 despite migrations through
0065 existing on disk).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:33:20 +02:00
bded8b21f1 feat(reporting): money-math sweep — Step 1 PRE-DEPLOY-PLAN
Single coherent commit completing § 1.1 (hot-path correctness) plus
§ 1.1.4.5 (multi-berth EOI mooring fix). Numbers users see are now
self-consistent across dashboard / kanban / hot deals / PDF reports.

## Active-interest sweep (canonical predicate everywhere)

Routed every "active interest" filter through `activeInterestsWhere`
(commit b966d81 helper). The helper enforces port-scoping + archivedAt
IS NULL + outcome IS NULL — strict definition, won is closed.

Touched sites:
- src/lib/services/reminders.service.ts:digestPort — no longer fires
  reminders for won/lost/cancelled deals
- src/lib/services/berths.service.ts:getLatestInterestStageByBerth
- src/lib/services/client-archive-dossier.service.ts (next-in-line
  others lookup)
- src/lib/services/client-archive.service.ts (remaining-under-offer
  recount before flipping berth back to available)
- src/lib/services/client-restore.service.ts (yacht-usage check)
- src/lib/services/interests.service.ts:listInterestsForBoard +
  getInterestStageCounts + the "others on same berth" lookup —
  kanban / board now exclude terminal deals
- src/lib/services/report-generators.ts: fetchPipelineData,
  fetchRevenueData stage breakdowns, top-N interests

## Pipeline-value currency conversion

`getKpis()` now fetches the port's defaultCurrency from `ports` and
converts each berth's `priceCurrency`→port-default via
`currency.service`. Returns `pipelineValue` + `pipelineValueCurrency`
instead of the lying `pipelineValueUsd`. Missing rates fall through to
raw amount summing (so the tile still shows an approximate number) —
behind a follow-up to surface a "rates incomplete" indicator.

3 consumers updated: KpiCards, PipelineValueTile, ActiveDealsTile.

## Occupancy = sold only

Both the dashboard KPI tile and the revenue-report PDF occupancy data
now count only `berth.status='sold'`. `under_offer` is a hold, not
occupation. The analytics timeline switches from
`berth_reservations`-derived to a cumulative-won-deals derivation via
`interests.outcome='won' AND outcome_at::date <= day` — same source of
truth, historical shape preserved.

## Revenue PDF two-card layout

Added `totalForecast` + `pipelineWeights` to `RevenueData`. Summary
section now renders both:
- "Completed revenue (won)"  — money in the bank
- "Forecast revenue (pipeline-weighted)" — expected pipeline value

Pipeline weights resolve from `system_settings.pipeline_weights`
(per-port admin override) and fall back to STAGE_WEIGHTS defaults. PDF
and dashboard forecast tiles reconcile.

## Multi-berth EOI mooring (4.5)

Documenso `Berth Number` form field now carries the formatBerthRange
output for BOTH single- and multi-berth EOIs. Single-berth output is
byte-identical to the legacy primary-only path
(`formatBerthRange(['A1']) === 'A1'`). Multi-berth EOIs now render
the full range ("A1-A3, B5") in the existing field instead of being
silently dropped against a nonexistent `Berth Range` field.

Dropped:
- `'Berth Range'` from the Documenso formValues payload + TS type
- `setBerthRange()` helper from fill-eoi-form.ts (now redundant)
- The "missing Berth Range AcroForm field" warning log

Updated CLAUDE.md to reflect — no Documenso admin template change
needed.

## Tests

- Updated `documenso-payload.test.ts` — new fixture asserts
  formatBerthRange output flows into Berth Number; multi-berth case
  added.
- Updated `analytics-service.test.ts:computeOccupancyTimeline` —
  fixture creates a won interest instead of a reservation.
- Updated `alerts-engine.test.ts:interest.stale` — fixture stage
  switched from dead `'in_communication'` to canonical `'qualified'`.
- Updated `report-templates.test.tsx:revenue` — fixture carries
  `totalForecast` + `pipelineWeights` to match new RevenueData.

1373/1373 vitest pass. tsc + eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:19:38 +02:00
b966d8106d feat(active-interest): canonical predicate + fix stale getHotDeals rank
Extract activeInterestsWhere(portId) as the single source of truth for
"active interest" SQL filtering: scoped + archived_at IS NULL + outcome
IS NULL. Won deals are now CLOSED, not active — pre-2026-05-14 the
dashboard used a permissive `outcome IS NULL OR outcome = 'won'` that
double-counted won revenue against the in-flight pipeline.

Locked in PRE-DEPLOY-PLAN § 1.1.2.

Bonus catch: getHotDeals rank-CASE referenced the OLD 9-stage pipeline
names ('completed', 'contract_signed', 'contract_sent', 'deposit_10pct',
'eoi_signed', 'eoi_sent', 'in_communication', 'details_sent'). Every
row hit the ELSE 0 branch under the new 7-stage model, collapsing
ordering to updatedAt only — the widget silently stopped surfacing
"closest to closing". Rebuilt the rank ladder against the current
canonical stages (enquiry → ... → contract).

Tests: 2 unit tests assert the predicate's compiled SQL contains
"archived_at" IS NULL + "outcome" IS NULL, and never the legacy 'won'
literal.

Remaining sweep targets queued for the next commit:
- client-archive-dossier.service.ts
- client-restore.service.ts
- client-archive.service.ts
- reminders.service.ts
- berths.service.ts (recommender feasibility)
- interests.service.ts
- report-generators.ts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:53:58 +02:00
905852b8a5 feat(permissions): carve out dedicated payments resource
Payments (deposit / balance / refund records on an interest) used to
share `invoices.record_payment`, which forces a port that doesn't
issue invoices at all to still navigate the invoicing permission
group to grant its sales reps payment-recording rights. Splitting
the resource lets admins gate the two surfaces independently.

The new resource has three actions:
  - view   — gates the UI affordance (API reads still go through
             `interests.view`)
  - record — POST / PATCH a payment
  - delete — DELETE a payment record

Seed maps updated for all six system roles; existing role rows +
per-user permission overrides are backfilled by migration 0064 so
upgrades don't silently lose access. Two call sites (POST /interests/
[id]/payments, PATCH /payments/[id]) → payments.record; one
(DELETE /payments/[id]) → payments.delete. The PermissionGates on the
payments-section UI swap to the new keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:46:01 +02:00
6b28459c45 feat(pipeline): 9→7 stage refactor + v1.1 hardening wave
Replaces the legacy 9-stage pipeline with 7 canonical stages
(enquiry → qualified → eoi → reservation → deposit_paid → contract →
nurturing) plus three doc sub-status columns (eoi_doc_status,
reservation_doc_status, contract_doc_status) that track sent/signed
within a single stage instead of branching it.

Schema (migration 0062):
- interests gains assigned_to, deposit_expected_amount/currency,
  three doc-status columns, two documenso-id columns, and
  date_reservation_signed.
- New tables: qualification_criteria (per-port admin-configurable),
  interest_qualifications (per-interest state), payments (deposit /
  balance / refund records keyed to interest + client).
- Default qualification criteria seeded for every existing port.
- Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into
  the new stage + doc-status + outcome shape.

Migration 0063 adds interest_contact_log.voice_transcript and
template_used columns for v1.1-A/B (quick-template buttons + voice
transcription via Web Speech API).

v1.1 phase work bundled here:
- A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on
       the contact-log compose dialog (useVoiceTranscription hook).
- C:   berth-rules-engine wraps state writes in pg_advisory_xact_lock
       with an idempotent re-read; emits rule_evaluated audit traces.
- D:   Documenso webhook: reservation/contract sub-status stamping
       moved out of the PDF-download try-block so a download failure
       no longer swallows the stamp. New integration test coverage.
- E:   /admin/qualification-criteria CRUD page + admin component.
- F:   default_new_interest_owner exposed in System Settings.
- G:   recentActivityCount + active_engagement deal-pulse signal
       surfaced as a chip on interests + hot-deals card.
- H:   interest_assigned notification on assignedTo change (skips
       self-assign, uses a dedupe key).

Plus the supporting components: AssignedToChip, DealPulseChip,
PaymentsSection, QualificationChecklist, MultiEoiChip,
SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner,
SupplementalInfoRequestButton, UserPicker.

Tests: 1370/1370 vitest pass (added deal-health unit suite +
expanded constants/validators/pipeline-transitions coverage). tsc
clean, eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
4d1fbcd469 feat(documenso-phase-5): pin transformSigningUrl + document website-side coordination
Phase 5 is mostly coordination + verification rather than a code
build — the embedded signing pages live in a different repo. What
lands here:

1. transformSigningUrl hardening — routes through extractSigningToken
   so a bare URL like `https://sig.example.com` no longer produces
   the malformed `<host>/sign/<role>/sig.example.com`. The token
   validator (≥8 URL-safe chars) rejects malformed tails so the
   function falls back to returning the raw URL.

2. 10 unit tests pin the role-segment mapping so a future refactor
   can't silently break the contract with the marketing website's
   /sign/[type]/[token] page. Covers:
     - all five SignerRole → URL segment mappings
     - trailing-slash normalization on the host
     - null host fallback (single-tenant / staging)
     - rejection of non-token-shaped tails

3. docs/documenso-integration-audit.md updated with:
     - Phase 2/3/4/7 landed-work summary (replacing the old
       "deferred" list that was now stale)
     - Phase 5 coordination tracker for the marketing-website side
       (the four edits the website team needs to make — listed
       here so the CRM stays the source of truth on the contract)
     - Phase 6 polish backlog (auto-send delay, document expiration,
       per-document message, reminder display, failed-webhook UI,
       field metadata panel, zoom controls, recipient drag-reorder)

Tests: 21 new transformSigningUrl + signers tests across two files;
full suite 1340 → 1350 ; tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:11:50 +02:00
7bf587de90 feat(documenso-phase-4): recipient configurator + field placement UI
Phase 4 lands the visual half of the Documenso build — the upload-
for-signing dialog the Contract + Reservation tabs hand off to. Four
files of new code; the existing tab placeholders point at it.

Files added:
- lib/services/document-field-detector.ts — Phase 4c auto-detect
  scanner. Uses pdfjs-dist to extract per-page text + positions, then
  matches anchor patterns (Signature, Date, Initials, Email, Name,
  underscore-runs) and produces percent-coordinate DetectedField
  rows. Recipient label inference walks ±100pt of each match for
  Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the
  PDF is image-only; UI falls back to manual placement without an
  error. 6 unit tests pin the matching + coordinate math.

- app/api/v1/documents/auto-detect-fields/route.ts — multipart POST
  endpoint that delegates to detectFields(). Permission-gated by
  documents.send_for_signing.

- app/api/v1/documents/signing-defaults/route.ts — GET endpoint that
  surfaces just the per-port developer + approver display name/email
  + sendMode flag. No secrets exposed; lets the dialog prefill the
  recipient configurator without an admin-scoped settings read.

- components/documents/upload-for-signing-dialog.tsx — the Phase 4
  UI. Three-step state machine inside a single Dialog:
    1. select-file:  drop/click PDF picker + title input
    2. configure-recipients: client + developer + approver prefilled,
       rep can add/remove/reorder + change role (SIGNER/APPROVER/CC)
    3. place-fields: react-pdf renders the source PDF; auto-detect
       runs in the background on file load and seeds the overlay;
       rep places, drags, resizes, deletes, reassigns fields via the
       palette + side panel. Native DOM drag (no dnd-kit dependency
       added — the coordinate math stays obvious).
  Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3
  service); success toast reflects port sendMode (auto fires the
  invite immediately, manual leaves it for the rep).

Files modified:
- components/interests/interest-contract-tab.tsx + reservation-tab.tsx:
  swap the ComingSoonDialog placeholder for the real
  UploadForSigningDialog with the matching documentType prop. The
  placeholder ComingSoonDialog helper is deleted from both.

- scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp
  staged-only tsconfig so side-effect CSS imports (e.g.
  react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing
  declare-module shim. Without this fix the staged compile reports
  TS2882 even though the full tsc --noEmit pass passes.

Design choices noted in code comments:
- Native drag over dnd-kit: the field overlay's percent-based
  coordinate math is short enough that adding a drag library adds
  complexity without saving lines.
- Auto-detect on file-load (not on demand): runs immediately so the
  rep doesn't have to click a second button — empty result drops
  back to manual placement silently.
- Per-recipient color swatches indexed by signingOrder.
- Recipient seed via useMemo + user-event handler instead of
  useEffect → setRecipients (Wave 3 set-state-in-effect avoidance).

Server-side, Phase 3 plumbing handles the rest: tenant guard, magic-
byte verify, Documenso round-trip with per-port v1/v2 routing,
recipient signingToken capture for Phase 2 webhook cascade, auto-
send when port.sendMode === 'auto'.

Tests: 1334 → 1340  (6 new for the detector); tsc clean.

Deferred polish (Phase 6):
- Per-field metadata side panel for DROPDOWN/RADIO option lists
- Pinch-zoom + zoom-out controls on the field-placement canvas
- Recipient drag-reorder via dnd-kit
- Required toggle per field

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
33d0426911 feat(documenso-phase-3): custom document upload-to-Documenso
Backend foundation for the Contract + Reservation signing flows. The
existing tab placeholders point at a "send for signing" CTA that had
no code behind it; this commit lands the service + endpoint that the
Phase 4 drag-drop UI will POST to.

Files added:
- lib/services/custom-document-upload.service.ts — orchestrates the
  full PDF → Documenso → local-state-update flow:
    1. Magic-byte verifies the PDF (defense vs. mislabelled bytes —
       same posture as berth-pdf + brochures).
    2. Stores the source PDF via getStorageBackend(), works on s3 +
       filesystem backends. Auto-files into the client's entity folder
       when resolvable.
    3. Inserts the documents row (status=draft → sent), with the file
       FK + interest link + clientId snapshot.
    4. Documenso round-trip via createDocument → sendDocument →
       placeFields. Per-port apiVersion drives v1 vs v2 (existing
       client handles both — v1: /api/v1/documents; v2: envelope/create
       multipart). meta.signingOrder + redirectUrl flow through.
    5. Captures recipient signingUrl + token into document_signers so
       the Phase 2 cascade picks them up.
    6. Auto-send first invitation when port.eoi_send_mode === 'auto';
       stamps invitedAt to suppress duplicate cascades.
    7. Advances pipeline stage to contract_sent.

- app/api/v1/interests/[id]/upload-for-signing/route.ts — multipart
  POST endpoint. Zod-validates recipients (≤20), fields (≤200), PDF
  size (≤50MB), all 11 Documenso field types. Permission-gated by
  documents.send_for_signing + interests.edit (matches the
  external-eoi precedent — the auto-advance side-effect is
  interest-mutating).

Files modified: none — keeps the existing tab placeholders as the
entry point; Phase 4 builds the drag-drop UI on top.

Validation contract pinned by 8 unit tests covering: empty recipient
list, empty field list, empty/oversized PDF, non-PDF magic bytes,
out-of-range + negative recipientIndex, duplicate signingOrder.

The heavy paths (storage put, Documenso HTTP, signer update) are
exercised by the existing realapi Playwright project — no new
realapi specs added because the contract-upload UI doesn't exist yet
to drive them.

Verified against Documenso API spec (v1 OpenAPI + v2 docs via
Context7): recipients[].token is on the Recipient model in both
versions; webhook payloads echo the same shape so the Phase 2 token-
match handler works against custom-uploaded docs without changes.

Tests: 1326 → 1334 ; tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:52:21 +02:00
3dc4c6ff14 feat(documenso-phase-2): webhook handler enhancement — cascade + completion fan-out
Closes the silence after the first signing invitation. Three real
improvements on top of the existing webhook plumbing, all aligned with
the Documenso v1.32 + v2 webhook payload shape (verified against the
official OpenAPI spec + Context7 docs):

1. Cascading "your turn" emails — when DOCUMENT_SIGNED / DOCUMENT_
   RECIPIENT_COMPLETED / RECIPIENT_SIGNED fires for a recipient,
   handleRecipientSigned now resolves the next pending signer in
   signing order and sends them the branded sendSigningInvitation()
   email with the embedded-host-wrapped URL. Stamps invitedAt so a
   duplicate webhook retry doesn't re-send.

2. On-completion PDF distribution — handleDocumentCompleted now re-
   reads the just-committed signedFileId, resolves all signers, and
   fires sendSigningCompleted() to every recipient with the signed
   PDF attached. resolveAttachments in lib/email already pulls bytes
   through getStorageBackend() so this works under both the s3/minio
   and filesystem backends without changes. Failures fall through to
   logger.error rather than throwing — the document is already marked
   completed and the admin can re-trigger manually.

3. Token-based recipient matching — Documenso v1 + v2 webhook recipients
   carry a `token` field (per the OpenAPI spec); same token appears in
   the document-create response. Captured at send time into the existing
   document_signers.signing_token column (already in schema from Phase 1)
   and used by handleRecipientSigned + handleDocumentOpened before
   falling back to email match. Robust against the case where one email
   serves multiple roles on a contract — which is the documented gap in
   the legacy nocodb-based handler.

Supporting changes:
- New helper module lib/services/documenso-signers.ts with
  extractSigningToken() (URL-tail fallback), DOC_TYPE_LABEL map, and
  nextPendingSigner() picker. 11 unit tests cover the token-regex,
  the helper picks the lowest pending signing-order, and rejects
  declined/signed correctly.
- documenso-client normalizeDocument now reads `token` from both
  `recipients[]` and the legacy capital-R `Recipient[]` array Documenso
  v1.32 sometimes ships in webhooks.
- documents.service signer-update at send time prefers the explicit
  token field, falling back to extractSigningToken(signingUrl) for any
  v2 deployment whose distribute response omits it.

Out of scope for Phase 2 (per the build plan):
- Custom-doc upload-to-Documenso path (Phase 3)
- Recipient + field-placement UI (Phase 4)
- DNS-rebinding hardening + circuit-breaker (deferred-refactor list)
- Auto-reminder cron — manual "Send reminder" button + auto-reminder
  toggle remain manual until Phase 6 polish

Tests: 1315/1315 vitest  + 11 new tests for documenso-signers ;
tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:47:33 +02:00
b4e502fedd fix(audit-wave-11): BullMQ jobId plumbing for natural dedup
concurrency-auditor C-2: every queue.add(...) site previously enqueued
without a stable jobId, so a double-dispatch (webhook retry, double-
click on Send, scheduler tick collision) would create two queue jobs
and the downstream worker would deliver twice. BullMQ rejects a
duplicate jobId while the original is still queued or active, so a
stable per-entity key gives at-most-once semantics naturally.

Added jobIds across all 10 enqueue sites:

- email send-invoice → `send-invoice:<invoiceId>`
- notifications invoice-overdue-notify → keyed per UTC day so dupes
  collapse intra-day but tomorrow's run can re-notify if unpaid
- export gdpr-export → keyed on the exportId (unique per request)
- webhooks deliver (3 sites: dispatch, retry, test) → keyed on the
  webhook_deliveries row UUID
- maintenance expense-dedup-scan → keyed on expenseId
- notifications send-notification-email → keyed on notification id
- email send-inquiry-confirmation → keyed on interestId (1 per
  submission)
- email send-inquiry-sales-notification → keyed on interestId+email
  (1 per recipient per submission)
- reports generate-report → keyed on the generated_reports row id

Pure refactor — no UX impact. Closes the BullMQ dedup gap that was
the second half of the concurrency-auditor's CRITICAL-tier findings.

Test fixture update: gdpr-export integration test now asserts the
jobId option on the queue.add call.

Tests 1315/1315.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:02:38 +02:00