Commit Graph

919 Commits

Author SHA1 Message Date
f55be14813 test(berths): CM-2 — drop unused var in price-reconcile test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:53:24 +02:00
6bc81270b9 feat(interests): CM-2 Part B — deal-price override route + UI on linked berths
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:48:38 +02:00
38e392e38b feat(interests): CM-2 Part B — EOI/doc generation honours berth price override
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:41:42 +02:00
039ef25fe5 feat(interests): CM-2 Part B — interest_berths price override (data + resolver)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:40:17 +02:00
b3753b96a1 feat(berths): CM-2 — bulk price-reconcile admin page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:38:08 +02:00
9147f2857e feat(berths): CM-2 — price-reconcile API (list + bulk apply)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:35:09 +02:00
47778796ad feat(berths): CM-2 — bulk price-reconcile service (parse + apply)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:33:40 +02:00
f7425d1231 fix(berths): CM-2 — robust purchase-price extraction (clean-token + magnitude floor)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:30:12 +02:00
df8c26d1b3 feat(proxies): CM-9 UI — ProxyCard on client, interest, and yacht detail pages
- shared ProxyCard (view/add/edit/remove point-of-contact) reading each entity's
  /[id]/proxy sub-resource; permission-gated on the entity's edit right
- wired into the client overview, interest overview, and yacht overview tabs

Completes CM-9. tsc clean, lint 0 errors, prod build green, 1638 vitest pass.
Comms send-side wiring (route EOIs/emails through resolveEffectiveProxy) is a
deliberate follow-up — the resolver + data are ready for it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:01:08 +02:00
91703bdb00 feat(proxies): CM-9 backend — polymorphic point-of-contact + resolver
- proxies table (migration 0095, port_id cascade), one per client/interest/yacht
- service: get/set(upsert)/clear + resolveEffectiveProxy (yacht → interest →
  client precedence), port-scoped with entity-in-port guard
- per-entity sub-resource routes (/clients|interests|yachts/[id]/proxy) reusing
  each entity's existing view/edit permission (no new permission resource)
- 3 integration tests (CRUD/upsert, tenant guard, resolution precedence)

Backend only — ProxyCard UI on the 3 detail pages to follow. tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:54:47 +02:00
3165ec651f feat(client-groups): CM-1 API routes + UI (list, member viewer, copy-emails)
- /api/v1/client-groups (list/create), /[id] (get/patch/delete),
  /[id]/members (get/set) — route.ts + handlers.ts split, client_groups perms
- Client Groups list page (grid + create dialog) and detail page
  (member viewer, per-row copy email, "Copy all emails" → To:-bar format,
  manage-members picker over /api/v1/clients)
- Sidebar nav entry (UsersRound icon)

