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>
This commit is contained in:
107
src/lib/services/residential-module.service.ts
Normal file
107
src/lib/services/residential-module.service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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.');
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import { match } from 'ts-pattern';
|
||||
import { db } from '@/lib/db';
|
||||
import { redis } from '@/lib/redis';
|
||||
import type { RolePermissions } from '@/lib/db/schema/users';
|
||||
import { isResidentialModuleEnabled } from '@/lib/services/residential-module.service';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -499,6 +500,12 @@ async function searchResidentialClients(
|
||||
query: string,
|
||||
limit: number,
|
||||
): Promise<ResidentialClientResult[]> {
|
||||
// Module gate (in addition to the per-caller permission check): when a
|
||||
// port has the Residential module turned off, its residential records
|
||||
// must not surface in global search — clicking one would dead-end on
|
||||
// the route-level "module disabled" page. Single chokepoint covers both
|
||||
// the all-buckets fan-out and the single-bucket (type=) path.
|
||||
if (!(await isResidentialModuleEnabled(portId))) return [];
|
||||
const tsQ = buildPrefixTsquery(query) ?? NEVER_TSQUERY;
|
||||
const phoneQ = normalizePhoneQuery(query) ?? NEVER_PHONE;
|
||||
const ilikePattern = `%${query}%`;
|
||||
@@ -727,6 +734,8 @@ async function searchResidentialInterests(
|
||||
query: string,
|
||||
limit: number,
|
||||
): Promise<ResidentialInterestResult[]> {
|
||||
// Module gate — see searchResidentialClients for rationale.
|
||||
if (!(await isResidentialModuleEnabled(portId))) return [];
|
||||
const ilikePattern = `%${query}%`;
|
||||
|
||||
const rows = await db.execute<{
|
||||
|
||||
Reference in New Issue
Block a user