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:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user