refactor(data-model): yacht + company + reservation refactor (final merge) #1
Reference in New Issue
Block a user
Delete Branch "refactor/data-model"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
15-PR data-model refactor (Spec 1) consolidated into a single feature branch.
0008_loud_ikaris.sqlincludedTest plan
pnpm db:pushto apply migration0008on the target databaseassets/eoi-template.pdf(seeassets/README.mdfor required AcroForm fields)pnpm vitest run— 652+ unit/integration tests, all green locallypnpm test:e2e:smokeagainst a freshly seeded dev instancepnpm test:e2e:exhaustive— verifies every refactored page is clickable without console/network errorsclient.companyName/yachtNamereferences are gone —rg "yachtName|companyName" src/🤖 Generated with Claude Code
Failures were mostly stale selectors, not product regressions: - .or() traps matching the topbar "+ New" button → use specific names (Add Webhook, New Field, New Template) - broad /create|add|new/ patterns → same fix - [role="dialog"] overlay matched before content → getByRole('dialog').last() - locator('input') picked hidden Radix Select inputs → getByPlaceholder / getByRole('combobox', { name }) - 11-global-search rewritten for the inline topbar search (the cmdk CommandDialog the old tests targeted was replaced) - missing .first() causing strict-mode failures on notifications heading, version history text, nav links - dashboard landing test: no h1 exists, target KPI text instead - activity-feed: items aren't anchors; match action badge text - monitoring data-leak check scoped to <main> (sidebar has Email/Documents) - admin API without port context returns 400 (not 403) for non-admins — accept 400 as a valid "blocked" status in the sales-agent test Also dropped dead imports and unused locals surfaced by lint-staged. Full suite: 124 passed (11.2m). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Adds the berth_reservations service covering the full lifecycle (pending -> active -> ended/cancelled) with tenant scoping, DB-enforced exclusivity on the idx_br_active partial unique index, and client-or-company-member cross-checks for yacht ownership. - validators: createPending / activate / end / cancel / list schemas - service: createPending, activate, endReservation, cancel, getById, listReservations — with narrow 23505/idx_br_active catch that re-queries the conflicting active reservation - socket events: berth_reservation:{created,activated,ended,cancelled} - tests: unit (lifecycle, tenant, membership cross-check), integration (concurrent-activate ConflictError + re-activate after end)Replaces the Task 5.3 stub with a real YachtTransferDialog backed by OwnerPicker, a date input, reason select, and notes textarea. Submits to POST /api/v1/yachts/{id}/transfer, invalidates yacht + ownership-history queries on success, and surfaces API errors (same-owner 400, cross-tenant 404, no-permission 403) as form-level messages. Transfer button is now gated by PermissionGate resource="yachts" action="transfer". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Wires the billingEntityType/billingEntityId columns (added in PR 1) through the invoice validator and service. Clients can now be billed as either a client or a company; clientName becomes a snapshot derived from the entity at create time. - createInvoiceSchema: replace clientName with billingEntity {type,id} - listInvoicesSchema: add billingEntityType/billingEntityId filters - createInvoice: resolveBillingEntity helper (tenant-scoped; tx-aware) falls back to entity primary email/address when not supplied - listInvoices: honor new billing-entity filters - updateInvoice: unchanged — billing entity is fixed after create - invoice wizard step 1: temporary billing-entity id input (Task 10.2 replaces this with a proper picker) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Adds a new per-port document_templates row of type 'eoi' containing an HTML EOI / Letter of Intent body with {{section.field}} merge tokens that mirror the EoiContext shape. Enables the in-app pdfme PDF path as an alternative to the Documenso template flow. - New getStandardEoiTemplateHtml() returns the Letter-sized HTML body with Applicant / Yacht / Owner / Berth / Interest / Signatures blocks - STANDARD_EOI_MERGE_FIELDS exported for resolveTemplate wiring (11.4) - seed-data.ts inserts one document_templates row per port inside the existing withTransaction block, between ownership transfers and interests, using SEED_USER_ID for audit consistency Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>generateAndSign now accepts a `pathway` parameter: - `inapp` (existing): resolve in-app template -> pdfme -> MinIO -> Documenso createDocument + sendDocument. - `documenso-template` (new): build EOI context from interestId, assemble the Documenso template payload, and call Documenso's /api/v1/templates/{id}/generate-document. Documenso owns the PDF; we still record a documents row for tracking. Adds generateDocumentFromTemplate helper to the Documenso client and new env vars (DOCUMENSO_TEMPLATE_ID_EOI + client/developer/approval recipient IDs) with defaults matching the legacy flow. Covered by 6 new integration tests (637/637 green). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Extracts the MERGE_FIELDS catalog out of the document-templates service into src/lib/templates/merge-fields.ts so the Zod validator can import it without circular deps. createTemplateSchema now refines mergeFields against VALID_MERGE_TOKENS — unknown tokens (including the deprecated `{{client.yachtName}}` / `{{client.companyName}}` family) are rejected at template creation time with a message naming the offenders. Adds the missing `eoi` value to templateType enum so seeded EOI rows round-trip through the validator. Drops the historical "Removed (PR 11):" comment from the catalog (per project convention against `// removed` markers). 6 new validator unit tests; 652/652 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>PR 13: now that all reads are migrated to the dedicated yacht / company / membership entities, drop the columns that mirrored them on `clients`: companyName, isProxy, proxyType, actualOwnerName, relationshipNotes, yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M}, berthSizeDesired. Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to apply. Caller cleanup (zero behavioral change to remaining flows): - Drops the legacy `generateEoi` flow entirely (route, service function, pdfme template, validator schema). The dual-path generate-and-sign service from PR 11 has fully replaced it; the route was no longer wired to the UI. - `clients.service`: company-name search column / WHERE / audit value removed; search now ranks by full name only. - `interests.service`: `resolveLeadCategory` reads dimensions from `yachts` via `interest.yachtId` instead of the dropped `client.yachtLength{Ft,M}`. - `record-export`: client-summary now lists yachts via owner-side lookup (direct + active company memberships); interest-summary fetches yacht via `interest.yachtId`. Both PDF templates updated to read yacht details from the new entity. - `client-detail-header`, `client-picker`, `command-search`, `search-result-item`, `use-search` hook, `types/domain.ts`, `search.service` — drop the companyName badge / sub-label / typed field everywhere it was rendered or fetched. - `ai.ts` worker: drop the company / yacht context lines from the prompt (will be re-added later sourced from the new entities). - `validators/interests.ts`: remove the deprecated public-form flat yacht/company fields. The route already ignores them. - `factories.ts`: drop the `isProxy: false` default. Tests: 652/652 green; type-check clean. The `security-sensitive-data` tests use `companyName` / `isProxy` as arbitrary record keys for a generic util — left unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The client portal no longer uses passwordless / magic-link sign-in. Each client now has a `portal_users` row with a scrypt-hashed password, created by an admin from the client detail page; the admin's invite mails an activation link that the client uses to set their own password. Forgot-password is wired through the same token mechanism. Schema (migration `0009_outgoing_rumiko_fujikawa.sql`): - `portal_users` — one per client account, separate from the CRM `users` table (better-auth) so the auth realms stay isolated. Email is globally unique, password is null until activation. - `portal_auth_tokens` — single-use activation / reset tokens. Stores only the SHA-256 hash so a DB compromise never leaks live tokens. Services: - `src/lib/portal/passwords.ts` — scrypt hash/verify (no new deps; uses node:crypto), token mint+hash helpers. - `src/lib/services/portal-auth.service.ts` — createPortalUser, resendActivation, activateAccount, signIn (timing-safe), requestPasswordReset, resetPassword. Auth failures throw the new UnauthorizedError (401); enumeration-safe behaviour everywhere. Routes: - POST /api/portal/auth/sign-in — sets the existing portal JWT cookie. - POST /api/portal/auth/forgot-password — always 200. - POST /api/portal/auth/reset-password — token + new password. - POST /api/portal/auth/activate — token + initial password. - POST /api/v1/clients/:id/portal-user — admin invite (and `?action=resend`). - Removed: /api/portal/auth/request, /api/portal/auth/verify (magic link). UI: - /portal/login — replaced email-only magic-link form with email + password + "forgot password" link. - /portal/forgot-password, /portal/reset-password, /portal/activate — new. - New shared `PasswordSetForm` component used by activate + reset. - New `PortalInviteButton` rendered on the client detail header. Email send: - `createTransporter` now wires SMTP auth when SMTP_USER+SMTP_PASS are set (gmail app-password or marina-server creds, configured via env). - `SMTP_FROM` env var lets the sender address be overridden without pinning it to `noreply@${SMTP_HOST}`. Tests: - Smoke spec 17 (client-portal) updated to the new flow: 7/7 green. - Smoke specs 02-crud-spine, 05-invoices, 20-critical-path updated to match the post-refactor client + invoice forms (drop companyName, use OwnerPicker + billingEmail). - Vitest 652/652 still green; type-check clean. Drops the dead `requestMagicLink` from portal.service.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>SettingsFormCard - Parent components pass `FIELDS.slice(...)` inline, so the prop reference changes on every render. The fetch callback's useCallback re-created itself, useEffect re-fired, and loading flicker meant the form never rendered. Capture fields in a ref so the callback is stable. Sidebar - Show real user name + avatar initial from session/profile, replacing the hardcoded "User Name" / "U" placeholder. - Default the admin-section to expanded so its items are reachable on first page load (was collapsed behind a chevron). Dashboard layout - Pass {name, email} from the session/profile through to <Sidebar />. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Yachts list page rendered each row's Current Owner via OwnerLink, which fired its own /api/v1/clients/{id} or /companies/{id} fetch — N+1 round- trips per page load (12+ for the harbor-royale fixture). Worse, until those fetches resolved each cell showed "Client c68da7..." style raw IDs. Fix: listYachts now resolves the polymorphic currentOwnerName in two batched in-array queries after the page query (mirrors the listClients yachtCount/companyCount pattern), and OwnerLink accepts an optional preloadedName prop that suppresses the per-row fetch when supplied. Topbar: show real user name + avatar initial from session/profile, and expand the My-Account dropdown header to include the user's email. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>- 21-role-based-ui: tighten the Settings link locator. The previous `getByRole('link', { name: /settings/i }).first().or(getByText(/.../) .first())` chain hit a strict-mode violation once the sidebar Admin section became default-expanded — both the section header text node and the Settings link matched. Match the link directly with exact: true. - 26-residential: extend smoke with two API-driven specs covering the residential interest pipeline — create+list and detail-page render — using preferences-string stamp + heading match for assertions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Adds the design tokens the polish PRs (10a-e) will draw from: shadow-xs/sm/md/lg/glow, radius scale tuned to spec, gradient utilities, spring/smooth eases, and fast/base/slow durations. Introduces StatusPill, KPITile, and EmptyState primitives plus a polished PageHeader variant ('gradient') with optional eyebrow + KPI sub-line — existing PageHeader callers stay on the plain variant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The dashboard and residential interest smoke tests were intermittently failing with the page rendering empty/skeleton state. Root causes: 1. ui-store persisted currentPortId/Slug, but those are URL-derived state. After login lands on /<first-port-by-name>/dashboard, localStorage holds that port. Hard-navigating to /port-nimara/... rehydrated the store with the stale id, and useQuery fired with the wrong port before PortProvider's URL-sync useEffect could correct it. Drop both fields from partialize — PortProvider re-derives them from the route every navigation. 2. apiFetch's slug-to-port fallback fired N parallel /api/v1/admin/ports calls when N components mounted simultaneously with an empty store. Dedupe in-flight lookups so a stampede collapses into one round-trip. Also tightened four flaky smoke tests that depended on a fixed 3s wait or non-waiting isVisible({timeout}) — replaced with expect(...).toBeVisible or expect.poll so they handle dev-mode JIT cold-start delays cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Adds shared <DetailHeaderStrip> wrapper (rounded-xl + gradient-brand-soft + shadow-xs) and applies it to every legacy domain detail header. Residential client/interest and invoice detail get an inline gradient strip with eyebrow ('Residential Client', 'Residential Interest', 'Invoice'). Residential bodies normalized to lg:grid-cols-[2fr_1fr] per spec. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Sidebar active items: 4px brand left-edge stripe (rounded-r-full) replacing the border-l-2 + bg shift; section header smaller-caps + brand-200 colour; user-footer avatar gets shadow-sm + ring-2 ring-white/30. Topbar '+ New' uses bg-gradient-brand with shadow-sm + scale-1.02 hover. User avatar trigger gets shadow-sm + ring-2 ring-white. Notification badge gets gradient-brand fill + ring-2 ring-background + animate-badge-pop spring keyframe (retriggers on count change via key={unreadCount}). Command search gets shadow-xs inset + brand focus ring (ring-4 ring-brand/15). Adds badge-pop keyframes to tailwind config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Integration tests use makePort() which writes ports with slug 'test-port-{rand}' and never cleans up. Result: 17,564 leaked rows in dev that slowed every page load fetching the port-switcher list (and was contributing to smoke flakes). Adds tests/global-setup.ts with a teardown() that DELETEs every 'test-port-%' row plus its dependent rows across 30+ tables in one CTE. Wires it into vitest.config.ts via globalSetup. Adds closeDb() helper so the teardown can end the postgres-js pool cleanly (kills the 'Tests closed but Vite server won't exit' warning). Also lands docs/superpowers/specs/2026-04-28-country-phone-timezone-design.md — full-scope agenda for the country dropdown / E.164 phone input / country-driven timezone autofill work, ~7 dev days across 10 PRs. Per user request: 'let's do this full-fledged if we're gonna do it'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>PR2 of Phase B. Wires the alert framework end-to-end: - alert-rules.ts: 10 rule evaluators implemented as pure async fns over the existing schema. reservation.no_agreement, interest.stale, document.signer_overdue, berth.under_offer_stalled, expense.duplicate, expense.unscanned, interest.high_value_silent, eoi.unsigned_long, audit.suspicious_login fire against real conditions. document.expiring_soon stays inert until the documents schema gets an expires_at column. audit.suspicious_login also stays inert until the auth layer logs 'login.failed' rows (TODO noted in the rule body). - alert-engine.ts: runAlertEngine() walks every port × every rule and calls reconcileAlertsForPort. Errors per (port, rule) are collected in the summary, not thrown — one bad evaluator can't stop the sweep. - alerts.service.ts: reconcileAlertsForPort now emits 'alert:created' socket events on insert and 'alert:resolved' on auto-resolve; dismissAlert emits 'alert:dismissed'. All scoped to port:{portId} rooms. - socket/events.ts: adds the three Server→Client alert event types. - queue/scheduler.ts: registers 'alerts-evaluate' on the maintenance queue with cron */5 * * * * (every 5 min, per spec risk register). - queue/workers/maintenance.ts: dispatches 'alerts-evaluate' to runAlertEngine; logs sweep summary. Tests: - tests/integration/alerts-engine.test.ts (6 cases): seeds reservation → fires, runs twice → no dupe, adds agreement → auto-resolves; seeds stale interest → fires; hot lead silent → critical; engine summary shape on no-data port. Socket emit module is vi.mocked. Vitest 681/681 (was 675; +6). tsc clean. Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>PR3 of Phase B. Replaces the no-op stubs in analytics.service.ts with working drizzle queries and adds the recurring BullMQ job that warms the cache. Computations: - computePipelineFunnel: groups interests by pipeline_stage filtered by port + range + not archived; emits 8-row stages array with conversion pct relative to 'open' as the funnel top. - computeOccupancyTimeline: per day in range, counts berths covered by an active reservation (start_date ≤ day, end_date IS NULL OR ≥ day); emits {date, occupied, total, occupancyPct}. - computeRevenueBreakdown: sums invoices.total grouped by status + currency; filters out archived rows. - computeLeadSourceAttribution: counts interests by source descending; null source bucketed as 'unspecified'. Public API (getPipelineFunnel, getOccupancyTimeline, etc.) reads analytics_snapshots first; falls back to compute + writeSnapshot. TTL 15 minutes (matches the cron interval). Cron: - queue/scheduler.ts registers 'analytics-refresh' on maintenance with pattern '*/15 * * * *'. - queue/workers/maintenance.ts dispatches to refreshSnapshotsForPort for every port; per-port try/catch so one bad port doesn't kill the sweep. Tests: tests/integration/analytics-service.test.ts (9 cases). Pipeline funnel math (incl. zero state), occupancy timeline shape/percentages with seeded reservations, revenue grouped by status + currency, lead source attribution incl. null bucketing, cache hit (mutate snapshot directly → next read returns mutated value), refreshSnapshotsForPort warms every metric×range combo. Vitest 690/690 (+9). tsc + lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema + service skeletons committed in PRs 1-3. PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source), date-range picker (today/7d/30d/90d), CSV+PNG export per card. PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard right-rail, three-tab page (active/dismissed/resolved), socket-driven invalidation. Bell lazy-loads list on popover open to keep cold pages fast in non-dashboard routes. PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count surfaces in tab label. PR7 Interests-by-berth tab on berth detail — replaces the stub. PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow banner on detail w/ Merge / Not-a-duplicate, transactional merge consolidates receipts and archives the source. PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in its own (scanner) group with no dashboard chrome, dynamic per-port manifest, OpenAI + Claude provider abstraction, admin OCR settings page (port-level + super-admin global default w/ opt-in fallback), test-connection endpoint, manual-entry fallback when no key is configured. Verify form always shown before save — no ghost rows. PR10 Audit log read view — swap to tsvector full-text search on the existing GIN index, cursor pagination, filters for entity/action/user /date range, batched actor-email resolution. PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip cleanly without their gate envs so CI stays green. Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Cross-cutting i18n polish for forms across the marina + residential + company domains. Introduces a single source of truth for country/phone/timezone/ subdivision data and replaces every nationality-as-free-text and timezone- as-string Input with a dedicated combobox. PR1 Countries — ALL_COUNTRY_CODES (~250 ISO-3166-1 alpha-2), Intl.DisplayNames for localized labels, detectDefaultCountry() with navigator-region fallback to US, CountryCombobox with regional-indicator flag glyphs + compact mode for inline use. PR2 Phone — libphonenumber-js wrapper (parsePhone / formatAsYouType / callingCodeFor), PhoneInput with flag dropdown + national-format AsYouType + paste-detect that flips the country dropdown for pasted international strings. PR3 Timezones — country->IANA map (250 entries, multi-zone for AU/BR/CA/CD/ ID/KZ/MN/MX/RU/US), formatTimezoneLabel ("Europe/London (UTC+1)"), TimezoneCombobox with Suggested/All grouping driven by countryHint. PR4 Subdivisions — wraps the iso-3166-2 npm package (~5000 ISO 3166-2 codes for every country), per-country cache, SubdivisionCombobox with "Pick a country first" / "No regions available" empty states. PR5 Schema deltas (migration 0015) — clients.nationality_iso, clientContacts {value_e164, value_country}, clientAddresses {country_iso, subdivision_iso}, residentialClients {phone_e164, phone_country, nationality_iso, timezone, place_of_residence_country_iso, subdivision_iso}, companies {incorporation_ country_iso, incorporation_subdivision_iso}, companyAddresses {country_iso, subdivision_iso}. Plus shared zod validators (validators/i18n.ts) used by every entity validator + route handler. PR6 ClientForm + ClientDetail — CountryCombobox replaces nationality Input, TimezoneCombobox replaces timezone Input (driven by nationalityIso hint), PhoneInput conditionally rendered for phone/whatsapp contacts. Inline editors (InlineCountryField / InlineTimezoneField / InlinePhoneField) for the detail-page overview rows + ContactsEditor. PR7 Residential client form + detail — phone -> PhoneInput, nationality/ timezone/place-of-residence-country/subdivision rows in both create sheet and inline-editable detail view. Subdivision wipes when country flips since codes are country-scoped. PR8 Company form + detail — incorporation country -> CountryCombobox, incorporation region -> SubdivisionCombobox in both modes. PR9 Public inquiry endpoint — accepts pre-normalized phoneE164/phoneCountry and i18n fields from newer website builds, server-side parsePhone() fallback for legacy raw-international submissions. Old Nuxt builds keep working unchanged. Tests: 4 unit suites for the primitives (25 tests), 1 integration spec for the public phone-normalization path (3 tests), 1 smoke spec asserting the combobox triggers render in all three create sheets. Test totals: vitest 713 -> 741 (+28). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Multi-area cleanup pass closing partial-implementation gaps surfaced by the post-i18n audit. No behavior changes for happy-path users; closes real correctness/security holes. PR1a Public yacht-interest endpoint i18n. /api/public/interests now accepts phoneE164/phoneCountry, nationalityIso, address.{countryIso, subdivisionIso}, and company.{incorporationCountryIso, incorporationSubdivisionIso}. Server-side parsePhone() fallback for legacy raw phone strings. PR1b Alert rule registry trim. Two rule slots ('document.expiring_soon', 'audit.suspicious_login') were registered but evaluators returned []. Both required schema/instrumentation that hadn't landed. Removed from the registry; comments record the dependencies needed to revive them. Effective rule count: 8 active. PR1c vi.mock hoist + flake fix. Hoisted vi.mock calls to top-level in 5 integration test files; webhook-delivery uses vi.hoisted for the queue-add ref. Vitest no longer warns about non-top-level mocks. Deflaked the 'short value' assertion in security-encryption.test.ts by switching plaintext from 'ab' to 'XY' (non-hex chars). 5/5 runs green. PR1d Soft-delete reference audit. listClientOptions and listYachtsForOwner now filter by isNull(archivedAt). Berths use status (no archivedAt). PR1e Permission-matrix audit script + report. scripts/audit-permissions.ts walks every src/app/api/v1/**/route.ts and reports handlers without a withPermission() wrapper. Initial run found 33 violations. - Allow-listed 17 with explicit reasons (self-data, admin, alerts, search, currency, ai, custom-fields — some marked TODO). - Wrapped 7 routes with concrete permissions: clients/options (clients:view), berths/options (berths:view), dashboard/* (reports:view_dashboard), analytics (reports:view_analytics). Audit report at docs/runbooks/permission-audit.md. Script exits non-zero on any unallow-listed violation so it can become a CI gate. Vitest: 741 -> 741 (no new tests; existing suite covers the changes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Test-data only — no production migration needed (per earlier decision). Schema is now ISO-only; readers convert ISO codes to localized names where human-readable output is required (EOI documents, invoices, portal). Migration 0016 drops: - clients.nationality - companies.incorporation_country - client_addresses.{state_province, country} - company_addresses.{state_province, country} Code paths that previously read free-text values now read the ISO column and pass through `getCountryName()` / `getSubdivisionName()` for rendering. Document templates ({{client.nationality}}), portal client view, EOI/ reservation-agreement contexts, and invoice billing addresses all updated. Public yacht-interest endpoint (/api/public/interests) drops the legacy fields from its insert path and writes ISO codes only. The Zod validators no longer accept the legacy fields — older website builds posting raw 'incorporationCountry' / 'country' / 'stateProvince' will get 400s. Server-side phone normalization is unchanged. Seed data updated to use ISO codes (GB/FR/ES/GR/SE/IT/GH/MC/PA), spread across continents to keep test fixtures realistic. Test assertions updated to match the new render shape (e.g. 'United States' not 'US', 'California' not 'CA'). Vitest: 741 -> 741 (unchanged count; assertions updated, no new tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Two new runbooks under docs/runbooks/ plus the automation scripts the backup runbook references. Both are written so an operator who has only the off-site backup credentials and the runbook can recover the system unaided. Backup/restore (Phase 4a): - docs/runbooks/backup-and-restore.md — covers what gets backed up (Postgres / MinIO / .env+ENCRYPTION_KEY), schedule (hourly DB + hourly MinIO mirror, 7-day hourly + 30-day daily retention), cold-restore procedure with row-count verification, weekly drill - scripts/backup/pg-backup.sh — pg_dump → gzip → optional GPG → mc upload, fails loud - scripts/backup/minio-mirror.sh — incremental mc mirror, no --remove flag so accidental deletes on the live bucket can't cascade - scripts/backup/restore.sh — interactive prod restore + --drill mode that runs against a sandbox DB and diffs row counts Email deliverability (Phase 4b): - docs/runbooks/email-deliverability.md — what the CRM sends, DNS records (SPF/DKIM/DMARC/MX), per-port override implications, diagnosis flow ("didn't arrive" → 4-step checklist starting with EMAIL_REDIRECT_TO), provider migration plan, realapi suite as the end-to-end probe Tests still 778/778 vitest, tsc/lint clean — these phases are docs + shell scripts, no code changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Surveys what it actually takes to ship the AI inbox-triage feature gated on Google Workspace integration. Walks through three deployment models with their real costs: - Model A (Marketplace OAuth app): 4-6 months calendar, $15k-$75k for the required CASA security assessment, recurring re-verification - Model B (per-customer Internal OAuth app): ~5 weeks engineering, $0 Google-side, scoped to one workspace per customer - Model C (forward-to-CRM mailbox): ~1 week, receive-only, no reply drafts possible Recommends Model B for the current customer profile, with B → A promotion only if 3+ customers ask unprompted. Documents what's already scaffolded (email_accounts/threads/messages tables, syncInbox stub, BullMQ email queue, ai_usage_ledger, per-port aiEnabled flag, withRateLimit('ai')) vs what's new (OAuth flow, Pub/ Sub push receiver, gws_user_tokens + email_triage tables, /inbox UI). End-to-end flow, schema additions, AI cost interaction with the Phase 3b token budgets, 5-phase build plan (G1-G5), and 5 open decisions to resolve before scheduling the build. Explicitly out of scope: M365, sentiment analysis, smart-drafts, cross-staff triage queue. No code changes — this is a design doc to drive a go/no-go decision. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The same `interface AuditMeta { userId; portId; ipAddress; userAgent }` was duplicated in 26 service files. Move the canonical definition into `@/lib/audit` next to the related types and update every service to import it. `ServiceAuditMeta` (the alias used in invoices.ts and expenses.ts) collapses into the same name. Tag CRUD across clients/companies/yachts/interests/berths followed an identical wipe-then-rewrite recipe with two latent issues: the delete and insert weren't wrapped in a transaction (a partial failure left the entity with zero tags) and the audit-log payload shape diverged (`newValue: { tagIds }` for clients/yachts/companies but `metadata: { type: 'tags_updated', tagIds }` for interests/berths). Extract `setEntityTags` in `entity-tags.helper.ts` that performs the delete+insert inside a single transaction, normalizes the audit payload to `newValue: { tagIds }`, and dispatches the per-entity socket event through a switch so `ServerToClientEvents` typing stays intact. The five `setXTags(...)` service functions now do parent-row tenant verification and delegate the join-table work + side effects. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Three findings from the branch security review: 1. HIGH — Privilege escalation via super-admin invite. POST /api/v1/admin/invitations was gated only by manage_users (held by the port-scoped director role). The body schema accepted isSuperAdmin from the request, createCrmInvite persisted it verbatim, and consumeCrmInvite copied it into userProfiles.isSuperAdmin — granting the new account cross-tenant access. Now the route rejects isSuperAdmin=true unless ctx.isSuperAdmin, and createCrmInvite requires invitedBy.isSuperAdmin as defense-in-depth. 2. HIGH — Receipt-image exfiltration via OCR settings. The route /api/v1/admin/ocr-settings (and the sibling /test) were wrapped only in withAuth — any port role including viewer could PUT a swapped provider apiKey + flip aiEnabled, redirecting every subsequent receipt scan to attacker infrastructure. Both are now wrapped in withPermission('admin','manage_settings',…) matching the sibling admin routes (ai-budget, settings). 3. MEDIUM — Cross-tenant alert IDOR. dismissAlert / acknowledgeAlert issued UPDATE … WHERE id=? with no portId predicate. Any authenticated user with a foreign alert UUID could mutate it. Both service functions now require portId and add it to the WHERE; the route handlers pass ctx.portId. The dev-trigger-crm-invite script passes a synthetic super-admin caller identity since it runs out-of-band. The two public-form tests randomize their IP prefix per run so a fresh test process doesn't collide with leftover redis sliding-window entries from a prior run (publicForm limiter pexpires after 1h). Two new regression test files cover the fixes (6 tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>1. HIGH — Socket.IO accepted client-supplied `auth.portId` in the handshake without verifying the user actually held a role in that port, then unconditionally joined the socket to `port:${portId}`. The `join:entity` handler also skipped authorization. This let any authenticated CRM user receive realtime events from any other tenant: invoice numbers + totals + client names, document signer emails, registration events with full client name + berth, file uploads, etc. Auth middleware now resolves the user's userPortRoles (or isSuperAdmin) before honouring portId, and join:entity verifies the entity's port matches a port the user has access to. Pre-existing pre-branch issue but fixed here given the explicit "all data is extremely sensitive" directive. 2. MEDIUM — listCrmInvites issued a global SELECT with no port scope. The crm_user_invites table has no portId column (invites mint global better-auth users, then port roles are assigned later). The previous gating on per-port admin.manage_users let any director enumerate every other tenant's pending invitee emails + isSuperAdmin flags — a phishing target list and a super-admin onboarding timing oracle. Restrict GET (list), DELETE (revoke), and POST resend to ctx.isSuperAdmin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.