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>
108 lines
3.9 KiB
TypeScript
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.');
|
|
}
|
|
}
|