Files
pn-new-crm/docs/tenancies-design.md
Matt 353a31323e fix(tenancies): unblock first-tenancy chicken-and-egg in webhook
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) <noreply@anthropic.com>
2026-05-26 18:48:15 +02:00

19 KiB
Raw Blame History

Tenancies Module Design

Status: Design doc. All Q-block decisions locked 2026-05-24 via AskUserQuestion + a follow-up platform-wide module-enabled rule locked 2026-05-25 in the alpha UAT master doc. Implementation phased into discrete PRs at the end.

Vocabulary split (the foundational decision)

The pipeline-stage reservation + the signed Reservation Agreement keep their names — they describe the right being reserved, not the occupancy that results.

The occupancy record (berth_reservations table + sidebar + entity tabs + top-level page) is renamed Tenancy:

Concept Lives in Name (post-rename)
Pipeline stage where the rep targets a berth interests.pipelineStage reservation (unchanged)
The signed legal document documents w/ documentType='reservation_agreement' Reservation Agreement (unchanged)
The record of who's tied up at a berth tenancies (was berth_reservations) Tenancy

A signed Reservation Agreement → results in a Tenancy.


Platform-wide module-enabled rule

The entire Tenancies module surface is hidden by default.

A sold berth stays sold without any tenancy data — the platform does not assume tenancies exist for sold berths. The module only surfaces when EITHER:

  • (a) at least one tenancies row exists for the port (lazy auto-enable on first creation, including auto-create from a signed Reservation Agreement), OR
  • (b) an admin has explicitly enabled it via system_settings.tenancies_module_enabled (default false).

When disabled

  • Sidebar entry hidden
  • Client / Yacht / Berth Tenancies tab hidden
  • All four reporting widgets hidden from dashboard registry
  • Top-level /{portSlug}/tenancies page returns 404
  • 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

Full module surfaces.

Admin toggle

Admin → Operations → "Tenancies module" Switch:

  • Helper copy: "When enabled, the platform tracks who occupies each berth (Tenancies). Without it, sold berths stay sold but the platform doesn't model the occupancy record."
  • Warning on disable with rows: Modal — "This will hide N existing tenancies. Data is preserved but invisible until re-enabled. Continue?"
  • Auto-enable on first insert: The first row INSERT on tenancies flips tenancies_module_enabled=true in the same transaction (pg_advisory_xact_lock per port to avoid races).
  • Never auto-disables.

Data model

Rename migration

-- 008X_rename_reservations_to_tenancies.sql
ALTER TABLE berth_reservations RENAME TO tenancies;

-- Self-FKs for renewals + transfers.
ALTER TABLE tenancies
  ADD COLUMN previous_tenancy_id text REFERENCES tenancies(id) ON DELETE SET NULL,
  ADD COLUMN transferred_from_tenancy_id text REFERENCES tenancies(id) ON DELETE SET NULL;

CREATE INDEX tenancies_previous_id_idx ON tenancies(previous_tenancy_id) WHERE previous_tenancy_id IS NOT NULL;
CREATE INDEX tenancies_transferred_from_id_idx ON tenancies(transferred_from_tenancy_id) WHERE transferred_from_tenancy_id IS NOT NULL;

Schema TypeScript also renames: src/lib/db/schema/reservations.tstenancies.ts, berthReservationstenancies. Adjust all imports.

tenure_type discriminator (unchanged from existing union)

permanent | fee_simple | strata_lot | seasonal | fixed_term

Behaviour by type:

tenure_type Renewals Public map flip
permanent Mutate existing row (one record forever) Sets berths.status='sold'
fee_simple Mutate existing row Sets berths.status='sold'
strata_lot Mutate existing row Sets berths.status='sold'
seasonal New row each cycle, previous_tenancy_id links No status flip — temporary
fixed_term New row each cycle, previous_tenancy_id links No status flip — temporary

Transfers

Two-step operation:

  1. End old tenancy: UPDATE tenancies SET status='ended', end_date=transfer_date WHERE id=:old.
  2. Mint new tenancy: INSERT INTO tenancies (..., transferred_from_tenancy_id=:old) VALUES (...) for the new client.

Both steps in one transaction; same berth, different client. Preserves history.

