Commit Graph

856 Commits

Author SHA1 Message Date
37ffb2c3b4 fix(audit): financial — M19 (group-by-currency accumulation, full-precision rates), M23 (invoice money rounding + 0% discount), L25 (no silent unconverted/stale FX), L26 (companyNotes updatedAt)
M23 numeric(12,2) schema precision deferred to a migration.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:54:36 +02:00
30f6723fef docs(audit): complete unified master — all 17 lanes, 85 findings (4 CRIT/17 HIGH/29 MED/35 LOW)
Consolidates audit passes 1-3 + smoke test + reconciliation. Supersedes the
partial doc. Pre-fix; nothing remediated yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:38:44 +02:00
3337a20091 docs(audit): consolidated master findings — passes 1+2 (6/17 lanes, 3 CRIT/6 HIGH); 11 lanes pending re-run
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:07:35 +02:00
366b0d79fd docs(launch): reports polish shipped — empty states + Operational Area filter
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:25:07 +02:00
0ee3cd6073 feat(reports): operational Area filter (FilterBar + query + template scope)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:21:57 +02:00
91d8ee226b feat(reports): financial report-level empty state
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:19:57 +02:00
24e88ae32e feat(reports): sales report-level empty state
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:17:56 +02:00
7cf364e03a feat(reports): shared ReportEmptyState component
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:17:05 +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
68da165b37 feat(reports): operational route — Area filter + areaOptions + hasData
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:11:26 +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
93e96da43b docs(reports): implementation plan for beta-finish polish
11 bite-sized TDD tasks: parseOperationalFilters (unit-tested), Area
filter threaded through the operational service + route, hasData
existence flags on all three report routes, shared ReportEmptyState
component, and per-client wiring. Verification + tracker update in the
final task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:05:13 +02:00
244fb14ce5 docs(reports): design spec for beta-finish polish (empty states + Operational area filter)
Locked decisions from brainstorming: report-level empty states across
Sales/Operational/Financial gated on a window-independent hasData flag;
Operational gains an Area-only berth-scope filter (Status dropped as a
light filter in this report); rep/source confirmed not applicable to
Operational.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:57:12 +02:00
41c64dc126 feat(reports): gate unbuilt Marketing report to 404 for beta
Sales/Operational/Financial are built + verified; Marketing is blocked
on the website cutover (launch-readiness Init 1b), not on code. Rather
than hide the whole reports surface behind a module toggle, keep it live
for beta and 404 the one unbuilt kind so a hand-typed /reports/marketing
URL can't reach the "in development" placeholder. The landing page
already advertises only the three live reports + Custom.

Remove the UNAVAILABLE_NEW_KINDS entry when the Marketing report ships.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:34:55 +02:00
0f7da79a64 docs(launch): Financial report SHIPPED (Phase 4) — payments-model reframe
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:44:27 +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
75fdb9fab4 docs(launch): reconcile tracker — mark shipped rep/source filters + 3 stale-deferred items
- rep + source multi-select filters → SHIPPED in b97f6e94
- Waiting List + Maintenance Log tabs → SHIPPED in 8be7a6e2 (were still
  listed deferred)
- contract/reservation paper-upload misroute fix → SHIPPED in d98aa5cc
  (was still listed deferred)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:25:34 +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
c7325010e6 feat(import): commit runner + undo + wired BullMQ worker
Third importer increment — the write path, fully testable without UI.

- commit.ts: commitBatch streams classified rows, applies insert/update per
  the conflict policy via the adapter (each row in its own try/catch so valid
  rows still land), records every action in import_batch_rows, and keeps live
  counts on the batch header. undoBatch hard-deletes a batch's inserted rows
  (port-scoped); a delete blocked by a dependent FK is reported, not forced,
  and the batch flips to `undone` only when every inserted row was removed.
- import worker: replaced the no-op placeholder with the real processor —
  loads the batch, re-reads the uploaded file from storage, parses, and runs
  commitBatch under the batch's mapping + policy. Marks the batch failed on
  error. Concurrency 1 so imports don't race each other's dedup lookups.

