From 353a31323e57a234c76349432cf01a05c9b71e6b Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 26 May 2026 18:48:15 +0200 Subject: [PATCH] fix(tenancies): unblock first-tenancy chicken-and-egg in webhook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Webhook auto-create on signed Reservation Agreement was gating itself on isTenanciesModuleEnabled, but autoCreatePendingTenancies never enabled the module — so the very first tenancy on a fresh port was unreachable even though the row-exists fallback in isTenanciesModuleEnabled was designed exactly for this lazy auto-surface case. Drop the gate; the inserted row now flips the module on automatically via the fallback. docs/tenancies-design.md §"When disabled" and the P3 PR-table row updated to reflect the new contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/tenancies-design.md | 20 ++++++++++---------- src/lib/services/berth-tenancies.service.ts | 11 ++++++----- src/lib/services/documents.service.ts | 12 +++++------- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/docs/tenancies-design.md b/docs/tenancies-design.md index 0d561cce..c60a5168 100644 --- a/docs/tenancies-design.md +++ b/docs/tenancies-design.md @@ -33,7 +33,7 @@ A sold berth stays sold without any tenancy data — the platform does not assum - Client / Yacht / Berth `Tenancies` tab hidden - All four reporting widgets hidden from dashboard registry - Top-level `/{portSlug}/tenancies` page returns 404 -- `handleDocumentCompleted` skips the auto-create branch on signed `reservation_agreement` (the document still progresses the interest stage and flips `reservationDocStatus` — only the tenancy mint is gated) +- `handleDocumentCompleted` still mints pending tenancies on a signed `reservation_agreement` — we intentionally do NOT gate the auto-create branch on the module flag, because the resulting row is what lazily surfaces the module on a fresh port (rule (a) above). The CRM surface stays hidden until that first insert lands; from then on, both rules (a) and (b) are satisfied. ### When enabled @@ -278,15 +278,15 @@ All routes gated on `tenancies.view` (read) or `tenancies.manage` / `tenancies.c ## Phased PR plan -| PR | Scope | Effort | Ships independently | -| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------ | -| **P1: Rename migration + perms + setting** | `008X_rename_reservations_to_tenancies.sql` + self-FKs + seed `tenancies.view`/`.manage`/`.cancel` + `tenancies_module_enabled` registry entry. Schema files renamed. ALL imports updated. **No behaviour change** — module starts disabled, so reps don't see anything new. | ~6 h | Yes (silent rename; existing consumers keep working through the renamed table) | -| **P2: Module-enabled gating infra** | `tenancies-module.service.ts` + admin Operations page Switch + lazy-flip logic + permission helper that combines `tenancies.view` AND module-enabled. | ~4 h | Yes (admin can toggle; rest of app honors the flag) | -| **P3: Webhook auto-create branch** | `autoCreatePendingTenancies` + branch in `handleDocumentCompleted` + first-insert flip. Vitest covering: module on → row created; module off → no row + stage still advances. | ~5 h | Yes (back-compat — pre-existing reservation flows keep working) | -| **P4: Public-map status flip rules** | Status resolver in `berths.service.ts` honors active permanent-class tenancies. Vitest for precedence + module-off behaviour. | ~3 h | Yes | -| **P5: Sidebar entry + top-level page** | Sidebar mounts the Tenancies entry behind both gates. New `/{portSlug}/tenancies/page.tsx` with the listing table + filters. 404 when module disabled. | ~6 h | Yes (visible to super_admin first; sales reps see it once perms seed) | -| **P6: Entity tab refresh + Create dialog** | Friendly empty state + "Create tenancy" CTA on Client / Yacht / Berth tabs. `` pre-fills from parent context. Edit / Renew / Transfer / End dialogs follow the same idiom. | ~8 h | Yes | -| **P7: Reporting widgets** | All four widgets — occupancy heatmap, renewals at risk, revenue forecast, tenure type breakdown — all module-gated via `selfGates: true` + `requires: 'tenancies_module'`. | ~10 h | Yes | +| PR | Scope | Effort | Ships independently | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------ | +| **P1: Rename migration + perms + setting** | `008X_rename_reservations_to_tenancies.sql` + self-FKs + seed `tenancies.view`/`.manage`/`.cancel` + `tenancies_module_enabled` registry entry. Schema files renamed. ALL imports updated. **No behaviour change** — module starts disabled, so reps don't see anything new. | ~6 h | Yes (silent rename; existing consumers keep working through the renamed table) | +| **P2: Module-enabled gating infra** | `tenancies-module.service.ts` + admin Operations page Switch + lazy-flip logic + permission helper that combines `tenancies.view` AND module-enabled. | ~4 h | Yes (admin can toggle; rest of app honors the flag) | +| **P3: Webhook auto-create branch** | `autoCreatePendingTenancies` + unconditional branch in `handleDocumentCompleted` (no module gate — the inserted row is what surfaces the module via the row-exists fallback in `isTenanciesModuleEnabled`). Vitest covering: first signing on a fresh port surfaces the module; replay is idempotent; stage still advances regardless. | ~5 h | Yes (back-compat — pre-existing reservation flows keep working) | +| **P4: Public-map status flip rules** | Status resolver in `berths.service.ts` honors active permanent-class tenancies. Vitest for precedence + module-off behaviour. | ~3 h | Yes | +| **P5: Sidebar entry + top-level page** | Sidebar mounts the Tenancies entry behind both gates. New `/{portSlug}/tenancies/page.tsx` with the listing table + filters. 404 when module disabled. | ~6 h | Yes (visible to super_admin first; sales reps see it once perms seed) | +| **P6: Entity tab refresh + Create dialog** | Friendly empty state + "Create tenancy" CTA on Client / Yacht / Berth tabs. `` pre-fills from parent context. Edit / Renew / Transfer / End dialogs follow the same idiom. | ~8 h | Yes | +| **P7: Reporting widgets** | All four widgets — occupancy heatmap, renewals at risk, revenue forecast, tenure type breakdown — all module-gated via `selfGates: true` + `requires: 'tenancies_module'`. | ~10 h | Yes | Total: ~42 h spread across 7 PRs. diff --git a/src/lib/services/berth-tenancies.service.ts b/src/lib/services/berth-tenancies.service.ts index 3ab1fb8b..efc8b3cc 100644 --- a/src/lib/services/berth-tenancies.service.ts +++ b/src/lib/services/berth-tenancies.service.ts @@ -680,11 +680,12 @@ export interface AutoCreateOptions { * then confirms start date + tenure type via the entity-tab UI to flip * `pending → active`. * - * Caller is responsible for gating on `isTenanciesModuleEnabled(portId)` — - * this function does NOT check the flag itself (per design line 132: the - * webhook caller short-circuits when the module is off). Service-level - * idempotency: skips any berth that already has a non-terminal tenancy in - * `(pending, active)` so a webhook re-delivery never double-mints. + * The webhook caller does not gate on `isTenanciesModuleEnabled` — the + * row-exists fallback in that helper lazily surfaces the module from the + * first successful insert onwards, which is what unlocks the very first + * tenancy on a fresh port. Service-level idempotency: skips any berth that + * already has a non-terminal tenancy in `(pending, active)` so a webhook + * re-delivery never double-mints. * * Returns the newly-inserted rows (empty array when nothing to mint). */ diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index 1f041d4c..ba0738fd 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -1683,15 +1683,13 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p evaluateRule('contract_signed', doc.interestId!, doc.portId, systemMeta), ); - // Tenancies P3 — auto-create pending tenancies (one per in-bundle berth) - // when the module is enabled for this port. Gating is at the call site: - // disabled module = stage + docStatus updates still fire, only the - // tenancy mint is skipped (per docs/tenancies-design.md §"When disabled"). + // Tenancies P3 — auto-create pending tenancies (one per in-bundle berth). + // No module gate here: the row-exists fallback in `isTenanciesModuleEnabled` + // lazily surfaces the module after the first signing, which is exactly what + // we want for the first-ever Reservation Agreement on a fresh port. Admins + // can still pre-enable the module to make widgets/sidebar appear earlier. void (async () => { try { - const { isTenanciesModuleEnabled } = - await import('@/lib/services/tenancies-module.service'); - if (!(await isTenanciesModuleEnabled(doc.portId))) return; const { autoCreatePendingTenancies } = await import('@/lib/services/berth-tenancies.service'); // Re-read signedFileId from the post-commit row; the in-tx update