Module-enabled setting

Add to src/lib/settings/registry.ts:

{
  key: 'tenancies_module_enabled',
  section: 'operations.tenancies',
  label: 'Tenancies module',
  description: 'When enabled, the platform tracks who occupies each berth (Tenancies). Without it, sold berths stay sold but the platform does not model the occupancy record.',
  type: 'boolean',
  defaultValue: false,
  scope: 'port',
}

Permissions

Three new perms in src/lib/db/seed-permissions.ts:

Perm Default ON for Notes
tenancies.view super_admin, director, sales_manager, sales_agent, finance_manager, viewer Read access.
tenancies.manage super_admin, sales_manager, sales_agent Create / mutate / transfer.
tenancies.cancel super_admin, sales_manager Cancel only. Carved out because cancellation has revenue implications.

Every Tenancies surface respects both tenancies.view AND tenancies_module_enabled — the module-enabled gate is checked first.


Webhook auto-create branch

Inside handleDocumentCompleted (src/lib/services/documents.service.ts):

// After signedFileId is committed + post-completion email queues, branch:
if (doc.documentType === 'reservation_agreement') {
  const moduleEnabled = await isTenanciesModuleEnabled(doc.portId);
  if (moduleEnabled) {
    await autoCreatePendingTenancies(doc.portId, doc.interestId, {
      signedAt: completedAt,
      sourceDocumentId: doc.id,
      userId: 'system',
    });
  }
  // Stage advance + reservationDocStatus flip happen regardless.
}

autoCreatePendingTenancies loops over interest_berths WHERE interest_id = :interestId AND is_in_eoi_bundle = TRUE and inserts ONE tenancy row per in-bundle berth (locked Q4 decision: "one tenancy per in-bundle berth"). Status pending; rep confirms startDate + tenureType in a follow-up modal before pending → active. Default startDate = signed date when not on the doc.

The first insert in a port flips tenancies_module_enabled=true (lazy auto-enable).


Public map status flip

src/lib/services/berths.service.ts (status precedence resolver):

sold > under_offer > available

Sold can come from:
  1. berths.status = 'sold' (explicit admin set)
  2. An active tenancy with tenure_type IN ('permanent', 'fee_simple', 'strata_lot') exists for this berth

The new branch (2) only fires when tenancies_module_enabled = true. When disabled OR the only active tenancies are seasonal / fixed_term, fall through to existing precedence (under_offer / available based on interest links).

Reversal: when an active permanent-class tenancy ends + no replacement is active for the same berth, the auto-derived sold lifts. Explicit berths.status='sold' (admin-set) stays sold.


Sidebar entry

