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

303 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```sql
-- 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.ts``tenancies.ts`, `berthReservations``tenancies`. 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`:
```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`):
```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:
```ts
{
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.