Commit Graph

362 Commits

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:18:28 +02:00
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
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
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
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
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
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
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
58203ca8ea feat(reports): financial hasData existence flag (service + route)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:13:42 +02:00
8b7099c4c1 feat(reports): sales hasData existence flag (service + route)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:12:54 +02:00
10b3b68851 feat(reports): thread Area filter + add area-options/hasData helpers (operational service)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:10:33 +02:00
3d9084c94b feat(reports): parseOperationalFilters pure parser (Area scope)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:08:16 +02:00
b690fb8d56 feat(reports): Financial report (Initiative 1 Phase 4)
Builds the Financial report on the canonical payments + expenses tables
(the CRM records money received; it does not invoice — invoices module
is off, dev DB has zero invoice rows). The invoice-centric spec is
reframed onto the payments model: "outstanding AR" → expected-deposit
shortfall on active deals; "AR aging" → outstanding deposits bucketed by
deal age.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:28:04 +02:00
31ba72f344 chore(launch-prep): hide unfinished report/import surfaces, defer big builds
Ship-what's-done prep ahead of the prod cutover (launch ~today):

- Hide Financial + Marketing report cards from the reports landing
  (both were "Builder in development" placeholders gated on unbuilt
  data sources). Sales/Operational/Custom + templates/scheduling/
  exports remain live.
- Trim the Custom-report card copy to match the shipped basic builder
  (no group-by/filters yet; the builder page header was already honest).
- Hide the Bulk Import mockup from search-nav-catalog + the admin
  sections browser; /admin/import is now unreachable from the UI.
