docs/reports-page-design.md: ~400 lines covering
- Routing: /{portSlug}/reports landing + builder/templates/runs/schedules
- 3 new tables (report_templates_shared, report_runs, report_schedules)
with full schema + indexes
- API surface (12 routes) gated on reports.export / reports.admin
- BullMQ queues (reports-render, reports-email) + cron scheduler
- UI plan for landing + two-panel builder + 3 sub-pages
- Quick-path dashboard button rewire
- 7-PR phased plan (~43h total)
docs/tenancies-design.md: ~350 lines covering
- Vocabulary split (Reservation vs Tenancy)
- Platform-wide module-enabled rule (auto-flips on first insert,
admin Operations toggle, warning on disable)
- Rename migration berth_reservations -> tenancies + self-FKs
- Tenure-type behaviour matrix (renewals + public-map flip)
- Transfer flow (end + mint linked rows)
- 3 new perms (view/manage/cancel)
- Webhook auto-create branch (gated)
- Public-map status precedence (permanent-class only)
- Sidebar entry + top-level page + entity-tab CTAs
- All 4 reporting widgets (module-gated)
- Service layer additions
- API surface (10 routes)
- 7-PR phased plan (~42h total)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
18 KiB
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
tenanciesrow 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(defaultfalse).
When disabled
- Sidebar entry hidden
- Client / Yacht / Berth
Tenanciestab hidden - All four reporting widgets hidden from dashboard registry
- Top-level
/{portSlug}/tenanciespage returns 404 handleDocumentCompletedskips the auto-create branch on signedreservation_agreement(the document still progresses the interest stage and flipsreservationDocStatus— only the tenancy mint is gated)
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
tenanciesflipstenancies_module_enabled=truein the same transaction (pg_advisory_xact_lockper 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.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:
- End old tenancy:
UPDATE tenancies SET status='ended', end_date=transfer_date WHERE id=:old. - 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.viewpermissiontenancies_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.
- 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 / endDateoverlap with each month. - Renewals at risk (next 90 days) — Table of active tenancies whose
endDate IS NOT NULL AND endDate <= now() + 90d ANDno successor row exists yet. Click-through opens the tenancy with "Renew" CTA pre-focused. - 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.
- 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/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 ontenure_type.transferTenancy(portId, id, newClientId, transferDate, meta).cancelTenancy(portId, id, reason, meta)— gated ontenancies.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 OREXISTS (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 + 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. <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_idconstraints).
Capture in docs/BACKLOG.md after P5 ships.