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>
303 lines
19 KiB
Markdown
303 lines
19 KiB
Markdown
# 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.
|