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;