feat(tenancies-p1): module-enabled gate + admin toggle endpoints
Part of the locked Tenancies module design (docs/tenancies-design.md).
This PR is the gating infrastructure — the actual table rename
(berth_reservations -> tenancies) + self-FKs + perm-rename + sidebar
entry land in subsequent PRs.
What ships:
- `system_settings.tenancies_module_enabled` registry entry (port-scoped
boolean, default false). Surfaces in the registry-driven admin form
+ the resolveForAdminAPI chain.
- `src/lib/services/tenancies-module.service.ts` with:
* isTenanciesModuleEnabled(portId) — checks the admin setting AND
the lazy "any berth_reservations row exists" sentinel
* enableTenanciesModule / disableTenanciesModule — idempotent
upserts on the system_settings row
* assertTenanciesModuleEnabled — throw-on-disabled helper for
route handlers (NotFoundError -> 404)
- Three admin endpoints under /api/v1/admin/tenancies-module/
(status / enable / disable), all gated on admin.manage_settings.
Behaviour today: with the module off (default), nothing changes.
Sidebar, entity tabs, top-level page, webhook auto-create branch,
and dashboard widgets all continue to read the same flag and stay
hidden until either an admin toggles it ON or the first auto-create
flips it via the lazy "row exists" sentinel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
21
src/app/api/v1/admin/tenancies-module/disable/route.ts
Normal file
21
src/app/api/v1/admin/tenancies-module/disable/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
20
src/app/api/v1/admin/tenancies-module/enable/route.ts
Normal file
20
src/app/api/v1/admin/tenancies-module/enable/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
21
src/app/api/v1/admin/tenancies-module/status/route.ts
Normal file
21
src/app/api/v1/admin/tenancies-module/status/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
111
src/lib/services/tenancies-module.service.ts
Normal file
111
src/lib/services/tenancies-module.service.ts
Normal file
@@ -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<boolean> {
|
||||||
|
// 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -600,6 +600,24 @@ export const REGISTRY: SettingEntry[] = [
|
|||||||
placeholder: 'Cold',
|
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 ──────────────────────────────────────
|
// ─── Residential - partner forwarding ──────────────────────────────────────
|
||||||
{
|
{
|
||||||
key: 'residential_partner_recipients',
|
key: 'residential_partner_recipients',
|
||||||
|
|||||||
Reference in New Issue
Block a user