Files
pn-new-crm/src/lib/services/residential-module.service.ts
Matt 172af02f81 feat(residential-toggle): port-level module gate for Residential
Adds a `residential_module_enabled` port setting (default ON) that
hides/disables the entire Residential surface when an admin turns it
off, mirroring the Tenancies / Invoices / Expenses module-toggle
pattern. Disabling is a soft hide — residential clients/interests are
preserved and reappear on re-enable.

Surfaces gated:
- Route guard: new residential/layout.tsx renders ModuleDisabledPage
  (covers all 5 residential pages)
- Sidebar "Residential" section + mobile more-sheet tile (SSR-resolved
  residentialModuleByPort threaded layout → app-shell → sidebar)
- Global search: residential client/interest buckets early-return at
  the shared chokepoint so disabled-port records don't dead-end
- Public intake: /api/public/residential-inquiries 404s when off
- Admin Switch in settings-manager (writes via settings PUT)

Service TDD'd (residential-module.test.ts, 6 tests) plus a
disabled-port rejection test on the public endpoint. tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:49:16 +02:00

108 lines
3.9 KiB
TypeScript

/**
* Residential module gate. Port-scoped on/off switch for the entire
* Residential surface (sidebar "Residential" section, the
* /residential/clients + /residential/interests pages, the admin
* residential-stages page, the global-search residential buckets, and
* the public residential-inquiry intake endpoint).
*
* Defaults to ENABLED so existing ports keep the feature on deploy —
* residential is in active use, unlike Tenancies / Invoices which are
* opt-in. When an admin turns it off:
* - the sidebar "Residential" section + mobile "Residential" entry
* disappear via the port-resolved residentialModuleByPort prop
* - the /residential/* and admin/residential-stages routes render a
* "module disabled" page instead of the real content, so bookmarks
* land somewhere meaningful and direct API hits are rejected at the
* layout boundary
* - the public /api/public/residential-inquiries endpoint hard-fails
* so a disabled port stops accepting residential leads it can't see
* - previously-recorded residential clients/interests are preserved
* (no destructive cleanup) so re-enabling restores everything
*
* Mirrors the Tenancies / Expenses / Invoices module-gate pattern.
*/
import { and, eq, isNull, or } from 'drizzle-orm';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { NotFoundError } from '@/lib/errors';
const SETTING_KEY = 'residential_module_enabled';
/**
* Resolve whether the Residential module is currently active for the
* given port. Reads from `system_settings.residential_module_enabled`
* (port-scoped row first, then global row, then registry default = true).
*
* Defaulting to enabled mirrors how residential behaved before the
* toggle existed: deploying this change to a port that has never
* configured the setting leaves the feature visible.
*/
export async function isResidentialModuleEnabled(portId: string): Promise<boolean> {
const settingRow = await db
.select({ value: systemSettings.value })
.from(systemSettings)
.where(
and(
eq(systemSettings.key, SETTING_KEY),
or(eq(systemSettings.portId, portId), isNull(systemSettings.portId)),
),
)
.limit(1);
// Stored JSONB shape is the raw boolean (`true` / `false`); the admin-
// settings PUT handler writes the primitive directly. Only an explicit
// `false` disables — a missing row / true / unrecognized shape means
// enabled, matching the registry default.
if (settingRow[0]?.value === false) return false;
return true;
}
/**
* Admin-driven enable. Idempotent — safe to call when already enabled
* (UPSERT on key+port).
*/
export async function enableResidentialModule(portId: string): Promise<void> {
await db
.insert(systemSettings)
.values({
key: SETTING_KEY,
portId,
value: true,
})
.onConflictDoUpdate({
target: [systemSettings.key, systemSettings.portId],
set: { value: true, updatedAt: new Date() },
});
}
/**
* Admin-driven disable. Does NOT delete any residential 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.
*/
export async function disableResidentialModule(portId: string): Promise<void> {
await db
.insert(systemSettings)
.values({
key: SETTING_KEY,
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.
*/
export async function assertResidentialModuleEnabled(portId: string): Promise<void> {
const enabled = await isResidentialModuleEnabled(portId);
if (!enabled) {
throw new NotFoundError('Residential module is not enabled for this port.');
}
}