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:
2026-05-31 18:49:16 +02:00
parent cb8292464c
commit 172af02f81
13 changed files with 380 additions and 1 deletions

View File

@@ -0,0 +1,44 @@
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports as portsTable } from '@/lib/db/schema/ports';
import { isResidentialModuleEnabled } from '@/lib/services/residential-module.service';
import { ModuleDisabledPage } from '@/components/shared/module-disabled-page';
interface ResidentialLayoutProps {
children: React.ReactNode;
params: Promise<{ portSlug: string }>;
}
/**
* Layout-level gate for the entire /residential subtree (clients +
* interests, list + detail). When the port has
* residential_module_enabled = false, every route under /residential
* renders the ModuleDisabledPage instead of the real content. This is
* the route-level half of the "hybrid hide+block" model (the sidebar
* "Residential" section + mobile entry are independently hidden via
* residentialModuleByPort on the SSR-resolved sidebar prop).
*
* Using a layout rather than per-page guards means: (a) one place to
* change the gate logic, (b) nested routes ([id]) are covered
* automatically, (c) the children subtree never mounts when disabled,
* so its data-fetching effects don't fire.
*/
export default async function ResidentialLayout({ children, params }: ResidentialLayoutProps) {
const { portSlug } = await params;
const port = await db.query.ports.findFirst({
where: eq(portsTable.slug, portSlug),
columns: { id: true },
});
if (!port) return children;
const enabled = await isResidentialModuleEnabled(port.id);
if (enabled) return children;
return (
<ModuleDisabledPage
moduleName="Residential"
description="The Residential clients and interests pipeline is turned off for this port. Existing residential records are preserved and will reappear when the module is re-enabled."
settingsHref={`/${portSlug}/admin/settings`}
fallbackHref={`/${portSlug}/dashboard`}
/>
);
}

View File

