feat(tenancies-p5): sidebar entry + 404 top-level page + API module gate

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 15:23:06 +02:00
parent bfb29ab619
commit 3a48150d13
10 changed files with 138 additions and 26 deletions

View File

@@ -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 <TenancyDetail tenancyId={id} portSlug={portSlug} />;
}

View File

@@ -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 <TenanciesListPage />;
}

View File

@@ -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<string, string | null> = 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<string, boolean> = Object.fromEntries(tenanciesModuleEntries);
return (
<QueryProvider>
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
@@ -95,6 +111,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
user={user}
ports={ports}
portLogoUrls={portLogoUrls}
tenanciesModuleByPort={tenanciesModuleByPort}
initialFormFactor={initialFormFactor}
>
{children}

View File

@@ -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<void>
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(

View File

@@ -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,

View File

@@ -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<NextResponse> {
try {
await assertTenanciesModuleEnabled(ctx.portId);
const query = parseQuery(req as never, listTenanciesSchema);
const result = await listTenancies(ctx.portId, query);
const { page, limit } = query;