- Correct client-facing doc over-claims (waiting-list "next-in-line
  notification", Import) in features-list.md + new-system-feature-summary.md.
- Un-stale BACKLOG.md (Documenso phases 2-7 confirmed shipped).
- Log decisions + deferred work (full importer, full custom-builder,
  waiting-list, maintenance-log, paper-upload bug) to launch-readiness.md.

Deferred-importer design spec added at
docs/superpowers/specs/2026-06-01-bulk-import-design.md.

Verified: tsc --noEmit clean, eslint clean on changed files,
1512/1519 vitest pass (7 failures are Redis-down, unrelated).

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:49:16 +02:00
cb8292464c feat(launch-readiness-batch): UAT drains, navigation refactor, launch infra, trackers
Bundles the rest of the in-flight work from this UAT round into one
checkpoint. Each sub-area is independent; see the headings below.

UAT polish (drained 11 findings from active-uat.md):
- Dialog primitive default bumped sm:max-w-xl/lg:max-w-3xl →
  sm:max-w-2xl/lg:max-w-4xl so multi-field forms + PDF previews
  aren't cramped at 1440-1920px.
- Notes tab badge aggregation: new countFor{Client,Yacht,Company}
  Aggregated helpers in notes.service mirror the listFor*Aggregated
  symmetric-reach joins. yacht-tabs + company-tabs render the
  badge; client-tabs already had badge support.
- Supplemental-info form polish bundle: BrandedAuthShell gains a
  `width: 'sm' | 'md'` prop (md uses min-h-dvh scroll instead of
  fixed inset-0 pin so long forms scroll naturally). Form picks up
  port branding (logoUrl + backgroundUrl + appName) via
  loadByToken. Address fields completed (street + city + region +
  postal + country). Port name eyebrow + success-state copy added.
- new-document-menu Upload-file landing toast: per-file completion
  emits toast.success with action link to the destination entity
  or folder.
- interest-tabs OverviewTab "from client" pill on Email + Phone
  rows via new EditableRow `inheritedFrom` prop.
- create-document-wizard subject picker → segmented button strip
  (5 types visible at once).

Launch infra:
- UTM column wiring (Init 1b step 4): migration
  0089_website_submissions_utm.sql adds utm_source/medium/campaign/
  term/content + composite index (port_id, utm_source, received_at)
  for per-campaign rollups. website-inquiries intake accepts the
  five fields. Residential intake intentionally untouched per audit
  scope.
- Invoicing module gate (Init 1c spike): new
  invoices-module.service + invoices layout guard + registry entry
  invoices_module_enabled (default false). Audit conclusion in
  launch-readiness.md: payments table is canonical money path;
  /invoices flow is parallel infrastructure now hidden by default.

Smart-back navigation refactor:
- Replaced breadcrumb component with history-aware Back button.
  New route-labels.ts + use-smart-back hook +
  navigation-history-tracker so back falls through to the parent
  route when there's no prior page in history.
- Sidebar / topbar / mobile-topbar adopt the new pattern; old
  breadcrumb-store kept for back-compat consumers but the
  breadcrumbs component is gone.
- 6 detail pages (admin/errors per-id + codes, invoices/
  upload-receipts, reports kind, tenancies detail, analytics
  metric, client detail) migrated.

Trackers + docs:
- docs/launch-readiness.md — master pre-launch tracker. Includes
  the reports gap audit (cross-cutting filter set, Marketing +
  Financial blockers, custom builder remaining entities, scheduled
  CSV/XLSX, template scope picker).
- docs/superpowers/audits/active-uat.md — 15 findings flipped
  OPEN → SHIPPED locally with fix-applied notes; 4 OPEN remaining
  (each blocked on user input or cross-repo).
- CLAUDE.md — minor session notes carried forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:42:37 +02:00
3bdf59e917 feat(reports-overhaul): sales + operational + custom reports, templates, schedules, exports
End-to-end reports build covering Phases 1, 2, 5, 6, 7 of Initiative 1
in docs/launch-readiness.md. Phases 3 (Marketing) + 4 (Financial)
remain deferred per the gap audit at the bottom of that doc.

Highlights:
- Sales performance report: 7 KPI tiles, pipeline funnel + stage
  velocity + win-rate-over-time + source conversion + rep leaderboard
  charts, deal-heat section, 5 detail tables, stage / lead-cat /
  outcome filters.
- Operational report: 7 KPIs, 7 charts (heatmap, status mix, tenancy
  churn, tenure histogram, signing box plot, occupancy by area, docs
  in pipeline), 4 tables. Module-OFF banner when tenancies disabled.
- Custom (ad-hoc) builder v1: 4 entities (clients, interests, berths,
  tenancies), column-whitelist composer, date filter, CSV download,
  save-as-template. Registry-only extension path for the remaining 6
  entities documented at src/lib/reports/custom/registry.ts.
- Templates: load / modify / save / save-as on Sales / Operational /
  Custom. ?templateId= URL deep-link hydration via useRef guard.
  Active-template badge clears when the user drives view-state via
  wrapped setters; raw setters used on template apply so the badge
  survives.
- Scheduled runs: BullMQ poll fires due schedules, mints report_runs,
  renders, optionally emails. Recipients optional (zero-recipient
  schedules archive without sending). PDF-only output for v1.
  Schedule dialog re-mounts via key prop on schedule.id transitions
  to avoid setState-in-effect reset patterns.
- Server-side PDF endpoint + shared payload renderer
  (lib/pdf/reports/payload-report.tsx) so client + scheduler share
  one rendering path.
- Shared currency formatter (lib/reports/format-currency.ts)
  consolidates 5 duplicated formatMoney helpers; fixes hardcoded
  'USD' in detail tables; pre-formats money rows so PDF export
  (which strips column.format callbacks at the JSON boundary)
  renders consistently with CSV / XLSX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:41:53 +02:00
909dd44605 feat(uat-p5): activity-feed module, signing-order tri-state, webhook health card
- Activity-feed: shared formatting module
  (src/components/shared/activity-formatting.ts) centralises action
  verbs, badge variants, entity-type labels, enum-value normalisation,
  shortValue, and buildDiffLine. The dashboard widget feed and the
  per-entity audit feed now both consume it - duplicate ~250 lines
  collapsed, vocabularies aligned, badge palette unified.
- Signing order setting becomes tri-state. The new
  TEMPLATE_DEFAULT value (the new default) skips overriding the
  template's own signingOrder so each Documenso template's stored
  setting wins. PARALLEL / SEQUENTIAL keep forcing the override.
- Admin Documenso page now ships a Webhook health card backed by
  /api/v1/admin/documenso-webhook/health (secret status,
  expected URL, last received event, recent secret rejections) and
  a "Test now" button that fires a synthetic DOCUMENT_OPENED through
  /api/v1/admin/documenso-webhook/test against the local receiver
  to verify the full pipeline without driving a real Documenso event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:05:14 +02:00
6caf41651f feat(uat-p5): long-tail polish - tag chips, notes counts, hub context, tenancies toggle
- StageStepper renders now carry tag chips next to the progress bar
  (client interest cards, pipeline summary, preview sheet).
- Notes tab badge on the interest detail aggregates note counts across
  the interest, the linked client, the linked yacht, and any companies
  the client is an active member of - reps see the full surface area
  at a glance.
- Admin Settings: Tenancies Module toggle wired into the Feature Flags
  card. Disabling hides nav/tabs without deleting any rows; re-enabling
  brings them back. Service layer was already complete; this surfaces
  the control on the operations page.
- HubRoot recent-files rows now show folder breadcrumb + entity badge
  (Interest/Client/Yacht/Company) so reps can tell at a glance where a
  file lives. Backed by listFiles enrichment (5 batched lookups per
  page; no per-row queries).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:57:20 +02:00
2592e28578 feat(uat-p4): inheritance polish - yacht dims, occupancy chip, map-flip flag
Phase 4 of the active UAT sweep wraps the inheritance/polish bucket.

- BerthOccupancyChip: new shared component that surfaces the competing
  active interest on a non-available berth as a colour-coded chip with
  a stage badge. Adopted in LinkedBerthRowItem, BerthRecommenderPanel
  recommendation card, and InterestBerthStatusBanner; the banner aligns
  query keys with the chip so React Query dedupes the network call.
- OverviewTab inheritance: getInterestById now ships a yachtDimensions
  block when the interest is linked to a yacht with dimensions. The
  Berth Requirements rows render a "↩ <value> from yacht" pill when
  the desired field is blank; clicking the pill copies the value into
  the interest. After a manual edit, a toast offers to write the new
  value back to the yacht record so the canonical truth stays in sync.
- Map-flip inheritance: ExternalEoiUploadDialog and UploadForSigningDialog
  now expose a single "Mark berth(s) as Under Offer on the public map"
  checkbox that defaults ON when any in-bundle berth already has
  is_specific_interest=true. On submit, PATCHes the in-bundle berths
  that don't already match; sister surface to the EOI generate
  dialog's per-berth picker.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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