@@ -19,6 +19,7 @@ import { classifyFormFactor } from '@/lib/form-factor';
import { getPortBrandingConfig } from '@/lib/services/port-config';
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
import { isResidentialModuleEnabled } from '@/lib/services/residential-module.service';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const headerList = await headers();
@@ -108,6 +109,24 @@ export default async function DashboardLayout({ children }: { children: React.Re
);
const expensesModuleByPort: Record<string, boolean> = Object.fromEntries(expensesModuleEntries);
// Per-port residential-module gate. Defaults to enabled (the registry's
// default) so existing ports keep the feature on deploy. Resolved
// server-side so the sidebar "Residential" section SSRs in/out without
// flicker when an admin has turned the feature off for a tenant.
const residentialModuleEntries = await Promise.all(
ports.map(async (p) => {
try {
return [p.id, await isResidentialModuleEnabled(p.id)] as const;
} catch {
// Conservative default on lookup failure: keep the feature
// visible so a transient DB hiccup doesn't hide the module.
return [p.id, true] as const;
}
}),
);
const residentialModuleByPort: Record<string, boolean> =
Object.fromEntries(residentialModuleEntries);
return (
<QueryProvider>
<PortProvider
@@ -136,6 +155,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
portLogoUrls={portLogoUrls}
tenanciesModuleByPort={tenanciesModuleByPort}
expensesModuleByPort={expensesModuleByPort}
residentialModuleByPort={residentialModuleByPort}
initialFormFactor={initialFormFactor}
>
{children}

View File

@@ -14,6 +14,7 @@ import {
import { resolveSubject } from '@/lib/email/resolve-subject';
import { getBrandingShell } from '@/lib/email/branding-resolver';
import { getPortBrandingConfig, getPortEmailConfig } from '@/lib/services/port-config';
import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service';
import { env } from '@/lib/env';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
@@ -61,6 +62,12 @@ export async function POST(req: NextRequest) {
throw new ValidationError('Unknown port');
}
// Reject when the port has the Residential module turned off — a
// disabled port shouldn't silently accept residential leads it can't
// see in the CRM. Throws NotFoundError → 404 (mirrors the v1 route
// + entity-tab gates for the other module toggles).
await assertResidentialModuleEnabled(portId);
// If the website didn't pre-normalize, parse server-side. International
// strings parse without a hint; national-format submissions need a country.
let phoneE164 = data.phoneE164 ?? null;

View File

@@ -63,6 +63,14 @@ const KNOWN_SETTINGS: Array<{
type: 'boolean',
defaultValue: true,
},
{
key: 'residential_module_enabled',
label: 'Residential Module',
description:
'Enable the residential (non-berth) clients + interests pipeline for this port. On by default. Disabling hides the Residential section from the sidebar and mobile nav, blocks the /residential routes with a "module disabled" page, drops residential records out of global search, and stops the public residential-inquiry endpoint from accepting new leads. Previously-recorded residential clients and interests are preserved and reappear when you re-enable.',
type: 'boolean',
defaultValue: true,
},
{
key: 'ai_interest_scoring',
label: 'AI Interest Scoring',

View File

@@ -32,6 +32,10 @@ interface AppShellProps {
* + How-to-upload-receipts sidebar entries SSR-side. Defaults to
* true so existing ports keep the feature. */
expensesModuleByPort: Record<string, boolean>;
/** Per-port `residential_module_enabled` resolution. Gates the
* "Residential" sidebar section + mobile entry SSR-side. Defaults to
* true so existing ports keep the feature. */
residentialModuleByPort: Record<string, boolean>;
/**
* Server-rendered form-factor hint (from the request User-Agent). The
* shell mounts the matching tree on first render so we never paint the
@@ -96,6 +100,7 @@ export function AppShell({
portLogoUrls,
tenanciesModuleByPort,
expensesModuleByPort,
residentialModuleByPort,
initialFormFactor,
children,
}: AppShellProps) {
@@ -104,6 +109,7 @@ export function AppShell({
const [searchOpen, setSearchOpen] = useState(false);
const [tabletSidebarOpen, setTabletSidebarOpen] = useState(false);
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
const currentPortId = useUIStore((s) => s.currentPortId);
const logoUrl = currentPortSlug ? portLogoUrls[currentPortSlug] : null;
useEffect(() => {
@@ -149,8 +155,15 @@ export function AppShell({
portLogoUrls,
tenanciesModuleByPort,
expensesModuleByPort,
residentialModuleByPort,
};
// Resolve the current port's residential flag for the mobile More sheet
// (the sidebar resolves its own copy internally from the by-port map).
const residentialModuleEnabled = currentPortId
? (residentialModuleByPort[currentPortId] ?? true)
: true;
// Chrome subtree per tier.
let chrome: ReactNode = null;
if (isMobile) {
@@ -177,7 +190,11 @@ export function AppShell({
onMoreClick={() => setMoreOpen(true)}
onSearchClick={() => setSearchOpen(true)}
/>
<MoreSheet open={moreOpen} onOpenChange={setMoreOpen} />
<MoreSheet
open={moreOpen}
onOpenChange={setMoreOpen}
residentialModuleEnabled={residentialModuleEnabled}
/>
<MobileSearchOverlay open={searchOpen} onOpenChange={setSearchOpen} />
</>
) : null;

View File

@@ -81,9 +81,14 @@ const MORE_GROUPS: MoreGroup[] = [
export function MoreSheet({
open,
onOpenChange,
residentialModuleEnabled = true,
}: {
open: boolean;
onOpenChange: (next: boolean) => void;
/** Per-port residential-module gate, resolved SSR-side in the shell.
* Hides the Residential tile when the module is off. Defaults to true
* so a missing value keeps the feature visible (registry default). */
residentialModuleEnabled?: boolean;
}) {
const pathname = usePathname();
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
@@ -98,6 +103,7 @@ export function MoreSheet({
...g,
items: g.items.filter((item) => {
if (item.segment === 'website-analytics') return umamiConfigured;
if (item.segment === 'residential/clients') return residentialModuleEnabled;
return true;
}),
})).filter((g) => g.items.length > 0);

View File

@@ -61,6 +61,11 @@ interface SidebarProps {
* the dashboard layout. Defaults to true (feature on) per port when
* the map is missing for the active port. */
expensesModuleByPort?: Record<string, boolean>;
/** Per-port `residential_module_enabled` resolution. Gates the entire
* "Residential" sidebar section. Resolved server-side in the dashboard
* layout. Defaults to true (feature on) per port when the map is
* missing for the active port. */
residentialModuleByPort?: Record<string, boolean>;
}
interface NavItem {
@@ -78,6 +83,9 @@ interface NavSection {
marinaRequired?: boolean;
/** When true, only render if the user has residential-side access. */
residentialRequired?: boolean;
/** When true, only render if the residential module is enabled for the
* current port. Resolved against `residentialModuleByPort`. */
requiresResidentialModule?: boolean;
/** When true, only render if Umami analytics is wired up for the port. */
umamiRequired?: boolean;
}
@@ -119,6 +127,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
{
title: 'Residential',
residentialRequired: true,
requiresResidentialModule: true,
items: [
{
href: `${base}/residential/clients`,
@@ -280,6 +289,7 @@ function SidebarContent({
hasResidentialAccess,
tenanciesModuleEnabled,
expensesModuleEnabled,
residentialModuleEnabled,
user,
ports,
currentPort,
@@ -295,6 +305,7 @@ function SidebarContent({
hasResidentialAccess: boolean;
tenanciesModuleEnabled: boolean;
expensesModuleEnabled: boolean;
residentialModuleEnabled: boolean;
user?: SidebarProps['user'];
ports?: Port[];
currentPort: Port | null;
@@ -388,6 +399,7 @@ function SidebarContent({
if (section.adminRequired && !hasAdminAccess) return null;
if (section.marinaRequired && !hasMarinaAccess) return null;
if (section.residentialRequired && !hasResidentialAccess) return null;
if (section.requiresResidentialModule && !residentialModuleEnabled) return null;
if (section.umamiRequired && !umamiConfigured) return null;
return (
@@ -514,6 +526,7 @@ export function Sidebar({
portLogoUrls,
tenanciesModuleByPort,
expensesModuleByPort,
residentialModuleByPort,
}: SidebarProps) {
// Sidebar collapse removed - design preference is the always-expanded
// form. Forcibly false; the store flag stays for backwards-compat with
@@ -532,6 +545,12 @@ export function Sidebar({
const expensesModuleEnabled = currentPortId
? (expensesModuleByPort?.[currentPortId] ?? true)
: true;
// Residential defaults to enabled when the port's entry is missing -
// the registry default is `true`, so a port that's never explicitly
// toggled the feature keeps the section visible.
const residentialModuleEnabled = currentPortId
? (residentialModuleByPort?.[currentPortId] ?? true)
: true;
// Super admins see every section regardless of role rows.
const hasAdminAccess =
@@ -565,6 +584,7 @@ export function Sidebar({
hasResidentialAccess={hasResidentialAccess}
tenanciesModuleEnabled={tenanciesModuleEnabled}
expensesModuleEnabled={expensesModuleEnabled}
residentialModuleEnabled={residentialModuleEnabled}
user={user}
ports={ports}
currentPort={currentPort}

View 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.');
}
}

View File

@@ -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<{

View File

@@ -662,6 +662,28 @@ export const REGISTRY: SettingEntry[] = [
defaultValue: false,
},
// ─── Operations - Residential module ──────────────────────────────────────
// Port-scoped gate for the entire Residential surface (sidebar
// "Residential" section, /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. Disabling hides the sidebar section + mobile entry,
// swaps the routes for a "module disabled" page, drops residential out
// of search, and hard-fails the public intake endpoint. Disabling does
// not delete residential clients/interests — they reappear on re-enable.
{
key: 'residential_module_enabled',
section: 'operations.residential',
label: 'Residential module',
description:
'When enabled, the platform tracks residential (non-berth) clients and their interests through a configurable pipeline, accepts residential inquiries from the website, and surfaces them in search. Turning this off hides the Residential section from the sidebar and blocks its routes with a "module disabled" page, and stops the public residential-inquiry endpoint from accepting new leads. Disabling does not delete previously-recorded residential records.',
type: 'boolean',
scope: 'port',
defaultValue: true,
},
// ─── Residential - partner forwarding ──────────────────────────────────────
{
key: 'residential_partner_recipients',