diff --git a/src/app/api/v1/admin/tenancies-module/disable/route.ts b/src/app/api/v1/admin/tenancies-module/disable/route.ts new file mode 100644 index 00000000..66fc313a --- /dev/null +++ b/src/app/api/v1/admin/tenancies-module/disable/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { disableTenanciesModule } from '@/lib/services/tenancies-module.service'; + +/** + * POST /api/v1/admin/tenancies-module/disable — admin-driven disable. + * Data is preserved; only the rendering surfaces hide. Frontend warns + * the operator about the row count before calling this. + */ +export const POST = withAuth( + withPermission('admin', 'manage_settings', async (_req, ctx) => { + try { + await disableTenanciesModule(ctx.portId); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/admin/tenancies-module/enable/route.ts b/src/app/api/v1/admin/tenancies-module/enable/route.ts new file mode 100644 index 00000000..7b6d192e --- /dev/null +++ b/src/app/api/v1/admin/tenancies-module/enable/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { enableTenanciesModule } from '@/lib/services/tenancies-module.service'; + +/** + * POST /api/v1/admin/tenancies-module/enable — admin-driven enable. + * Idempotent; flipping an already-enabled module is a no-op. + */ +export const POST = withAuth( + withPermission('admin', 'manage_settings', async (_req, ctx) => { + try { + await enableTenanciesModule(ctx.portId); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/admin/tenancies-module/status/route.ts b/src/app/api/v1/admin/tenancies-module/status/route.ts new file mode 100644 index 00000000..b2b70d78 --- /dev/null +++ b/src/app/api/v1/admin/tenancies-module/status/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service'; + +/** + * GET /api/v1/admin/tenancies-module/status — surface whether the module + * is currently enabled (via setting OR lazy "any row exists" sentinel) + * so the admin Operations page can render the toggle in the correct state. + */ +export const GET = withAuth( + withPermission('admin', 'manage_settings', async (_req, ctx) => { + try { + const enabled = await isTenanciesModuleEnabled(ctx.portId); + return NextResponse.json({ data: { enabled } }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/lib/services/tenancies-module.service.ts b/src/lib/services/tenancies-module.service.ts new file mode 100644 index 00000000..3c8bc4b3 --- /dev/null +++ b/src/lib/services/tenancies-module.service.ts @@ -0,0 +1,111 @@ +/** + * Tenancies module gate. The entire Tenancies surface (sidebar entry, + * entity tabs, top-level page, dashboard widgets, webhook auto-create + * branch) is hidden by default and only surfaces when EITHER: + * + * (a) at least one `berth_reservations` row exists for the port + * (lazy auto-enable on first creation), OR + * (b) an admin has explicitly enabled the module via + * `system_settings.tenancies_module_enabled` (default false). + * + * Per the locked decision (2026-05-25): a sold berth stays sold without + * any tenancy data — the platform does not assume tenancies exist for + * sold berths. The module only surfaces when one of the two conditions + * above is true. + * + * See `docs/tenancies-design.md` for the full rationale + UX flow. + */ + +import { and, eq, isNull, or } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { systemSettings } from '@/lib/db/schema/system'; +import { berthReservations } from '@/lib/db/schema/reservations'; +import { NotFoundError } from '@/lib/errors'; + +/** + * Resolve whether the Tenancies module is currently active for the + * given port. Honors both the explicit admin setting AND the lazy + * "any row exists" sentinel. + * + * Performance: two indexed reads; the row-exists check uses `LIMIT 1` + * so it stops at the first match. Cached at the service boundary via + * react-query in the UI; never called inside tight loops on the server. + */ +export async function isTenanciesModuleEnabled(portId: string): Promise { + // 1. Admin setting check (port-scoped row first, fall back to global). + const settingRow = await db + .select({ value: systemSettings.value }) + .from(systemSettings) + .where( + and( + eq(systemSettings.key, 'tenancies_module_enabled'), + or(eq(systemSettings.portId, portId), isNull(systemSettings.portId)), + ), + ) + .limit(1); + if (settingRow[0]?.value === true) return true; + + // 2. Lazy auto-enable: any row in the table flips the module on for + // the rest of the app, even when the admin setting is still false. + // Once any port has a tenancy, the module's UX is justified. + const rowCheck = await db + .select({ id: berthReservations.id }) + .from(berthReservations) + .where(eq(berthReservations.portId, portId)) + .limit(1); + return rowCheck.length > 0; +} + +/** + * Idempotent helper for the webhook auto-create branch + admin-driven + * enables to call. Inserts/updates the system_settings row to true. + * Safe to call when already enabled (UPSERT on key+port). + */ +export async function enableTenanciesModule(portId: string): Promise { + await db + .insert(systemSettings) + .values({ + key: 'tenancies_module_enabled', + portId, + value: true, + }) + .onConflictDoUpdate({ + target: [systemSettings.key, systemSettings.portId], + set: { value: true, updatedAt: new Date() }, + }); +} + +/** + * Admin-driven disable. Does NOT delete any tenancies rows — those + * remain in the database and surface again when the module is re-enabled. + * The frontend warns the operator about the row count before calling + * this, so by the time it lands here the intent is confirmed. + */ +export async function disableTenanciesModule(portId: string): Promise { + await db + .insert(systemSettings) + .values({ + key: 'tenancies_module_enabled', + portId, + value: false, + }) + .onConflictDoUpdate({ + target: [systemSettings.key, systemSettings.portId], + set: { value: false, updatedAt: new Date() }, + }); +} + +/** + * Convenience throw-on-disabled helper for route handlers and services + * that should hard-fail (404 / NotFound) when the module is off. Most + * Tenancies-scoped routes wrap their handler body in this. + */ +export async function assertTenanciesModuleEnabled(portId: string): Promise { + const enabled = await isTenanciesModuleEnabled(portId); + if (!enabled) { + // Route handlers map NotFoundError to 404; the sidebar + entity tabs + // independently gate so the rep never sees a link that would 404. + throw new NotFoundError('Tenancies module is not enabled for this port.'); + } +} diff --git a/src/lib/settings/registry.ts b/src/lib/settings/registry.ts index d96fa1a4..aba52898 100644 --- a/src/lib/settings/registry.ts +++ b/src/lib/settings/registry.ts @@ -600,6 +600,24 @@ export const REGISTRY: SettingEntry[] = [ placeholder: 'Cold', }, + // ─── Operations - Tenancies module ──────────────────────────────────────── + // Platform-wide gate for the Tenancies (occupancy-record) surface area. + // Disabled by default. A first row INSERT on the `tenancies` table flips + // this on automatically (`pg_advisory_xact_lock` per port keeps the flip + // race-safe). Admins can also enable explicitly from Admin -> Operations, + // and disabling with existing rows is a soft hide (data is preserved but + // invisible until re-enabled). + { + 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. Auto-enables on the first tenancy created (e.g. via a signed Reservation Agreement).', + type: 'boolean', + scope: 'port', + defaultValue: false, + }, + // ─── Residential - partner forwarding ────────────────────────────────────── { key: 'residential_partner_recipients',