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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>