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:
@@ -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.
|
- **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 `<GenerateDocumentDialog>`, this is the natural place to ship the unified subject picker as one consistent pattern.
|
- **Bundle with:** the larger wizard refactor (above) — if `/documents/new` becomes a `<GenerateDocumentDialog>`, 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 `<ModuleDisabledPage>` 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 `<ModuleDisabledPage moduleName="Residential" …>` (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)
|
## Bucket 4 — Bugs (severity-tagged)
|
||||||
|
|||||||
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 { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||||
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||||
import { isExpensesModuleEnabled } from '@/lib/services/expenses-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 }) {
|
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
const headerList = await headers();
|
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);
|
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 (
|
return (
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<PortProvider
|
<PortProvider
|
||||||
@@ -136,6 +155,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
portLogoUrls={portLogoUrls}
|
portLogoUrls={portLogoUrls}
|
||||||
tenanciesModuleByPort={tenanciesModuleByPort}
|
tenanciesModuleByPort={tenanciesModuleByPort}
|
||||||
expensesModuleByPort={expensesModuleByPort}
|
expensesModuleByPort={expensesModuleByPort}
|
||||||
|
residentialModuleByPort={residentialModuleByPort}
|
||||||
initialFormFactor={initialFormFactor}
|
initialFormFactor={initialFormFactor}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { resolveSubject } from '@/lib/email/resolve-subject';
|
import { resolveSubject } from '@/lib/email/resolve-subject';
|
||||||
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||||
import { getPortBrandingConfig, getPortEmailConfig } from '@/lib/services/port-config';
|
import { getPortBrandingConfig, getPortEmailConfig } from '@/lib/services/port-config';
|
||||||
|
import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service';
|
||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
||||||
@@ -61,6 +62,12 @@ export async function POST(req: NextRequest) {
|
|||||||
throw new ValidationError('Unknown port');
|
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
|
// If the website didn't pre-normalize, parse server-side. International
|
||||||
// strings parse without a hint; national-format submissions need a country.
|
// strings parse without a hint; national-format submissions need a country.
|
||||||
let phoneE164 = data.phoneE164 ?? null;
|
let phoneE164 = data.phoneE164 ?? null;
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
defaultValue: true,
|
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',
|
key: 'ai_interest_scoring',
|
||||||
label: 'AI Interest Scoring',
|
label: 'AI Interest Scoring',
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ interface AppShellProps {
|
|||||||
* + How-to-upload-receipts sidebar entries SSR-side. Defaults to
|
* + How-to-upload-receipts sidebar entries SSR-side. Defaults to
|
||||||
* true so existing ports keep the feature. */
|
* true so existing ports keep the feature. */
|
||||||
expensesModuleByPort: Record<string, boolean>;
|
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
|
* Server-rendered form-factor hint (from the request User-Agent). The
|
||||||
* shell mounts the matching tree on first render so we never paint the
|
* shell mounts the matching tree on first render so we never paint the
|
||||||
@@ -96,6 +100,7 @@ export function AppShell({
|
|||||||
portLogoUrls,
|
portLogoUrls,
|
||||||
tenanciesModuleByPort,
|
tenanciesModuleByPort,
|
||||||
expensesModuleByPort,
|
expensesModuleByPort,
|
||||||
|
residentialModuleByPort,
|
||||||
initialFormFactor,
|
initialFormFactor,
|
||||||
children,
|
children,
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
@@ -104,6 +109,7 @@ export function AppShell({
|
|||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
const [tabletSidebarOpen, setTabletSidebarOpen] = useState(false);
|
const [tabletSidebarOpen, setTabletSidebarOpen] = useState(false);
|
||||||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||||
|
const currentPortId = useUIStore((s) => s.currentPortId);
|
||||||
const logoUrl = currentPortSlug ? portLogoUrls[currentPortSlug] : null;
|
const logoUrl = currentPortSlug ? portLogoUrls[currentPortSlug] : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -149,8 +155,15 @@ export function AppShell({
|
|||||||
portLogoUrls,
|
portLogoUrls,
|
||||||
tenanciesModuleByPort,
|
tenanciesModuleByPort,
|
||||||
expensesModuleByPort,
|
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.
|
// Chrome subtree per tier.
|
||||||
let chrome: ReactNode = null;
|
let chrome: ReactNode = null;
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
@@ -177,7 +190,11 @@ export function AppShell({
|
|||||||
onMoreClick={() => setMoreOpen(true)}
|
onMoreClick={() => setMoreOpen(true)}
|
||||||
onSearchClick={() => setSearchOpen(true)}
|
onSearchClick={() => setSearchOpen(true)}
|
||||||
/>
|
/>
|
||||||
<MoreSheet open={moreOpen} onOpenChange={setMoreOpen} />
|
<MoreSheet
|
||||||
|
open={moreOpen}
|
||||||
|
onOpenChange={setMoreOpen}
|
||||||
|
residentialModuleEnabled={residentialModuleEnabled}
|
||||||
|
/>
|
||||||
<MobileSearchOverlay open={searchOpen} onOpenChange={setSearchOpen} />
|
<MobileSearchOverlay open={searchOpen} onOpenChange={setSearchOpen} />
|
||||||
</>
|
</>
|
||||||
) : null;
|
) : null;
|
||||||
|
|||||||
@@ -81,9 +81,14 @@ const MORE_GROUPS: MoreGroup[] = [
|
|||||||
export function MoreSheet({
|
export function MoreSheet({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
residentialModuleEnabled = true,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (next: boolean) => void;
|
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 pathname = usePathname();
|
||||||
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
|
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
|
||||||
@@ -98,6 +103,7 @@ export function MoreSheet({
|
|||||||
...g,
|
...g,
|
||||||
items: g.items.filter((item) => {
|
items: g.items.filter((item) => {
|
||||||
if (item.segment === 'website-analytics') return umamiConfigured;
|
if (item.segment === 'website-analytics') return umamiConfigured;
|
||||||
|
if (item.segment === 'residential/clients') return residentialModuleEnabled;
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
})).filter((g) => g.items.length > 0);
|
})).filter((g) => g.items.length > 0);
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ interface SidebarProps {
|
|||||||
* the dashboard layout. Defaults to true (feature on) per port when
|
* the dashboard layout. Defaults to true (feature on) per port when
|
||||||
* the map is missing for the active port. */
|
* the map is missing for the active port. */
|
||||||
expensesModuleByPort?: Record<string, boolean>;
|
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 {
|
interface NavItem {
|
||||||
@@ -78,6 +83,9 @@ interface NavSection {
|
|||||||
marinaRequired?: boolean;
|
marinaRequired?: boolean;
|
||||||
/** When true, only render if the user has residential-side access. */
|
/** When true, only render if the user has residential-side access. */
|
||||||
residentialRequired?: boolean;
|
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. */
|
/** When true, only render if Umami analytics is wired up for the port. */
|
||||||
umamiRequired?: boolean;
|
umamiRequired?: boolean;
|
||||||
}
|
}
|
||||||
@@ -119,6 +127,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
|||||||
{
|
{
|
||||||
title: 'Residential',
|
title: 'Residential',
|
||||||
residentialRequired: true,
|
residentialRequired: true,
|
||||||
|
requiresResidentialModule: true,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
href: `${base}/residential/clients`,
|
href: `${base}/residential/clients`,
|
||||||
@@ -280,6 +289,7 @@ function SidebarContent({
|
|||||||
hasResidentialAccess,
|
hasResidentialAccess,
|
||||||
tenanciesModuleEnabled,
|
tenanciesModuleEnabled,
|
||||||
expensesModuleEnabled,
|
expensesModuleEnabled,
|
||||||
|
residentialModuleEnabled,
|
||||||
user,
|
user,
|
||||||
ports,
|
ports,
|
||||||
currentPort,
|
currentPort,
|
||||||
@@ -295,6 +305,7 @@ function SidebarContent({
|
|||||||
hasResidentialAccess: boolean;
|
hasResidentialAccess: boolean;
|
||||||
tenanciesModuleEnabled: boolean;
|
tenanciesModuleEnabled: boolean;
|
||||||
expensesModuleEnabled: boolean;
|
expensesModuleEnabled: boolean;
|
||||||
|
residentialModuleEnabled: boolean;
|
||||||
user?: SidebarProps['user'];
|
user?: SidebarProps['user'];
|
||||||
ports?: Port[];
|
ports?: Port[];
|
||||||
currentPort: Port | null;
|
currentPort: Port | null;
|
||||||
@@ -388,6 +399,7 @@ function SidebarContent({
|
|||||||
if (section.adminRequired && !hasAdminAccess) return null;
|
if (section.adminRequired && !hasAdminAccess) return null;
|
||||||
if (section.marinaRequired && !hasMarinaAccess) return null;
|
if (section.marinaRequired && !hasMarinaAccess) return null;
|
||||||
if (section.residentialRequired && !hasResidentialAccess) return null;
|
if (section.residentialRequired && !hasResidentialAccess) return null;
|
||||||
|
if (section.requiresResidentialModule && !residentialModuleEnabled) return null;
|
||||||
if (section.umamiRequired && !umamiConfigured) return null;
|
if (section.umamiRequired && !umamiConfigured) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -514,6 +526,7 @@ export function Sidebar({
|
|||||||
portLogoUrls,
|
portLogoUrls,
|
||||||
tenanciesModuleByPort,
|
tenanciesModuleByPort,
|
||||||
expensesModuleByPort,
|
expensesModuleByPort,
|
||||||
|
residentialModuleByPort,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
// Sidebar collapse removed - design preference is the always-expanded
|
// Sidebar collapse removed - design preference is the always-expanded
|
||||||
// form. Forcibly false; the store flag stays for backwards-compat with
|
// form. Forcibly false; the store flag stays for backwards-compat with
|
||||||
@@ -532,6 +545,12 @@ export function Sidebar({
|
|||||||
const expensesModuleEnabled = currentPortId
|
const expensesModuleEnabled = currentPortId
|
||||||
? (expensesModuleByPort?.[currentPortId] ?? true)
|
? (expensesModuleByPort?.[currentPortId] ?? true)
|
||||||
: 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.
|
// Super admins see every section regardless of role rows.
|
||||||
const hasAdminAccess =
|
const hasAdminAccess =
|
||||||
@@ -565,6 +584,7 @@ export function Sidebar({
|
|||||||
hasResidentialAccess={hasResidentialAccess}
|
hasResidentialAccess={hasResidentialAccess}
|
||||||
tenanciesModuleEnabled={tenanciesModuleEnabled}
|
tenanciesModuleEnabled={tenanciesModuleEnabled}
|
||||||
expensesModuleEnabled={expensesModuleEnabled}
|
expensesModuleEnabled={expensesModuleEnabled}
|
||||||
|
residentialModuleEnabled={residentialModuleEnabled}
|
||||||
user={user}
|
user={user}
|
||||||
ports={ports}
|
ports={ports}
|
||||||
currentPort={currentPort}
|
currentPort={currentPort}
|
||||||
|
|||||||
107
src/lib/services/residential-module.service.ts
Normal file
107
src/lib/services/residential-module.service.ts
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ import { match } from 'ts-pattern';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { redis } from '@/lib/redis';
|
import { redis } from '@/lib/redis';
|
||||||
import type { RolePermissions } from '@/lib/db/schema/users';
|
import type { RolePermissions } from '@/lib/db/schema/users';
|
||||||
|
import { isResidentialModuleEnabled } from '@/lib/services/residential-module.service';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -499,6 +500,12 @@ async function searchResidentialClients(
|
|||||||
query: string,
|
query: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
): Promise<ResidentialClientResult[]> {
|
): 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 tsQ = buildPrefixTsquery(query) ?? NEVER_TSQUERY;
|
||||||
const phoneQ = normalizePhoneQuery(query) ?? NEVER_PHONE;
|
const phoneQ = normalizePhoneQuery(query) ?? NEVER_PHONE;
|
||||||
const ilikePattern = `%${query}%`;
|
const ilikePattern = `%${query}%`;
|
||||||
@@ -727,6 +734,8 @@ async function searchResidentialInterests(
|
|||||||
query: string,
|
query: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
): Promise<ResidentialInterestResult[]> {
|
): Promise<ResidentialInterestResult[]> {
|
||||||
|
// Module gate — see searchResidentialClients for rationale.
|
||||||
|
if (!(await isResidentialModuleEnabled(portId))) return [];
|
||||||
const ilikePattern = `%${query}%`;
|
const ilikePattern = `%${query}%`;
|
||||||
|
|
||||||
const rows = await db.execute<{
|
const rows = await db.execute<{
|
||||||
|
|||||||
@@ -662,6 +662,28 @@ export const REGISTRY: SettingEntry[] = [
|
|||||||
defaultValue: false,
|
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 ──────────────────────────────────────
|
// ─── Residential - partner forwarding ──────────────────────────────────────
|
||||||
{
|
{
|
||||||
key: 'residential_partner_recipients',
|
key: 'residential_partner_recipients',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { residentialClients } from '@/lib/db/schema/residential';
|
import { residentialClients } from '@/lib/db/schema/residential';
|
||||||
|
import { disableResidentialModule } from '@/lib/services/residential-module.service';
|
||||||
import { makePort } from '../helpers/factories';
|
import { makePort } from '../helpers/factories';
|
||||||
import { makeMockRequest } from '../helpers/route-tester';
|
import { makeMockRequest } from '../helpers/route-tester';
|
||||||
|
|
||||||
@@ -138,4 +139,35 @@ describe('POST /api/public/residential-inquiries', () => {
|
|||||||
expect(row?.phoneE164).toBe('+48225550200');
|
expect(row?.phoneE164).toBe('+48225550200');
|
||||||
expect(row?.phoneCountry).toBe('PL');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
52
tests/integration/residential-module.test.ts
Normal file
52
tests/integration/residential-module.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user