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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
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