From 3a48150d131dde974e025c8a0922275652c31f5a Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 25 May 2026 15:23:06 +0200 Subject: [PATCH] feat(tenancies-p5): sidebar entry + 404 top-level page + API module gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dashboard layout resolves tenanciesModuleByPort server-side (one isTenanciesModuleEnabled call per port the user has access to) and passes the map through AppShell → Sidebar. Atomic SSR — no flicker of the nav entry in/out after hydration. - Sidebar gains NavItemGated.requiresTenanciesModule. The Tenancies entry (KeyRound icon, immediately below Berths) only renders when the currently-active port has the flag flipped on. Per-port live switch fires when the rep toggles ports without reload. - /[portSlug]/tenancies + /[portSlug]/tenancies/[id] both call isTenanciesModuleEnabled and notFound() when disabled — guards against direct URL access even when the sidebar is hidden. - API routes (/api/v1/tenancies, /[id], /berths/[id]/tenancies) prepended with assertTenanciesModuleEnabled — matches design § "All routes ... return 404 when off". NotFoundError maps to 404. - Existing tenancy API tests get a makePortWithTenancies() helper (calls enableTenanciesModule after makePort) so the gate is satisfied. Affects 2 test files (16 tests retargeted). Verified: tsc clean, 1493/1493 vitest. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[portSlug]/tenancies/[id]/page.tsx | 13 +++++ .../(dashboard)/[portSlug]/tenancies/page.tsx | 25 +++++++++- src/app/(dashboard)/layout.tsx | 17 +++++++ .../api/v1/berths/[id]/tenancies/handlers.ts | 3 ++ src/app/api/v1/tenancies/[id]/handlers.ts | 4 ++ src/app/api/v1/tenancies/handlers.ts | 2 + src/components/layout/app-shell.tsx | 5 ++ src/components/layout/sidebar.tsx | 48 +++++++++++++++---- .../api/berth-tenancies-list.test.ts | 15 ++++-- tests/integration/api/tenancies.test.ts | 32 ++++++++----- 10 files changed, 138 insertions(+), 26 deletions(-) diff --git a/src/app/(dashboard)/[portSlug]/tenancies/[id]/page.tsx b/src/app/(dashboard)/[portSlug]/tenancies/[id]/page.tsx index bca8aff5..a2e55517 100644 --- a/src/app/(dashboard)/[portSlug]/tenancies/[id]/page.tsx +++ b/src/app/(dashboard)/[portSlug]/tenancies/[id]/page.tsx @@ -1,4 +1,10 @@ +import { notFound } from 'next/navigation'; +import { eq } from 'drizzle-orm'; + import { TenancyDetail } from '@/components/tenancies/tenancy-detail'; +import { db } from '@/lib/db'; +import { ports as portsTable } from '@/lib/db/schema/ports'; +import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service'; interface PageProps { params: Promise<{ portSlug: string; id: string }>; @@ -6,5 +12,12 @@ interface PageProps { export default async function TenancyDetailPage({ params }: PageProps) { const { portSlug, id } = await params; + const port = await db.query.ports.findFirst({ + where: eq(portsTable.slug, portSlug), + columns: { id: true }, + }); + if (!port) notFound(); + if (!(await isTenanciesModuleEnabled(port.id))) notFound(); + return ; } diff --git a/src/app/(dashboard)/[portSlug]/tenancies/page.tsx b/src/app/(dashboard)/[portSlug]/tenancies/page.tsx index 974e00d7..2d22ce08 100644 --- a/src/app/(dashboard)/[portSlug]/tenancies/page.tsx +++ b/src/app/(dashboard)/[portSlug]/tenancies/page.tsx @@ -1,5 +1,26 @@ -import { TenanciesListPage } from '@/components/tenancies/tenancies-list-page'; +import { notFound } from 'next/navigation'; + +import { TenanciesListPage } from '@/components/tenancies/tenancies-list-page'; +import { db } from '@/lib/db'; +import { ports as portsTable } from '@/lib/db/schema/ports'; +import { eq } from 'drizzle-orm'; +import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service'; + +interface PageProps { + params: Promise<{ portSlug: string }>; +} + +export default async function BerthTenanciesPage({ params }: PageProps) { + const { portSlug } = await params; + // Per docs/tenancies-design.md §"When disabled": top-level page returns + // 404 when the module is off. The sidebar entry is already hidden via + // tenanciesModuleByPort, so this 404 guards against direct URL access. + const port = await db.query.ports.findFirst({ + where: eq(portsTable.slug, portSlug), + columns: { id: true }, + }); + if (!port) notFound(); + if (!(await isTenanciesModuleEnabled(port.id))) notFound(); -export default function BerthTenanciesPage() { return ; } diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 415e1d48..099c886a 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -17,6 +17,7 @@ import { RealtimeToasts } from '@/components/shared/realtime-toasts'; import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter'; import { classifyFormFactor } from '@/lib/form-factor'; import { getPortBrandingConfig } from '@/lib/services/port-config'; +import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service'; export default async function DashboardLayout({ children }: { children: React.ReactNode }) { const headerList = await headers(); @@ -73,6 +74,21 @@ export default async function DashboardLayout({ children }: { children: React.Re ); const portLogoUrls: Record = Object.fromEntries(portBrandingEntries); + // Per-port tenancies-module gate. Hidden by default; flips on either by + // the admin switch (Operations) OR the lazy auto-enable on first row. + // Resolved server-side so the sidebar nav SSRs in/out atomically with + // the layout instead of flickering after a client-side fetch. + const tenanciesModuleEntries = await Promise.all( + ports.map(async (p) => { + try { + return [p.id, await isTenanciesModuleEnabled(p.id)] as const; + } catch { + return [p.id, false] as const; + } + }), + ); + const tenanciesModuleByPort: Record = Object.fromEntries(tenanciesModuleEntries); + return ( @@ -95,6 +111,7 @@ export default async function DashboardLayout({ children }: { children: React.Re user={user} ports={ports} portLogoUrls={portLogoUrls} + tenanciesModuleByPort={tenanciesModuleByPort} initialFormFactor={initialFormFactor} > {children} diff --git a/src/app/api/v1/berths/[id]/tenancies/handlers.ts b/src/app/api/v1/berths/[id]/tenancies/handlers.ts index 7302ef9b..0afc39b3 100644 --- a/src/app/api/v1/berths/[id]/tenancies/handlers.ts +++ b/src/app/api/v1/berths/[id]/tenancies/handlers.ts @@ -7,6 +7,7 @@ import { db } from '@/lib/db'; import { berths } from '@/lib/db/schema/berths'; import { NotFoundError, errorResponse } from '@/lib/errors'; import { createPending, listTenancies } from '@/lib/services/berth-tenancies.service'; +import { assertTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service'; import { createPendingSchema, listTenanciesSchema } from '@/lib/validators/tenancies'; // URL berthId is authoritative; make body berthId optional (ignored anyway). @@ -23,6 +24,7 @@ async function assertBerthInPort(berthId: string, portId: string): Promise export const listHandler: RouteHandler = async (req, ctx, params) => { try { + await assertTenanciesModuleEnabled(ctx.portId); await assertBerthInPort(params.id!, ctx.portId); const query = parseQuery(req, listTenanciesSchema); const result = await listTenancies(ctx.portId, { ...query, berthId: params.id! }); @@ -46,6 +48,7 @@ export const listHandler: RouteHandler = async (req, ctx, params) => { export const createHandler: RouteHandler = async (req, ctx, params) => { try { + await assertTenanciesModuleEnabled(ctx.portId); await assertBerthInPort(params.id!, ctx.portId); const body = await parseBody(req, createPendingBodySchema); const tenancy = await createPending( diff --git a/src/app/api/v1/tenancies/[id]/handlers.ts b/src/app/api/v1/tenancies/[id]/handlers.ts index 571fbb7c..219c3eec 100644 --- a/src/app/api/v1/tenancies/[id]/handlers.ts +++ b/src/app/api/v1/tenancies/[id]/handlers.ts @@ -6,6 +6,7 @@ import { parseBody } from '@/lib/api/route-helpers'; import { requirePermission } from '@/lib/auth/permissions'; import { errorResponse } from '@/lib/errors'; import { activate, cancel, endTenancy, getById } from '@/lib/services/berth-tenancies.service'; +import { assertTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service'; // ─── PATCH body schema (action-based discriminated union) ──────────────────── @@ -30,6 +31,7 @@ const patchBodySchema = z.discriminatedUnion('action', [ export const getHandler: RouteHandler = async (_req, ctx, params) => { try { + await assertTenanciesModuleEnabled(ctx.portId); const tenancy = await getById(params.id!, ctx.portId); return NextResponse.json({ data: tenancy }); } catch (error) { @@ -39,6 +41,7 @@ export const getHandler: RouteHandler = async (_req, ctx, params) => { export const patchHandler: RouteHandler = async (req, ctx, params) => { try { + await assertTenanciesModuleEnabled(ctx.portId); const body = await parseBody(req, patchBodySchema); const meta = { userId: ctx.userId, @@ -84,6 +87,7 @@ export const patchHandler: RouteHandler = async (req, ctx, params) => { export const deleteHandler: RouteHandler = async (_req, ctx, params) => { try { + await assertTenanciesModuleEnabled(ctx.portId); await cancel( params.id!, ctx.portId, diff --git a/src/app/api/v1/tenancies/handlers.ts b/src/app/api/v1/tenancies/handlers.ts index 52669993..a7bd5cd4 100644 --- a/src/app/api/v1/tenancies/handlers.ts +++ b/src/app/api/v1/tenancies/handlers.ts @@ -4,6 +4,7 @@ import type { AuthContext } from '@/lib/api/helpers'; import { parseQuery } from '@/lib/api/route-helpers'; import { errorResponse } from '@/lib/errors'; import { listTenancies } from '@/lib/services/berth-tenancies.service'; +import { assertTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service'; import { listTenanciesSchema } from '@/lib/validators/tenancies'; /** @@ -14,6 +15,7 @@ import { listTenanciesSchema } from '@/lib/validators/tenancies'; */ export async function listHandler(req: Request, ctx: AuthContext): Promise { try { + await assertTenanciesModuleEnabled(ctx.portId); const query = parseQuery(req as never, listTenanciesSchema); const result = await listTenancies(ctx.portId, query); const { page, limit } = query; diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 543a75c9..4a3ce935 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -24,6 +24,9 @@ interface AppShellProps { /** Per-port logo URLs resolved server-side. Sidebar picks the entry * matching the currently-active port from the UI store. */ portLogoUrls: Record; + /** Per-port `tenancies_module_enabled` resolution. Gates the Tenancies + * sidebar entry SSR-side so the nav doesn't flicker in/out. */ + tenanciesModuleByPort: 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 @@ -86,6 +89,7 @@ export function AppShell({ user, ports, portLogoUrls, + tenanciesModuleByPort, initialFormFactor, children, }: AppShellProps) { @@ -137,6 +141,7 @@ export function AppShell({ user, ports, portLogoUrls, + tenanciesModuleByPort, }; // Chrome subtree per tier. diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index ed80538c..bc4a5ad4 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -9,6 +9,7 @@ import { Users, Bookmark, Anchor, + KeyRound, Ship, Building2, Receipt, @@ -51,6 +52,9 @@ interface SidebarProps { * The sidebar header swaps to the current port's logo via the UI * store's `currentPortId`. Null entries render the wordmark fallback. */ portLogoUrls?: Record; + /** Per-port `tenancies_module_enabled` resolution. Gates the Tenancies + * sidebar entry. Resolved server-side in the dashboard layout. */ + tenanciesModuleByPort?: Record; } interface NavItem { @@ -72,6 +76,12 @@ interface NavSection { umamiRequired?: boolean; } +interface NavItemGated extends NavItem { + /** When true, only render this item if the tenancies module is enabled + * for the current port. Resolved against `tenanciesModuleByPort`. */ + requiresTenanciesModule?: boolean; +} + function buildNavSections(portSlug: string | undefined): NavSection[] { const base = portSlug ? `/${portSlug}` : ''; @@ -86,6 +96,12 @@ function buildNavSections(portSlug: string | undefined): NavSection[] { { href: `${base}/companies`, label: 'Companies', icon: Building2 }, { href: `${base}/interests`, label: 'Interests', icon: Bookmark }, { href: `${base}/berths`, label: 'Berths', icon: Anchor }, + { + href: `${base}/tenancies`, + label: 'Tenancies', + icon: KeyRound, + requiresTenanciesModule: true, + } as NavItemGated, ], }, { @@ -235,6 +251,7 @@ function SidebarContent({ hasAdminAccess, hasMarinaAccess, hasResidentialAccess, + tenanciesModuleEnabled, user, ports, currentPort, @@ -248,6 +265,7 @@ function SidebarContent({ hasAdminAccess: boolean; hasMarinaAccess: boolean; hasResidentialAccess: boolean; + tenanciesModuleEnabled: boolean; user?: SidebarProps['user']; ports?: Port[]; currentPort: Port | null; @@ -366,15 +384,22 @@ function SidebarContent({ )} {(!section.adminRequired || adminExpanded || collapsed) && (
    - {section.items.map((item) => ( -
  • - -
  • - ))} + {section.items + .filter((item) => { + const gated = item as NavItemGated; + if (gated.requiresTenanciesModule && !tenanciesModuleEnabled) + return false; + return true; + }) + .map((item) => ( +
  • + +
  • + ))}
)} @@ -456,6 +481,7 @@ export function Sidebar({ user, ports, portLogoUrls, + tenanciesModuleByPort, }: SidebarProps) { // Sidebar collapse removed - design preference is the always-expanded // form. Forcibly false; the store flag stays for backwards-compat with @@ -465,6 +491,9 @@ export function Sidebar({ const currentPortId = useUIStore((s) => s.currentPortId); const currentPort = ports?.find((p) => p.id === currentPortId) ?? ports?.[0] ?? null; const currentLogoUrl = currentPortId ? (portLogoUrls?.[currentPortId] ?? null) : null; + const tenanciesModuleEnabled = currentPortId + ? (tenanciesModuleByPort?.[currentPortId] ?? false) + : false; // Super admins see every section regardless of role rows. const hasAdminAccess = @@ -496,6 +525,7 @@ export function Sidebar({ hasAdminAccess={hasAdminAccess} hasMarinaAccess={hasMarinaAccess} hasResidentialAccess={hasResidentialAccess} + tenanciesModuleEnabled={tenanciesModuleEnabled} user={user} ports={ports} currentPort={currentPort} diff --git a/tests/integration/api/berth-tenancies-list.test.ts b/tests/integration/api/berth-tenancies-list.test.ts index 447e8ad3..34074761 100644 --- a/tests/integration/api/berth-tenancies-list.test.ts +++ b/tests/integration/api/berth-tenancies-list.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect } from 'vitest'; import { listHandler } from '@/app/api/v1/tenancies/handlers'; import { createHandler as createTenancyHandler } from '@/app/api/v1/berths/[id]/tenancies/handlers'; +import { enableTenanciesModule } from '@/lib/services/tenancies-module.service'; import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester'; import { makeBerth, @@ -19,6 +20,12 @@ import { makeYacht, } from '../../helpers/factories'; +async function makePortWithTenancies(): Promise>> { + const port = await makePort(); + await enableTenanciesModule(port.id); + return port; +} + async function seedTenancy(portId: string) { const berth = await makeBerth({ portId }); const client = await makeClient({ portId }); @@ -40,7 +47,7 @@ async function seedTenancy(portId: string) { describe('GET /api/v1/tenancies', () => { it('returns all tenancies for the requesting port', async () => { - const port = await makePort(); + const port = await makePortWithTenancies(); const r1 = await seedTenancy(port.id); const r2 = await seedTenancy(port.id); @@ -55,8 +62,8 @@ describe('GET /api/v1/tenancies', () => { }); it('does not leak tenancies from a different port', async () => { - const portA = await makePort(); - const portB = await makePort(); + const portA = await makePortWithTenancies(); + const portB = await makePortWithTenancies(); const tenancyInB = await seedTenancy(portB.id); // Caller is operating in portA; portB's tenancy must not appear. @@ -70,7 +77,7 @@ describe('GET /api/v1/tenancies', () => { }); it('honors pagination via query params', async () => { - const port = await makePort(); + const port = await makePortWithTenancies(); await seedTenancy(port.id); await seedTenancy(port.id); await seedTenancy(port.id); diff --git a/tests/integration/api/tenancies.test.ts b/tests/integration/api/tenancies.test.ts index 6bb310ea..878203b0 100644 --- a/tests/integration/api/tenancies.test.ts +++ b/tests/integration/api/tenancies.test.ts @@ -12,6 +12,7 @@ import { } from '@/app/api/v1/tenancies/[id]/handlers'; import { db } from '@/lib/db'; import { berthTenancies } from '@/lib/db/schema/tenancies'; +import { enableTenanciesModule } from '@/lib/services/tenancies-module.service'; import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester'; import { makeBerth, @@ -22,11 +23,20 @@ import { makeYacht, } from '../../helpers/factories'; +/** Wrap makePort so every test in this file operates against a port that + * has the tenancies module enabled — the API handlers assertModuleEnabled + * up front (P5 design) and would otherwise 404 every call. */ +async function makePortWithTenancies(): Promise>> { + const port = await makePort(); + await enableTenanciesModule(port.id); + return port; +} + // ─── POST /api/v1/berths/[id]/tenancies ─────────────────────────────────── describe('POST /api/v1/berths/[id]/tenancies', () => { it('creates pending reservation (201)', async () => { - const port = await makePort(); + const port = await makePortWithTenancies(); const berth = await makeBerth({ portId: port.id }); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ @@ -53,7 +63,7 @@ describe('POST /api/v1/berths/[id]/tenancies', () => { }); it('returns 400 when yacht does not belong to reservation client', async () => { - const port = await makePort(); + const port = await makePortWithTenancies(); const berth = await makeBerth({ portId: port.id }); const ownerClient = await makeClient({ portId: port.id }); const otherClient = await makeClient({ portId: port.id }); @@ -76,8 +86,8 @@ describe('POST /api/v1/berths/[id]/tenancies', () => { }); it('returns 404 when berth is cross-tenant', async () => { - const portA = await makePort(); - const portB = await makePort(); + const portA = await makePortWithTenancies(); + const portB = await makePortWithTenancies(); const berthA = await makeBerth({ portId: portA.id }); const client = await makeClient({ portId: portB.id }); const yacht = await makeYacht({ @@ -100,7 +110,7 @@ describe('POST /api/v1/berths/[id]/tenancies', () => { }); it('ignores berthId from body, uses URL param instead', async () => { - const port = await makePort(); + const port = await makePortWithTenancies(); const urlBerth = await makeBerth({ portId: port.id }); const bodyBerth = await makeBerth({ portId: port.id }); const client = await makeClient({ portId: port.id }); @@ -131,7 +141,7 @@ describe('POST /api/v1/berths/[id]/tenancies', () => { describe('GET /api/v1/berths/[id]/tenancies', () => { it('returns reservations filtered by that berth', async () => { - const port = await makePort(); + const port = await makePortWithTenancies(); const berthA = await makeBerth({ portId: port.id }); const berthB = await makeBerth({ portId: port.id }); const client = await makeClient({ portId: port.id }); @@ -184,7 +194,7 @@ describe('GET /api/v1/berths/[id]/tenancies', () => { describe('GET /api/v1/tenancies/[id]', () => { it('returns the reservation', async () => { - const port = await makePort(); + const port = await makePortWithTenancies(); const berth = await makeBerth({ portId: port.id }); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ @@ -218,8 +228,8 @@ describe('GET /api/v1/tenancies/[id]', () => { }); it('returns 404 for cross-tenant', async () => { - const portA = await makePort(); - const portB = await makePort(); + const portA = await makePortWithTenancies(); + const portB = await makePortWithTenancies(); const berth = await makeBerth({ portId: portA.id }); const client = await makeClient({ portId: portA.id }); const yacht = await makeYacht({ @@ -256,7 +266,7 @@ describe('GET /api/v1/tenancies/[id]', () => { describe('PATCH /api/v1/tenancies/[id]', () => { async function seedReservation() { - const port = await makePort(); + const port = await makePortWithTenancies(); const berth = await makeBerth({ portId: port.id }); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ @@ -438,7 +448,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => { describe('DELETE /api/v1/tenancies/[id]', () => { it('cancels the reservation (204)', async () => { - const port = await makePort(); + const port = await makePortWithTenancies(); const berth = await makeBerth({ portId: port.id }); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({