diff --git a/docs/superpowers/audits/active-uat.md b/docs/superpowers/audits/active-uat.md index c95a8ada..61fcd86a 100644 --- a/docs/superpowers/audits/active-uat.md +++ b/docs/superpowers/audits/active-uat.md @@ -602,6 +602,41 @@ The catch: most ports will have ~3 templates total (EOI, Reservation Agreement, - **Fix proposal:** replace the type+picker pair with a single unified search field (same idiom as the global Command-search). Typing surfaces matching clients/companies/yachts/interests/tenancies inline, each row carrying its type label as a badge. Recent interactions surface first when the input is empty. The chosen entity sets both `subjectType` and `subjectId` in one click. - **Bundle with:** the larger wizard refactor (above) — if `/documents/new` becomes a ``, this is the natural place to ship the unified subject picker as one consistent pattern. +### Admin toggle to disable Residential entirely (module gate) + +- **`SHIPPED locally (not yet committed) — 2026-05-31`** — net-new wiring; mirrors the Tenancies / Invoices / Expenses module-toggle pattern. +- **Fix applied (2026-05-31):** full module gate shipped end-to-end, defaulting ON. + - New `src/lib/services/residential-module.service.ts` (`isResidentialModuleEnabled` / `enableResidentialModule` / `disableResidentialModule` / `assertResidentialModuleEnabled`) — TDD'd via `tests/integration/residential-module.test.ts` (6 tests, RED→GREEN). + - Registry key `residential_module_enabled` (`section: 'operations.residential'`, `defaultValue: true`) in `src/lib/settings/registry.ts`. + - Route guard `src/app/(dashboard)/[portSlug]/residential/layout.tsx` renders `` when off — covers all 5 residential pages. + - Sidebar: `requiresResidentialModule` section flag + `residentialModuleByPort` map resolved SSR in `src/app/(dashboard)/layout.tsx`, threaded through `app-shell.tsx` → `sidebar.tsx`; mobile `more-sheet.tsx` Residential tile gated via new `residentialModuleEnabled` prop. + - Global search: module gate added at the shared chokepoint (`searchResidentialClients` / `searchResidentialInterests` early-return `[]` when off) so disabled-port records don't dead-end on the guard page — covers both the all-buckets fan-out and the single-bucket `type=` path. + - Public intake: `src/app/api/public/residential-inquiries/route.ts` now `assertResidentialModuleEnabled` after port resolution → 404 when off (regression test added to `tests/integration/public-residential-inquiry.test.ts`). + - Admin Switch: `residential_module_enabled` added to `settings-manager.tsx` KNOWN_SETTINGS (writes via `PUT /api/v1/admin/settings/[key]`). + - **Verification:** tsc clean; lint clean (0 errors); residential-module + public-residential-inquiry + search unit suites green (10 + 22 tests). + - **Deliberately NOT gated:** the `admin/residential-stages` page stays reachable when the module is off — an admin may legitimately configure residential stages before enabling. Reconsider if the user wants it hidden too. + - **Deferred (separate cleanup):** the consolidated `admin/operations` page hosting all four module toggles (+ retiring the orphaned `tenancies-module/*` endpoints) — see open question 3 below. +- **User ask (verbatim, 2026-05-31):** "is it possible to make the residential interests sections/functions in the platform to be toggleable in the admin space?" +- **Answer:** yes. The platform already has the exact pattern for Tenancies / Invoices / Expenses; residential can copy it. Caveat: residential is currently gated by **permissions** (`residential_clients` / `residential_interests` access verbs + the `residentialAccess` role flag at _src/lib/db/schema/users.ts:455_, auto-granting perms at _src/lib/api/helpers.ts:209-213_), **not** a module toggle, and has **no layout gate at all** today. So this is genuinely new wiring, not a flag flip. +- **Fix proposal (copy the Tenancies template — the most complete of the three):** + 1. **Registry entry** — add `residential_module_enabled` to _src/lib/settings/registry.ts_ (mirror the `tenancies_module_enabled` entry at lines 614-623): `section: 'operations.residential'`, `type: 'boolean'`, `scope: 'port'`, `defaultValue: true` (residential is in active use; default ON so existing ports aren't surprised — unlike tenancies/invoices which default OFF). + 2. **Module service** — new _src/lib/services/residential-module.service.ts_ mirroring _tenancies-module.service.ts_: `isResidentialModuleEnabled(portId)` / `enableResidentialModule` / `disableResidentialModule` / `assertResidentialModuleEnabled` (throws `NotFoundError` when off; used by API handlers). Lazy "any residential_clients row exists" auto-enable is optional. + 3. **Route gate** — new _src/app/(dashboard)/[portSlug]/residential/layout.tsx_ rendering `` (copy _expenses/layout.tsx:26-43_). One layout covers all 5 residential pages (clients list/detail, interests list/detail, index redirect). The `admin/residential-stages` page should also be gated. + 4. **Sidebar** — add a `requiresResidentialModule` flag to the Residential nav section in _src/components/layout/sidebar.tsx:119-134_ (alongside the existing `residentialRequired`); resolve a `residentialModuleByPort` map in _src/app/(dashboard)/layout.tsx:82-109_ (mirror the tenancies/expenses maps) and thread it through _src/components/layout/app-shell.tsx:28-34,97-98,150-151_; add the filter at the existing nav filter (sidebar.tsx ~390/419). **Also gate the mobile entry** _src/components/layout/mobile/more-sheet.tsx:58_ (currently ungated). + 5. **Search** — gate the two residential buckets in _src/lib/services/search.service.ts_ (`searchResidentialClients` line 497, `searchResidentialInterests` line 725; permission checks at 1949-1956 / 2163-2169 / 2199-2205) behind the module flag too, plus recently-viewed hydration in _src/lib/services/dashboard.service.ts:484-506_. + 6. **Public inquiry endpoint** — _src/app/api/public/residential-inquiries/route.ts_ should `assertResidentialModuleEnabled` (or 404) when off, so a disabled port stops accepting residential inquiries from the website. Currently only rate-limit + validation gate it. + 7. **Admin UI** — realistic path is the generic settings manager: add a `residential_module_enabled` Switch entry to _src/components/admin/settings/settings-manager.tsx_ (mirror the `tenancies_module_enabled` entry at lines 51-57), writing via `PUT /api/v1/admin/settings/[key]`. **Note:** the dedicated `/api/v1/admin/tenancies-module/enable|disable` endpoints are orphaned (nothing in the UI calls them) and the Invoices toggle has a registry entry + gate but no UI — so the settings-manager Switch is the path that actually works. Optionally build the long-promised `admin/operations` page to host all four module toggles in one place (closes the orphaned-endpoint gap for tenancies too). +- **Surfaces to gate (user-facing, ~a dozen):** 5 dashboard pages (1 new layout), 1 admin stages page, sidebar section, mobile more-sheet entry, 2 search buckets + recently-viewed, public inquiry endpoint. **Backend stays preserved (~28 files):** 4 DB tables + relations (_src/lib/db/schema/residential.ts_), ~12 service fns (_residential.service.ts_, _residential-stages.service.ts_), ~14 v1 API routes (_src/app/api/v1/residential/\*_), 11 components (_src/components/residential/\*_), 2 email templates (_residential-inquiry.tsx_), validators, seeds, constants — disabled but invisible, exactly like the Tenancies/Expenses "soft hide, data preserved" model. +- **Effort:** ~4-6h (half a day). Bulk is the sidebar/app-shell map plumbing + the new layout + search gating; the registry/service/Switch are ~1h. +- **Alternatives considered + rejected:** + - Reuse the existing permission gate (just strip `residentialAccess` from all roles) — rejected: that's per-user, not a clean port-level "this port doesn't do residential" switch, and leaves the public inquiry endpoint live + the nav logic fragile. + - Hard-delete residential tables for ports that don't use it — rejected: violates the established non-destructive module-toggle convention (data preserved, re-enable any time). +- **Open questions for the user:** + 1. **Default state** — ON for existing ports (residential is live; least surprising) or OFF (treat residential as opt-in like tenancies/invoices)? Default proposal: ON. + 2. **Scope** — just hide the UI surfaces, or also hard-reject the public residential-inquiry endpoint when off? Default proposal: both (a disabled port shouldn't silently accept inquiries it can't see). + 3. Build the proper `admin/operations` page to host all four module toggles (and retire the orphaned tenancies endpoints), or just add the residential Switch to the existing settings manager? Default proposal: settings-manager Switch now; Operations page as a separate cleanup. +- **Cross-refs:** sibling of the "Admin toggle to disable Tenancies entirely" finding (Bucket 1, `PARTIALLY SHIPPED`) and the invoices module-toggle work in `docs/launch-readiness.md` Initiative 1c. All four toggles share the same incomplete admin-UI story — worth adding the Operations page once and wiring all of them through it. + --- ## Bucket 4 — Bugs (severity-tagged) diff --git a/src/app/(dashboard)/[portSlug]/residential/layout.tsx b/src/app/(dashboard)/[portSlug]/residential/layout.tsx new file mode 100644 index 00000000..6f61340b --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/residential/layout.tsx @@ -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 ( + + ); +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 42ec8aca..2ede42c9 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -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 = 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 = + Object.fromEntries(residentialModuleEntries); + return ( {children} diff --git a/src/app/api/public/residential-inquiries/route.ts b/src/app/api/public/residential-inquiries/route.ts index fa99b8eb..6b80fb99 100644 --- a/src/app/api/public/residential-inquiries/route.ts +++ b/src/app/api/public/residential-inquiries/route.ts @@ -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; diff --git a/src/components/admin/settings/settings-manager.tsx b/src/components/admin/settings/settings-manager.tsx index 282a2e4c..495e372e 100644 --- a/src/components/admin/settings/settings-manager.tsx +++ b/src/components/admin/settings/settings-manager.tsx @@ -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', diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 90380771..3b550af5 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -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; + /** 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; /** * 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)} /> - + ) : null; diff --git a/src/components/layout/mobile/more-sheet.tsx b/src/components/layout/mobile/more-sheet.tsx index 08760bf1..595e51dd 100644 --- a/src/components/layout/mobile/more-sheet.tsx +++ b/src/components/layout/mobile/more-sheet.tsx @@ -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); diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 62594d35..988f4c26 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -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; + /** 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; } 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} diff --git a/src/lib/services/residential-module.service.ts b/src/lib/services/residential-module.service.ts new file mode 100644 index 00000000..e86c1f55 --- /dev/null +++ b/src/lib/services/residential-module.service.ts @@ -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 { + 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 { + 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 { + 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 { + const enabled = await isResidentialModuleEnabled(portId); + if (!enabled) { + throw new NotFoundError('Residential module is not enabled for this port.'); + } +} diff --git a/src/lib/services/search.service.ts b/src/lib/services/search.service.ts index e5e5e731..0b95bc8f 100644 --- a/src/lib/services/search.service.ts +++ b/src/lib/services/search.service.ts @@ -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 { + // 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 { + // Module gate — see searchResidentialClients for rationale. + if (!(await isResidentialModuleEnabled(portId))) return []; const ilikePattern = `%${query}%`; const rows = await db.execute<{ diff --git a/src/lib/settings/registry.ts b/src/lib/settings/registry.ts index ae4e1116..ac90676e 100644 --- a/src/lib/settings/registry.ts +++ b/src/lib/settings/registry.ts @@ -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', diff --git a/tests/integration/public-residential-inquiry.test.ts b/tests/integration/public-residential-inquiry.test.ts index 646f029f..06fcf3bc 100644 --- a/tests/integration/public-residential-inquiry.test.ts +++ b/tests/integration/public-residential-inquiry.test.ts @@ -11,6 +11,7 @@ import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { residentialClients } from '@/lib/db/schema/residential'; +import { disableResidentialModule } from '@/lib/services/residential-module.service'; import { makePort } from '../helpers/factories'; import { makeMockRequest } from '../helpers/route-tester'; @@ -138,4 +139,35 @@ describe('POST /api/public/residential-inquiries', () => { expect(row?.phoneE164).toBe('+48225550200'); expect(row?.phoneCountry).toBe('PL'); }); + + it('rejects the inquiry when the port has the Residential module disabled', async () => { + const port = await makePort(); + await disableResidentialModule(port.id); + const email = `res-${Math.random().toString(36).slice(2, 8)}@test.local`; + + const req = makeMockRequest( + 'POST', + `http://localhost/api/public/residential-inquiries?portId=${port.id}`, + { + headers: { 'x-forwarded-for': uniqueIp() }, + body: { + firstName: 'Ola', + lastName: 'Disabled', + email, + phone: '+48 22 555 0300', + placeOfResidence: 'Warsaw', + }, + }, + ); + + const res = await POST(req); + // Module gate maps NotFoundError → 404; no client row should be written. + expect(res.status).toBe(404); + + const rows = await db + .select() + .from(residentialClients) + .where(eq(residentialClients.email, email)); + expect(rows).toHaveLength(0); + }); }); diff --git a/tests/integration/residential-module.test.ts b/tests/integration/residential-module.test.ts new file mode 100644 index 00000000..2f51fa7d --- /dev/null +++ b/tests/integration/residential-module.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; + +import { NotFoundError } from '@/lib/errors'; +import { + assertResidentialModuleEnabled, + disableResidentialModule, + enableResidentialModule, + isResidentialModuleEnabled, +} from '@/lib/services/residential-module.service'; +import { makePort } from '../helpers/factories'; + +describe('residential module gate', () => { + it('defaults to ENABLED for a fresh port (no setting row)', async () => { + const port = await makePort(); + expect(await isResidentialModuleEnabled(port.id)).toBe(true); + }); + + it('disableResidentialModule turns it off (soft hide; setting persists)', async () => { + const port = await makePort(); + await disableResidentialModule(port.id); + expect(await isResidentialModuleEnabled(port.id)).toBe(false); + }); + + it('enableResidentialModule turns it back on after a disable', async () => { + const port = await makePort(); + await disableResidentialModule(port.id); + expect(await isResidentialModuleEnabled(port.id)).toBe(false); + await enableResidentialModule(port.id); + expect(await isResidentialModuleEnabled(port.id)).toBe(true); + }); + + it('enable/disable are idempotent (safe to call when already in that state)', async () => { + const port = await makePort(); + await enableResidentialModule(port.id); + await enableResidentialModule(port.id); + expect(await isResidentialModuleEnabled(port.id)).toBe(true); + await disableResidentialModule(port.id); + await disableResidentialModule(port.id); + expect(await isResidentialModuleEnabled(port.id)).toBe(false); + }); + + it('assertResidentialModuleEnabled resolves when enabled', async () => { + const port = await makePort(); + await expect(assertResidentialModuleEnabled(port.id)).resolves.toBeUndefined(); + }); + + it('assertResidentialModuleEnabled throws NotFoundError when disabled', async () => { + const port = await makePort(); + await disableResidentialModule(port.id); + await expect(assertResidentialModuleEnabled(port.id)).rejects.toBeInstanceOf(NotFoundError); + }); +});