Commit Graph

888 Commits

Author SHA1 Message Date
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
79b6ab2ae0 fix(build): split custom-report registry into client-safe metadata + server query module
The custom-report builder (client component) imported the registry which pulls
in @/lib/db (postgres -> tls), breaking the production build. Extract
ENTITY_META/ENTITY_KEYS/column defs into registry-meta.ts (no DB imports);
registry.ts keeps runQuery + composes ENTITY_REGISTRY. Pre-existing blocker
surfaced during pre-merge build validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:28:51 +02:00
cd82958307 docs(launch): Initiative 2 (codebase + security audit) COMPLETE — 85 findings remediated
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:32:04 +02:00
478aba1866 docs(audit): remediation complete — 84/85 fixed, L21 false-positive; M23/M25 DB migrations deferred
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:31:34 +02:00
8c4c9b967e fix(audit): UI — L18 (decorative emoji -> Lucide icons), L19 (gated NotesList timer + create-from-url ref-in-effect)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:30:25 +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
7b74e2314b fix(audit): M24 — reserve 'branding'/'avatar' file categories from the upload/update API
The public file-stream gate keys off files.category==='branding'; the API
upload/update schemas now reject the reserved categories so a user can't
self-set branding to publicly expose their own file. System writers (admin
image, avatar) set them via the service directly and are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:18:24 +02:00
fd69a75980 fix(audit): bounce/email — M8 (Message-ID port-safe bounce match), L16 (recipient validation, CRLF, header trust note)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:18:20 +02:00
cc5c053a79 fix(audit): reports workers — M9 (no duplicate scheduled emails), L5 (idempotent render artefacts), L6 (atomic schedule claim), L7 (per-port notification From)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:07:30 +02:00
64c73a5d77 fix(audit): rate-limit/DoS — M13 (bulk limiter on 6 routes), M14 (api limiter default in withAuth, fail-open), M15 (export-pdf payload bounds); L21 verified not-a-bug
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:07:25 +02:00
ebe5fe6ed8 fix(audit): GDPR/merge — M6 (drop false merge-reversibility claims), M7 (GDPR export adds 4 PII tables), L14 (docstring), L15 (hard-delete breadcrumb note)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:07:21 +02:00
aedbcfd58d fix(audit): AI — L8 (single recordAiUsage), L9 (budget-off warning), L10 (sanitize notes/subjects into prompt)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:59:16 +02:00
70bf26aea1 fix(audit): berth rules/recommender — M4 (bundle-wide status), M5 (berth_unlinked target), M20/L27 (interest_berths invariant + cross-port guard), L3 (recommender stage-scale), L4 (dead branch)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:59:12 +02:00
4084029962 fix(audit): documenso — M2 (reservation EOI-milestone pollution), L11 (v2 numericId GET fallback), L12 (API URL normalize/validate), L13 (event dedup)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:59:07 +02:00
37ffb2c3b4 fix(audit): financial — M19 (group-by-currency accumulation, full-precision rates), M23 (invoice money rounding + 0% discount), L25 (no silent unconverted/stale FX), L26 (companyNotes updatedAt)
M23 numeric(12,2) schema precision deferred to a migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:52:28 +02:00
49f5c3165b fix(audit): interests/pipeline — M1 (outcome terminal guard), M3 (single-UPDATE + milestone gating), L1 (dead 'completed'), L2 (nurturing edge), L24 (deposit re-lock on refund)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:52:24 +02:00
0ed4323826 fix(audit): socket cluster — M10 (isActive gate), M11 (permission-scoped entity rooms), L20 (join:entity validation)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:52:20 +02:00
25988dbfad fix(audit): import cluster — M27 (commit idempotency), M25 (in-file dedup preview), M26 (undo destructive-update reporting), L33 (mapping/mooring), L35 (port-auth doc)
M25 DB unique-index backstop deferred: needs a migration (column + backfill +
insert-stamp trigger + dedup) — tracked as a follow-up. The classify in-file
dedup (preview accuracy) ships now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:41:00 +02:00
9305c030de fix(audit): storage cluster — M16 (presign doc/contract), M17 (per-port byte cap), M18 (replay-after-stat), L17 (mime allow-list, fingerprint hash), L22 (brochure portSlug)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:40:56 +02:00
65ed90b603 fix(audit): webhook cluster — M21 (test-send isActive), M22 (cross-tenant dead-letter), L28 (ipv6 SSRF), L29 (rebind doc), L30 (replay event-time)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:40:41 +02:00
29fb882478 fix(audit): H15 (saved-view sort) + H14 (back/forward URL resync) in usePaginatedQuery
H15: new applyView({filters,sort}) atomic mutator (one URL write) restores a
saved view's sort, threaded through all six list components instead of being
discarded. H14: a guarded effect resyncs page/sort/filters FROM the URL on
Back/Forward; the resync setStates carry a scoped, justified
set-state-in-effect disable (loop-guarded external-URL sync).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:26:10 +02:00
808e80744b fix(audit): H12 — consistent refund sign so refunds never inflate revenue
createPayment/updatePayment now store refunds as a negative magnitude, and
every financial reader (sumPaymentsInRange, getRevenueByMonth, getCashFlow)
subtracts refund magnitude regardless of stored sign — fixing both new rows
and legacy positive-stored refunds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:24:51 +02:00
77829485a7 fix(audit): H5 — keep yacht ownership-history ledger consistent on archive/restore
Extracts transferOwnershipTx (close open yacht_ownership_history row + open
a new one + update denormalized owner) from transferOwnership, and uses it in
client-archive + client-restore instead of writing only the denormalized
columns — which left the ledger showing the old owner as current and let the
next real transfer close the wrong row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:24:46 +02:00
1882bcb2e4 fix(audit): H11 — gate cross-port coverBrandPortId in report runs
Layer 1: createReportRun rejects a user-triggered run whose coverBrandPortId
is a port the triggering user can't access (userCanAccessPort: super-admin or
userPortRoles membership). Layer 2: renderReportRun only honors the override
when it equals run.portId or the run's user is a member, else falls back to
the source port's branding — so a forged/scheduled config can't leak another
tenant's logo/name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:18:11 +02:00
a335dbc117 fix(audit): H10 — neutralize CSV formula injection in expense + audit exports
Adds sanitizeCsvCell() (prefixes a quote when a cell starts with = + - @
tab/CR) and applies it to the audit-export escape() and the user-controlled
free-text columns of the expense export before Papa.unparse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:18:07 +02:00
4489ad2431 fix(audit): H9 — rate-limit AI routes + budget-gate email-draft token spend
Applies withRateLimit('ai') to all three AI routes (mirroring scan-receipt)
and adds a checkBudget gate before the OpenAI call in generateEmailDraft,
falling back to the template draft when the per-port budget is exhausted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:18:03 +02:00
b51d6d3030 fix(audit): H4 (reservation signing berth rule) + H13 (manual EOI-sign stage parity)
H4: reservation_agreement completion fired the contract_signed berth rule,
flipping the berth to 'sold' one-to-two stages early. Add a dedicated
reservation_signed berth trigger (defaults to under_offer) and fire it.
H13: the manual signed-EOI upload path advanced only to 'eoi' via the
ungated helper while the Documenso-webhook path advanced to 'reservation';
both now use advanceStageIfBehindGated(..., 'reservation', 'eoi_signed') so
manually- and webhook-signed deals reach the same stage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:12:02 +02:00
865ae5c072 fix(audit): H2/H3 — client merge re-points payments, memberships, yacht & invoice ownership
Merge now re-points the loser's payments, company memberships (deduped
against unique_cm_exact), polymorphic yacht ownership, and polymorphic
invoice billing-entity to the winner inside the same transaction, before
archiving the loser. H2: the winner no longer silently loses those rows.
H3: because payments (notNull onDelete:cascade) are moved off the loser, a
later hard-delete of the archived loser can no longer cascade-delete the
winner's financial history. Counts wired into the merge result + audit row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:09:49 +02:00
7a7fd76081 fix(audit): H8 (residentialAccess caller-superset) + M12 (self-target guard) in updateUser
H8: enabling the residentialAccess flag grants the full residential CRUD
set, so a non-super-admin caller must now hold those leaves themselves to
grant it — closes the escalation back door around the role-superset check.
M12: an admin can no longer change their OWN isActive / roleId /
residentialAccess (self-lockout / self-escalation), mirroring the
permission-override route's self-target block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:06:06 +02:00
f4fb7aae84 fix(audit): H1 (webhook redirect SSRF), H6 (berth-status case), H7 (residential notes URL)
H1: webhook delivery fetch now uses redirect:'manual' and refuses to read
or expose a redirected (un-revalidated) response, closing the SSRF read
primitive. H6: dashboard report queries matched title-case 'Sold'/'Under
offer' that never match the lowercase canonical, silently reporting 0 sold
/ understated occupancy — now lowercase. H7: NotesList maps the entityType
discriminator to its REST path (residential_* -> residential/clients|
interests) instead of interpolating the raw underscore, which 404'd every
residential notes request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:03:35 +02:00
3c9310f81c fix(audit): critical C3 — enforce residential module gate on all v1 API routes
Adds assertResidentialModuleEnabled(ctx.portId) as the first statement in
every residential v1 handler (24 handlers across 13 files), mirroring the
Tenancies pattern. Previously the disabled-module state was enforced only
in the page layout, so a disabled module still accepted API writes
(including partner-forward emails on residential interest creation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:59:52 +02:00
7aa639f195 fix(audit): criticals C1 (currency-scoped deposit gate), C2 (outcome-aware berth rule), C4 (/q/ allowlist)
C1: getDepositTotalForInterest now filters to the interest's
depositExpectedCurrency for the auto-advance gate, so a wrong-currency
payment can no longer satisfy the deposit expectation (and mark the berth
Sold). C2: setInterestOutcome fires interest_completed only for 'won';
lost/cancelled fire a new 'deal_lost' rule that frees the berth instead of
flipping it to 'sold'. C4: add '/q/' to proxy PUBLIC_PATHS so tracked
links in outbound mail reach external recipients.

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