tsc clean, lint 0 errors, prod build green. Completes CM-1 (Mailchimp push
still deferred until client creds/account).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:49:29 +02:00
661187cc79 feat(client-groups): CM-1 data layer — groups entity, membership, service, Mailchimp scaffold
- client_groups + client_group_members tables (migration 0094, port_id cascade)
- client_groups permission resource (view/manage) in catalog + role backfill
- service: CRUD + wipe-and-rewrite membership + member email resolution
- mailchimp.service scaffold: config reader + inert one-way sync (mapping
  deferred until the client's MC account is wired, per CM-1 decision)
- 4 integration tests (CRUD, membership, email resolution, port-scope guard)

Backend only — API routes + UI to follow. tsc clean, 1635 vitest pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:28:20 +02:00
4dc0bdd8c4 feat(crm): client-meeting batch — contact-pill cleanup, assignment toggle, receipt manual mode
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m51s
Build & Push Docker Images / build-and-push (push) Successful in 9m16s
CM-4: remove Email/Call/WhatsApp deep-link pills from the client + interest
  detail headers; relocate GDPR export into the client-header action cluster
  as a compact icon. Keeps the interest "Log contact" quick action.
CM-5: gate the interest assignment feature behind a per-port `assignment_enabled`
  setting (default OFF for single-rep ports). Hides the AssignedToChip +
  residential assigned-to row and skips tier-2/3 auto-assign on create; the
  column + data are preserved and reversible. Tests cover the auto-assign guard.
CM-6: add a per-port `manualEntry` receipt mode (skip all parsing → empty form).
  Threaded through ocr-config.service, the admin OCR form, the scan-receipt
  route, and the scanner shell (skips Tesseract + the server call). Tests cover
  the save/resolve round-trip.

Verified: tsc clean, lint 0 errors, 1631 vitest pass, prod build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:42:36 +02:00
7f04c765f4 fix(crm): inquiry detail polish, EOI preview mime, EOI next-step, documenso v1 banner
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m51s
Build & Push Docker Images / build-and-push (push) Successful in 7m43s
- inquiries: format triage badges with labels (Open/Assigned/Converted/Dismissed),
  surface the lead's free-text message for every kind, and gate the raw-payload
  tab to super admins (was exposing raw JSON to all users)
- file preview: fall back to the server-resolved mime (getPreviewUrl already
  returns it) so files whose stored name lacks a .pdf extension — e.g.
  migration-backfilled signed EOIs — render instead of "preview not supported"
- interest overview: a signed EOI left at stage=eoi no longer shows as
  "NEXT STEP"; completion ordering rolls the next step to Reservation (display
  only, no pipeline_stage change)
- documenso admin: warning banner discouraging the deprecated v1 API + what
  breaks on it

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:36:35 +02:00
4d018be800 feat(inquiries): one-off NocoDB historical contact-form import (idempotent, dry-run default)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m1s
Build & Push Docker Images / build-and-push (push) Successful in 8m22s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:28:01 +02:00
95d7776bb6 test(inquiries): drop unused import 2026-06-17 18:25:13 +02:00
0cc05f302f feat(inquiries): top-level Inquiries page (list + detail + convert), nav entries; retire admin inbox
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:23:13 +02:00
54554a0928 feat(inquiries): list/get/triage/convert service + API routes (find-or-create client)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:09:59 +02:00
9879b82e5f feat(inquiries): website_submissions tracking + display columns; capture populates contact name/email
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:03:47 +02:00
08adb4aeea feat(permissions): add inquiries resource (view/manage) + idempotent role backfill
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 17:59:32 +02:00
6c4490f653 feat(alerts): always-visible dismiss/ack actions + Dismiss all (service, endpoint, UI)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 17:53:12 +02:00
13efe177a5 feat(alerts): split interest.stale into worked-then-quiet + new-untouched (interest.no_activity)
- interest.stale now fires only for interests with real in-system follow-up
  (contact log / note / update audit) that went quiet 14+ days.
- new interest.no_activity rule covers never-touched, non-imported interests.
- guard interest.high_value_silent against imported-untouched hot leads.
- keys off migration_source_links ledger to identify the bulk import, so the
  imported backlog matches neither rule and the engine auto-resolves the flood.
- test teardown: delete interest_contact_log + test migration ledger rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 17:49:22 +02:00
7591231c47 test(e2e): add Initiative 4 end-to-end + integration specs
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m12s
Build & Push Docker Images / build-and-push (push) Successful in 8m24s
Sales-process coverage (launch-readiness Initiative 4):
- exhaustive: full 7-stage sales journey + illegal-skip rejection + deposit
  total + tenancy/berth-sold; multi-berth EOI berth-range; EOI pathway parity
  (in-app vs Documenso, shared EoiContext); mobile-viewport journey.
- realapi (Documenso-gated, opt-in): generate-and-sign + post-EOI stages.
- integration: Documenso DOCUMENT_COMPLETED webhook idempotency (3x replay ->
  single file/audit write); storage backend swap (s3 <-> filesystem) with a
  real on-disk filesystem round-trip.
- visual: Reports UI snapshot cases (baselines captured separately).

1615 unit/integration pass; tsc + lint clean. Test-only change (specs are not
bundled into the app image) - no app behavior modified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:10:35 +02:00
2e8c4b43bf fix(backup): install pg_dump (postgresql16-client) in app + worker images
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m52s
Build & Push Docker Images / build-and-push (push) Successful in 8m54s
The DR backup engine spawns `pg_dump` (backup.service.ts), but neither
runner image installed a postgres client — so producing a bundle fails
in prod with ENOENT (only worked in dev, where the host has pg_dump).
Surfaced by testing the feature on the live prod container.

Add `postgresql16-client` (pg_dump 16.x, matched to the postgres:16
server) to the runner stage of Dockerfile (crm-app: on-demand export +
"back up now") and Dockerfile.worker (scheduled backup-push cron).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:58:14 +02:00
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
05950ae0b6 feat(uat): file preview/download fix, clients-by-country page, residential column picker
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m42s
Build & Push Docker Images / build-and-push (push) Successful in 7m20s
Batch #4 UAT items.

1. Documents — clicking any file dumped raw presigned-URL JSON. Was
   systemic: 6 surfaces linked a browser directly at the JSON-returning
   /files/[id]/{download,preview} routes. Those routes now 302-redirect
   when called with ?redirect=1 (default stays JSON for the dialog +
   interest-eoi-tab programmatic consumers); the six <Link> sites use it.
   The documents-hub file row now opens the inline FilePreviewDialog +
   has a per-row Download button, and the preview dialog header gained a
   persistent Download button for all file types.

2. Clients-by-country — the widget's "+N more" dead text is now a
   "Show all" link to a new /clients/by-country page rendering the full
   ranked country breakdown (each row drills into the filtered list).

3. Residential clients list — moved off its bespoke table onto the
   shared DataTable + ColumnPicker (same UX as clients/interests). Adds
   a "Date added" column, default-hides the empty "Residence" column,
   preserves the mobile card view, persists per-user column choices.

tsc clean, eslint clean, 1584/1584 vitest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:34:47 +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
1750e265e7 feat(berths): inline spec-PDF preview, manual-pin badge, maintenance module toggle, under-offer popover
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m45s
Build & Push Docker Images / build-and-push (push) Successful in 8m11s
Post-cutover UAT batch #3:
- #62 Spec tab renders the current berth spec PDF inline (lazy PdfViewer,
  toggleable, default-open) + explicit download. Interest Documents tab
  already previews/downloads linked deal docs inline (verified).