src/components/layout/sidebar.tsx: add Tenancies entry below Berths, gated by:

  • tenancies.view permission
  • tenancies_module_enabled = true (resolved server-side; SSR'd into the sidebar so it never flickers in)

Icon: KeyRound from lucide.


Top-level page — /{portSlug}/tenancies

Returns 404 when module disabled. When enabled:

  • Filters: status (active / pending / ended / cancelled), tenure_type, berth-area, client search.
  • Columns: Berth · Client · Yacht · Tenure type · Status · Start · End · Last renewal.
  • Row actions: Open detail · Edit · Renew (tenure-type aware) · Transfer · End / Cancel.
  • Bulk actions: End multiple (with tenancies.cancel).
  • "+ New tenancy" CTA top-right (gated on tenancies.manage).

Entity-tab CTAs

On Client / Yacht / Berth detail pages, the existing read-only tenancies tab gets a refreshed empty state when module is enabled but no rows exist:

┌─────────────────────────────────────────────────────────────────┐
│  [icon] No tenancies yet                                        │
│                                                                 │
│  This <client/yacht/berth> doesn't have any tenancies on file.  │
│                                                                 │
│  [ Create tenancy ]  (only when user has tenancies.manage)      │
└─────────────────────────────────────────────────────────────────┘

The "Create tenancy" button opens a pre-filled <TenancyCreateDialog> with the parent entity already selected. Berth context pre-fills berth_id, Client pre-fills client_id, Yacht pre-fills yacht_id.

When tenancies_module_enabled = false: the whole tab is hidden (entity tabs registry gates).


Reporting widgets (all four, all module-gated)

Locked Q7: ship all four in v1, every one gated by tenancies_module_enabled.

  1. Occupancy heatmap by month — Per-berth-area grid: rows = berth areas, columns = months for the active date range, cell shade = % months occupied. Data from tenancies.startDate / endDate overlap with each month.
  2. Renewals at risk (next 90 days) — Table of active tenancies whose endDate IS NOT NULL AND endDate <= now() + 90d AND no successor row exists yet. Click-through opens the tenancy with "Renew" CTA pre-focused.
  3. Revenue forecast by tenure expiry — Forward projection per quarter: sum of berth-price × remaining-tenure for active rows; bucketed by quarter ending date. Highlights revenue cliffs.
  4. Tenancy by tenure type breakdown — Donut + table of active tenancies grouped by tenure_type. Operational mix at a glance.

Each widget registers in src/components/dashboard/widget-registry.tsx with:

{
  id: 'tenancy_occupancy_heatmap',
  label: 'Occupancy heatmap',
  render: (range) => <TenancyOccupancyHeatmap range={range} />,
  group: 'chart',
  defaultVisible: true,
  selfGates: true,
  requires: 'tenancies_module',  // new gating channel
}

The tenancies_module integration check resolves to tenancies_module_enabled === true. When false → widget filtered out of both the dashboard render AND the customize picker.


Service layer additions

src/lib/services/berth-tenancies.service.ts (renamed from berth-reservations.service.ts):

  • listTenancies({ portId, filters, page }) — gated read.
  • createTenancy(portId, data, meta) — mints a row; also triggers the module-enable flip on first insert.
  • updateTenancy(portId, id, data, meta).
  • renewTenancy(portId, id, data, meta) — picks mutate-in-place vs new-row branch based on tenure_type.
  • transferTenancy(portId, id, newClientId, transferDate, meta).
  • cancelTenancy(portId, id, reason, meta) — gated on tenancies.cancel.
  • endTenancy(portId, id, endDate, meta).
  • autoCreatePendingTenancies(portId, interestId, opts) — webhook auto-create branch.

src/lib/services/tenancies-module.service.ts (new):

  • isTenanciesModuleEnabled(portId) — checks setting OR EXISTS (SELECT 1 FROM tenancies WHERE port_id = $1) to surface the lazy state.
  • enableTenanciesModule(portId, meta) — admin-driven enable.
  • disableTenanciesModule(portId, meta) — admin-driven disable; the warning copy lives in the admin UI.

API surface (/api/v1/tenancies/*)

All routes gated on tenancies.view (read) or tenancies.manage / tenancies.cancel (write). Each handler additionally calls assertTenanciesModuleEnabled(portId) first — returns 404 when off (matches the sidebar/top-level page behaviour).

Verb Path Permission
GET /api/v1/tenancies tenancies.view
GET /api/v1/tenancies/[id] tenancies.view
POST /api/v1/tenancies tenancies.manage
PATCH /api/v1/tenancies/[id] tenancies.manage
POST /api/v1/tenancies/[id]/renew tenancies.manage
POST /api/v1/tenancies/[id]/transfer tenancies.manage
POST /api/v1/tenancies/[id]/end tenancies.manage
POST /api/v1/tenancies/[id]/cancel tenancies.cancel
GET /api/v1/admin/tenancies-module/status admin.manage_settings
POST /api/v1/admin/tenancies-module/enable admin.manage_settings
POST /api/v1/admin/tenancies-module/disable admin.manage_settings

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 + 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. <TenancyCreateDialog> 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.


Open follow-ups (intentionally deferred past v1)

  • Auto-invoicing on tenancy lifecycle. Locked: v1 ships READ-ONLY — no auto-invoice on tenancy create / renew / end. Revisit once we see how ports actually use the tenancy data.
  • Strict-block duplicate-tenancy toggle. Locked: out of scope. No admin-configurable "block creating a tenancy if one already exists for this berth." Keep dead-simple now.
  • Warning for closed-outcome siblings. Out of scope.
  • Cross-tenant warnings. Out of scope (already enforced by port_id constraints).

Capture in docs/BACKLOG.md after P5 ships.