Tests: commit (skip/insert/error counts + per-row ledger + real inserted
entity), undo (removes exactly the inserted row, flips status), and
update-matches overwrite. 2 passing.

Engine is now functional end-to-end at the service layer: parse → map →
dry-run → commit → undo. Remaining: 4 FK adapters, API routes + permission,
wizard UI + history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:36:42 +02:00
3cf12b3015 feat(import): engine core + companies/clients/berths adapters
Second importer increment: the generic engine + the three no-FK adapters,
fully unit + integration tested.

- types: ImportAdapter contract (targetFields, matchKey, findExisting,
  resolveForeignKeys, insert/update) + engine types.
- mapping: fuzzy header → target-field auto-suggest (exact / alias / edit
  distance, one header per field) + applyMapping (drops empty cells).
- classify: per-field zod + cross-field extraValidate, FK resolution hook,
  natural-key dedup, and the conflict-policy matrix
  (skip-matches / update-matches / error-on-match) → row outcomes + summary.
- engine: CSV (papaparse) + XLSX (ExcelJS) parse into a uniform
  {headers, rows} of trimmed strings.
- adapters (delegating to existing create/update services for audit +
  validation): companies (name dedup, update), clients (flat email/phone →
  contacts[], email-or-phone dedup, insert-only), berths (canonical mooring
  dedup, numeric coercion, update).
- registry: implemented adapters in dependency order.

Tests: 11 unit (mapping/validation/matchKey/parse) + 3 integration
(dedup + all three conflict policies on a seeded DB). 14 passing.

Next increments: FK adapters (yachts/interests/tenancies/expenses),
commit runner + worker, API routes + permission, wizard UI + undo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:32:19 +02:00
372b585bf9 feat(import): data model for the bulk CSV/XLSX importer
First increment of the importer (docs/superpowers/specs/2026-06-01-bulk-import-design.md):
the three port-scoped tables, no changes to entity tables.

- import_batches — one row per run: entity_type, filename, storage_key,
  status, conflict_policy, mapping_json, live counts, created_by, timestamps.
- import_batch_rows — per-row action ledger (inserted/updated/skipped/errored)
  with entity_id + error; partial index on inserted rows powers Undo.
- import_mappings — saved column mappings, unique per (port, entity, name).

Migration 0090 applied via psql; schema re-exported from the index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:23:50 +02:00
a343eaa257 feat(migration): old-LOI EOI recovery, folded berth-links, contactless flag
Three polish items so the legacy seed is one-shot and complete:

- backfill-documents: recover the ~10 pre-Documenso "LOI process" EOIs
  whose signed PDF lives only as a NocoDB attachment in the `database`
  MinIO bucket (the pipeline keys EOI-doc creation off documensoID, so it
  never created rows for them). Reads EOI_Document attachment metadata
  from the local nocodb_legacy dump, pulls the PDF (read-only) from the
  `database` bucket, and CREATES the document + file + folder, linking the
  signed PDF. Idempotent via a `nocodb_eoi_document` ledger entry.