- #57 Surface berths.status_override_mode through the interest-berths API;
  linked-berth rows show an amber "Pin overrides pitch" badge + corrected
  consequence copy when a berth is specifically-pitched but manually pinned
  (the soft-pin wins on the public map).
- #63 New maintenance-module gate (maintenance_module_enabled, default on):
  registry + admin Settings toggle, maintenance-module.service, port-provider
  useMaintenanceModuleEnabled, layout wiring, buildBerthTabs hides the
  Maintenance tab when off, and both maintenance log routes assert the gate.
- #66 BerthOccupancyChip: >1 competing interest opens a popover listing every
  deal (name + stage + in-EOI/primary + link); single stays a direct link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:15:04 +02:00
2a7f922a01 fix(uat): dashboard snapshots current-state, pulse-chip gate, phone display, chip width
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m54s
Build & Push Docker Images / build-and-push (push) Successful in 8m10s
- pipeline funnel: count active interests by current stage (drop created_at
  window) — backfill had collapsed it to early stages (UAT 2026-06-03)
- pipeline value tile: render current-state (don't thread the date range)
- deal pulse chip: gate on the pulse_enabled master toggle (default ON) —
  was rendering even when admin turned it off; useFeatureFlag gains a
  default arg + the feature-flag endpoint a ?default= param (default-ON safe)
- contact phone display: show international format + country flag (E164),
  not the bare national format that hid the country
- berths: remove the dead row-density toggle; widen "Under offer to" chip on
  desktop so client names aren't truncated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:27:56 +02:00
39c19b2340 feat(berths): click-to-change status from the list (chip → reason modal)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m11s
Build & Push Docker Images / build-and-push (push) Successful in 13m12s
Adds BerthStatusQuickEdit — wraps the status chip on the berths list (card +
table) in a click target that opens a compact change-status dialog: status
dropdown + required reason (quick-pick chips) + optional interest link when
moving to under_offer/sold. Reuses the existing PATCH /api/v1/berths/[id]/status
endpoint + validator + audit (same capability the detail page already had).
Gated by berths.edit (non-editors see a plain chip); stops click propagation
so it doesn't also navigate into the berth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:01:40 +02:00
d1f6d6a427 feat(eoi): signed-EOI hero + send-signed-copy; fix search dropdown z-order
- EOI tab: when an EOI is already signed and none is in flight, lead with a
  SignedEoiCard (preview + download + send-to-client) instead of the big
  "Generate EOI" empty state; quiet "Generate new EOI" remains for re-issue
- history rows + hero gain a "Send to client" action — POST
  /api/v1/documents/[id]/send-signed-copy emails the deal's client the
  finalized signed PDF (sendSignedCopyToClient reuses sendSigningCompleted),
  guarded by a confirm
- topbar: header gets z-30 so the global search dropdown paints above page
  content (charts/tables were bleeding through — header + main are sibling
  normal-flow boxes, so the dropdown's own z-50 couldn't win cross-context).
  Stays below the z-50 modal tier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:55:28 +02:00
3b227fe9b2 feat(files): in-app .docx preview + allow office/text mimes
- .docx now renders client-side via docx-preview (fetches bytes from our
  own storage; works with private MinIO/disk). Drops Microsoft's hosted
  Office viewer which can't reach a private object store.
- add office (.docx/.doc/.xlsx/.xls) + text/csv to PREVIEWABLE_MIMES so
  /api/v1/files/[id]/preview returns a URL instead of rejecting them
  (was surfacing as a misleading "Failed to load preview")
- legacy .doc + spreadsheets fall through to a download CTA (can't render
  client-side); text/csv use the existing TextPreview

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:45:11 +02:00
95724c8e3a fix(uat): prod UAT batch — reports, sidebar, search, berths, breakpoint
- financial report: drop Expenses KPI, Net Contribution, cash-flow chart,
  expense donut + ledger (expenses are business-trip costs, not net contribution)
- dashboard report PDF: pagination-safe tables (TableSection + per-row wrap)
  so long doc lists no longer overlap/crush
- clients PDF report: rename "Nationality" -> "Country"
- sidebar: hide a section header when all its items gate off (FINANCIAL orphan)
- topbar: move global search into the 1fr grid track so it can't overlap "New"
- clients card: show all linked berths (not just latest interest's primary)
- berths list: hide table-only toggles (ft/m, density, columns) in card mode
- lists: lower table/card breakpoint lg -> md so narrow desktops get tables
- alert-rules: stale floor created_at -> updated_at (survives created_at backfill)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:41:31 +02:00
93c6554c95 fix(ui+alerts+email): prod walkthrough batch
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m14s
Build & Push Docker Images / build-and-push (push) Successful in 9m10s
- proxy/banner: interest-berth-status-banner used the interest_berths
  junction id for /api/v1/berths/{id}/active-interests (404 on every
  interest with a sold/under-offer berth). Add berthId to BerthRow and use
  it for both the active-interests query and the BerthOccupancyChip.
- scroll-area: override Radix viewport `display:table` (`[&>div]:!block`) so
  content respects the viewport width — fixes notification alert cards
  overflowing past the popover. No horizontal-scroll ScrollArea in the app.
- alert-card: drop the raw `interest.stale` rule key from the footer
  (plaintext only; the title already conveys the alert).
- alert-rules (interest.stale): add a createdAt >14d floor so a bulk import
  that backdates dateLastContact doesn't instantly flag every migrated
  interest as stale and flood the alert rail. 14-day clock starts no earlier
  than when the interest entered this system.
- env: allow EMAIL_REDIRECT_TO in production behind an explicit
  ALLOW_PROD_EMAIL_REDIRECT=true opt-in (beta: route all outbound mail to
  the operator inbox; default still refuses the footgun).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:09:16 +02:00
72028a7f32 fix(proxy): trust forwarded Host header for CSRF origin check
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m53s
Build & Push Docker Images / build-and-push (push) Successful in 7m22s
The previous attempt compared the Origin host against request.nextUrl.host,
but behind the custom-server + reverse-proxy setup nextUrl.host does NOT
resolve to the public host (mutations stayed 403 in prod). Accept the
Origin/Referer host if it matches ANY of: the forwarded Host header
(nginx sets `proxy_set_header Host $host` → crm.portnimara.com), APP_URL's
host, or nextUrl.host. The Host header is the reliable source here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 04:10:57 +02:00
d485695357 fix: CSRF host-compare behind proxy + default port = creation order
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m1s
Build & Push Docker Images / build-and-push (push) Successful in 7m30s
Two prod-only breakages found after go-live:

1. CSRF guard rejected EVERY /api/v1 mutation ("Cross-origin state-changing
   request rejected", 403) — making the CRM read-only. It compared the
   browser Origin (https://crm.portnimara.com) against request.nextUrl.origin,
   but TLS terminates at nginx so the app sees http://127.0.0.1 → protocol
   mismatch. Compare hosts instead (Host header survives the proxy; a
   cross-site attacker can't forge the browser-set Origin host).

2. Post-login landed on port-amador (empty tenant), not port-nimara. Three
   queries ordered ports by name (alphabetical → Amador first): the bare
   /dashboard redirect (app/dashboard/page.tsx), the dashboard layout's
   defaultPortId, and /api/v1/me/ports. Order by createdAt so the primary
   (first-seeded) port — Port Nimara — leads, matching listPorts().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 03:38:12 +02:00
23a5811342 fix(proxy): accept the __Secure- prefixed session cookie in production
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m35s
Build & Push Docker Images / build-and-push (push) Successful in 13m38s
The auth gate read only `pn-crm.session_token`, but better-auth prefixes
the cookie `__Secure-pn-crm.session_token` whenever it issues secure
cookies (production/HTTPS). So in prod every authenticated request was
bounced to /login — sign-in returned 200 + Set-Cookie, but the gate
couldn't see the (prefixed) cookie on the next navigation. Worked in dev
(HTTP → no prefix). Check both names.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 03:13:22 +02:00
102ee493f8 fix(ports): list ports in creation order so the primary port leads
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m43s
Build & Push Docker Images / build-and-push (push) Successful in 7m31s
listPorts() ordered alphabetically, putting "Port Amador" ahead of
"Port Nimara" in the switcher and as the default a super-admin lands on.
Order by creation instead (oldest first) so the first-seeded port — the
primary tenant, Port Nimara — leads. Name is a tiebreaker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:51:12 +02:00
c70eb1f945 fix(docker): merge prod deps into standalone node_modules (not replace)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m44s
Build & Push Docker Images / build-and-push (push) Successful in 6m53s
Replacing the Next standalone node_modules broke turbopack's externalized-
module resolution: the standalone tree is a matched set with the turbopack
server chunks, resolving externals (better-auth, postgres, pino, minio, ...)
by hashed id. With it replaced, every route using them 500'd with
"Failed to load external module <pkg>-<hash>" — confirmed on prod, while
`node .next/standalone/server.js` with the intact tree serves GET / (307)
and /api/health (200) cleanly.

So keep the standalone tree intact and MERGE the complete hoisted prod tree
in with `rsync --ignore-existing`: it adds the custom server's missing CJS
requires (socket.io closure: accepts/ws/engine.io/cors; drizzle-orm/index.cjs)
and skips everything the trace already provides — and tolerates the trace's
pnpm symlinks, where COPY/cp/tar/fs.cpSync all error on symlink-vs-dir.

Validated end-to-end on a host assembly of (intact standalone + merged prod
deps + the polyfilled server bundle): GET / → 307, /api/health → 200, zero
"Failed to load external module", zero MODULE_NOT_FOUND, server listening.
rsync --ignore-existing merge semantics verified in node:20-alpine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:31:33 +02:00
42baaf7bfc fix(docker): complete prod node_modules for the custom server
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m49s
Build & Push Docker Images / build-and-push (push) Successful in 11m22s
Follow-up to the NODE_PATH attempt, which fixed 'accepts' but not the
general case: server-custom.js is CJS (esbuild --packages=external) and
require()s deps the Next standalone trace ships ESM-only or omits, e.g.
drizzle-orm/index.cjs (present-but-incomplete in the traced tree, so a
NODE_PATH fallback can't rescue it). Replace the traced node_modules with
the complete hoisted prod tree so every external resolves.

That tree is prod-only, so move @next/bundle-analyzer (required at runtime
by next.config — its import is unconditional even though enabled is gated
on ANALYZE) from devDependencies to dependencies; otherwise the standalone
config load throws MODULE_NOT_FOUND in prod.

Validated end-to-end on a host prod install + standalone assembly: socket
server boots, Socket.io initializes, HTTP listens, /api/health → 200, no
MODULE_NOT_FOUND, no AsyncLocalStorage invariant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:03:11 +02:00
319fd7fd1a fix(server): resolve socket.io deps via NODE_PATH + polyfill AsyncLocalStorage
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m43s
Build & Push Docker Images / build-and-push (push) Successful in 7m31s
Two runtime defects in the crm-app prod image (never exercised before this
deploy; CI only builds + pushes):

1. Replacing the standalone node_modules wholesale to add socket.io's deps
   swapped out Next's standalone-tuned `next` and broke its runtime
   ("Invariant: AsyncLocalStorage accessed in runtime where it is not
   available"). Instead, stage the complete hoisted prod tree in a separate
   dir on NODE_PATH: the standalone node_modules (and its `next`) stay
   intact, and only the socket server's otherwise-missing deps
   (engine.io→accepts/ws/cors, @socket.io/redis-adapter) fall through to it.

2. Defensively set globalThis.AsyncLocalStorage before Next's app-render
   modules load, via a preamble that is the first import in server.ts.
   Next's node-environment-baseline normally sets it during the standalone
   bootstrap, but the custom server can load app-render storage first.
   Verified in the esbuild bundle that the assignment runs before
   require("next").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:34:28 +02:00
2315b58764 fix(docker): bundle socket.io transitive deps into crm-app runner
Some checks failed
Build & Push Docker Images / lint (push) Successful in 2m51s
Build & Push Docker Images / build-and-push (push) Failing after 5m16s
The crm-app image cherry-copied only socket.io + @socket.io into the
runner's node_modules, omitting their transitive closure (engine.io →
accepts/ws/cors, ...). server-custom.js is built with esbuild
--packages=external, so it require()s those at runtime and crashed with
MODULE_NOT_FOUND 'accepts' on first boot. CI reported success because it
only builds+pushes — the image runtime was never exercised.

Add a hoisted (symlink-free) prod-deps stage and overlay the complete
prod dependency tree onto the traced standalone subset. Hoisted layout
makes the Docker COPY faithful (pnpm's default symlinked layout
dereferences and breaks resolution). Validated locally: accepts,
engine.io, socket.io, @socket.io/redis-adapter, ws, cors all resolve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:03:57 +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
04ddd59662 chore(repo): untrack internal docs + CLAUDE.md (keep local-only)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m53s
Build & Push Docker Images / build-and-push (push) Successful in 6m32s
Per Matt: internal planning/audit/deployment docs + CLAUDE.md don't belong in the shared Gitea repo. git rm --cached (files kept in the working tree) + gitignored docs/ and CLAUDE.md. Tests kept. No history rewrite - what was exposed is infra topology (IP/SSH), not credentials (actual secrets were always in gitignored private/). Fresh repo-appropriate docs to follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:02:45 +02:00
2a4dadd5a7 docs(launch): execute-ready initial-deployment runbook
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m55s
Build & Push Docker Images / build-and-push (push) Successful in 8m51s
Locked decisions (Postgres=own, deploy dir /root/docker-compose/pn-crm, DB/Redis localhost-only), prerequisites checklist, ordered gated phases (recon -> CRM -> data -> Documenso -> website cutover), rollback anchors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:50:42 +02:00
44b004fa8f feat(intake): recipient picker UI (users/roles/everyone/emails)
Adds RecipientPicker (multi-select users + roles, everyone-with-inquiry-access toggle, free-text emails) and a new 'recipients' settings field type. The inquiry + residential notification-recipient settings now render the picker instead of a raw JSON textarea, persisting the structured {emails,userIds,roleIds,everyone} config the server resolver expands. tsc clean; full vitest suite (1570) green. Live browser verification of the picker pending a dev server (env currently on the prod build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:36:24 +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
0416dc8d39 docs(launch): website-integration env vars + cutover sequence
deployment-plan.md gains a full env-var reference (CRM + website) and the cutover env-flip sequence; launch-readiness.md gets the 2026-06-02 closeout; BACKLOG.md adds the deferred integration-health-panel idea (section L).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:22:12 +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
f699533224 Merge feat/residential-toggle-and-reports-comparison into main
Some checks failed
Build & Push Docker Images / lint (push) Successful in 2m59s
Build & Push Docker Images / build-and-push (push) Failing after 5m43s
Reports overhaul (residential toggle, sales comparison + filters, financial
report, importer, migration scripts, reports polish, marketing 404 gate) +
pre-launch codebase/security audit with full remediation (85 findings: 4
CRITICAL / 17 HIGH / 29 MEDIUM / 35 LOW; 84 fixed, L21 false-positive) +
custom-report build-blocker fix.

Validation: 1103 unit + 458 integration tests green; tsc clean; production
build green. E2e smoke deferred to CI (needs the standalone server).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:08:31 +02:00