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:
44
src/app/(dashboard)/[portSlug]/residential/layout.tsx
Normal file
44
src/app/(dashboard)/[portSlug]/residential/layout.tsx
Normal 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`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user