- connect-berth-links: refactored into an exported connectBerthLinks()
  and folded into migrate-from-nocodb --apply (best-effort; skips with a
  warning if the local dump isn't restored) so the multi-berth junction is
  reconnected as part of the one-shot seed, not a separate manual step.
- migration-apply: contactless legacy clients (no email/phone across the
  whole dedup cluster) get a per-port "Needs contact info" tag so staff
  can filter + chase them, instead of being dropped.

The current dev DB's 29 contactless clients were tagged via a one-off
mirroring the pipeline logic. EOI recovery code is ready but the actual
run needs LEGACY_MINIO_* read creds supplied at the command line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:18:28 +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
a7c11f2c51 feat(migration): exhaustive reconciliation + multi-berth link fix
reconcile-migration.ts: read-only cross-check of EVERY migrated record vs its
legacy source (via the ledger) — coverage (nothing dropped), field fidelity
(independently re-derives stage/eoiStatus/documensoId/berth/email), and
relationship integrity (orphans, dangling FKs).

connect-berth-links.ts: the dedup pipeline migrated only the single per-interest
Berth Number text field and missed the legacy _nc_m2m_Berths_Interests junction
(multi-berth deals) — 57 deals were missing links. Reads the junction from the
nocodb_legacy snapshot, resolves interest + berth via the ledger, inserts the
missing interest_berths rows (idempotent; respects the one-primary partial
unique index). Inserted 74 links, 51 new primaries.

After the fix: reconciliation = 0 discrepancies across all 255 deals, 165
expenses, 45 residential.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:16:41 +02:00
3e47793ebe feat(migration): verification/audit script (PDF↔person + completeness)
Read-only audit of migrated data:
- EOI PDF ↔ person: extracts each attached signed-EOI PDF text (unpdf), confirms
  the linked client name appears, flags any PDF where a different client name
  appears. Result: 35/35 strong match, 0 mismatches (visually spot-checked 2).
- Berth PDF ↔ mooring: soft text check; moorings render as graphics so the
  filename→mooring attachment is authoritative (113/113; A1 visually confirmed).
- Per-person completeness: 0 deals missing stage, 0 clients without a deal,
  29 clients without contact info (inherited legacy data gaps).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:03:58 +02:00
14ab8a8161 feat(migration): document backfill — legacy MinIO → CRM storage (Phase 2)
backfill-documents.ts pulls signed EOI PDFs + berth spec PDFs from the legacy
MinIO (client-portal bucket; read-only via dedicated LEGACY_MINIO_* creds) and
deposits them into the CRM (getStorageBackend), linking:
- berth PDFs → berth_pdf_versions + berths.current_pdf_version_id (mooring from
  filename; 113/113 matched)
- signed EOIs → documents.signed_file_id + status=completed + a files row filed
  into the client folder (exact name + conservative lev<=2 fuzzy; 33 linked)
Idempotent (skips when signedFileId / current_pdf_version_id already set).
Strictly prod-READ-only; all writes local (dev storage_backend=filesystem).
Unmatched EOIs reported (mostly in-flight deals w/ no signed PDF yet + old-LOI
docs in the NocoDB attachment bucket).

Adds probe-minio.ts (read-only bucket inventory).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:33:15 +02:00
6c040a617b feat(migration): add expenses + interest EOI status to NocoDB→CRM pipeline
A single idempotent --apply now seeds the full legacy dataset:
- Expenses: fetch the separate "Expenses" NocoDB base (mxfcefkk4dqs6uq),
  transform (price→amount+currency, payment status, receipt marker), apply to
  the expenses table under a new nocodb_expenses ledger tag.
- Interest EOI display state: set interests.eoiStatus/eoiDocStatus from the
  legacy EOI Status / LOI process so deals show signed / awaiting-signature
  (in-flight) state, not only a separate documents row.
- Runner reports expenses + tags createdBy with the seeded super-admin id.

Validated via --apply on the dev DB: 239 clients (multi-deal grouping intact),
255 interests (qualified 171/eoi 51/nurturing 30/reservation 2/contract 1),
48 signed + 3 in-flight EOIs, 165 expenses (5 currencies), 41 docs + 119
signers, 45 residential. tsc clean; 67 dedup unit tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:18:28 +02:00
7dba1a47bb fix(migration): modernize stale NocoDB→CRM pipeline stage map to current 7 stages
The 2026-05-03 migration pipeline (src/lib/dedup/*) predates the 9→7
pipeline-stage refactor; its STAGE_MAP emitted invalid stages
(open/details_sent/eoi_sent/…) that would write bad pipeline_stage values
on --apply. Remap to the current PIPELINE_STAGES (enquiry/qualified/
nurturing/eoi/reservation/deposit_paid/contract) + a deposit-received →
deposit_paid override. Frozen-fixture test expectations updated (17/17 pass).

Validated: live --dry-run = 239 clients / 255 interests / 41 EOI docs
(matches independent snapshot analysis; pipeline is more conservative and
flags 3 borderline pairs for review).

Adds the migration design spec (source map, scope lock to Port Nimara +
Expenses bases, EOI coverage 48/48, in-flight Documenso state, remaining
gaps: interest eoiStatus, expenses, doc-blob backfill).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:03:32 +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