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:
@@ -1,4 +1,10 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
import { TenancyDetail } from '@/components/tenancies/tenancy-detail';
|
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 {
|
interface PageProps {
|
||||||
params: Promise<{ portSlug: string; id: string }>;
|
params: Promise<{ portSlug: string; id: string }>;
|
||||||
@@ -6,5 +12,12 @@ interface PageProps {
|
|||||||
|
|
||||||
export default async function TenancyDetailPage({ params }: PageProps) {
|
export default async function TenancyDetailPage({ params }: PageProps) {
|
||||||
const { portSlug, id } = await params;
|
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} />;
|
return <TenancyDetail tenancyId={id} portSlug={portSlug} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />;
|
return <TenanciesListPage />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { RealtimeToasts } from '@/components/shared/realtime-toasts';
|
|||||||
import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
|
import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
|
||||||
import { classifyFormFactor } from '@/lib/form-factor';
|
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';
|
||||||
|
|
||||||
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();
|
||||||
@@ -73,6 +74,21 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
);
|
);
|
||||||
const portLogoUrls: Record<string, string | null> = Object.fromEntries(portBrandingEntries);
|
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 (
|
return (
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
||||||
@@ -95,6 +111,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
user={user}
|
user={user}
|
||||||
ports={ports}
|
ports={ports}
|
||||||
portLogoUrls={portLogoUrls}
|
portLogoUrls={portLogoUrls}
|
||||||
|
tenanciesModuleByPort={tenanciesModuleByPort}
|
||||||
initialFormFactor={initialFormFactor}
|
initialFormFactor={initialFormFactor}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { db } from '@/lib/db';
|
|||||||
import { berths } from '@/lib/db/schema/berths';
|
import { berths } from '@/lib/db/schema/berths';
|
||||||
import { NotFoundError, errorResponse } from '@/lib/errors';
|
import { NotFoundError, errorResponse } from '@/lib/errors';
|
||||||
import { createPending, listTenancies } from '@/lib/services/berth-tenancies.service';
|
import { createPending, listTenancies } from '@/lib/services/berth-tenancies.service';
|
||||||
|
import { assertTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||||
import { createPendingSchema, listTenanciesSchema } from '@/lib/validators/tenancies';
|
import { createPendingSchema, listTenanciesSchema } from '@/lib/validators/tenancies';
|
||||||
|
|
||||||
// URL berthId is authoritative; make body berthId optional (ignored anyway).
|
// 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) => {
|
export const listHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
|
await assertTenanciesModuleEnabled(ctx.portId);
|
||||||
await assertBerthInPort(params.id!, ctx.portId);
|
await assertBerthInPort(params.id!, ctx.portId);
|
||||||
const query = parseQuery(req, listTenanciesSchema);
|
const query = parseQuery(req, listTenanciesSchema);
|
||||||
const result = await listTenancies(ctx.portId, { ...query, berthId: params.id! });
|
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) => {
|
export const createHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
|
await assertTenanciesModuleEnabled(ctx.portId);
|
||||||
await assertBerthInPort(params.id!, ctx.portId);
|
await assertBerthInPort(params.id!, ctx.portId);
|
||||||
const body = await parseBody(req, createPendingBodySchema);
|
const body = await parseBody(req, createPendingBodySchema);
|
||||||
const tenancy = await createPending(
|
const tenancy = await createPending(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { parseBody } from '@/lib/api/route-helpers';
|
|||||||
import { requirePermission } from '@/lib/auth/permissions';
|
import { requirePermission } from '@/lib/auth/permissions';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { activate, cancel, endTenancy, getById } from '@/lib/services/berth-tenancies.service';
|
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) ────────────────────
|
// ─── PATCH body schema (action-based discriminated union) ────────────────────
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ const patchBodySchema = z.discriminatedUnion('action', [
|
|||||||
|
|
||||||
export const getHandler: RouteHandler = async (_req, ctx, params) => {
|
export const getHandler: RouteHandler = async (_req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
|
await assertTenanciesModuleEnabled(ctx.portId);
|
||||||
const tenancy = await getById(params.id!, ctx.portId);
|
const tenancy = await getById(params.id!, ctx.portId);
|
||||||
return NextResponse.json({ data: tenancy });
|
return NextResponse.json({ data: tenancy });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -39,6 +41,7 @@ export const getHandler: RouteHandler = async (_req, ctx, params) => {
|
|||||||
|
|
||||||
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
|
await assertTenanciesModuleEnabled(ctx.portId);
|
||||||
const body = await parseBody(req, patchBodySchema);
|
const body = await parseBody(req, patchBodySchema);
|
||||||
const meta = {
|
const meta = {
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
@@ -84,6 +87,7 @@ export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
|||||||
|
|
||||||
export const deleteHandler: RouteHandler = async (_req, ctx, params) => {
|
export const deleteHandler: RouteHandler = async (_req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
|
await assertTenanciesModuleEnabled(ctx.portId);
|
||||||
await cancel(
|
await cancel(
|
||||||
params.id!,
|
params.id!,
|
||||||
ctx.portId,
|
ctx.portId,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { AuthContext } from '@/lib/api/helpers';
|
|||||||
import { parseQuery } from '@/lib/api/route-helpers';
|
import { parseQuery } from '@/lib/api/route-helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { listTenancies } from '@/lib/services/berth-tenancies.service';
|
import { listTenancies } from '@/lib/services/berth-tenancies.service';
|
||||||
|
import { assertTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||||
import { listTenanciesSchema } from '@/lib/validators/tenancies';
|
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> {
|
export async function listHandler(req: Request, ctx: AuthContext): Promise<NextResponse> {
|
||||||
try {
|
try {
|
||||||
|
await assertTenanciesModuleEnabled(ctx.portId);
|
||||||
const query = parseQuery(req as never, listTenanciesSchema);
|
const query = parseQuery(req as never, listTenanciesSchema);
|
||||||
const result = await listTenancies(ctx.portId, query);
|
const result = await listTenancies(ctx.portId, query);
|
||||||
const { page, limit } = query;
|
const { page, limit } = query;
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ interface AppShellProps {
|
|||||||
/** Per-port logo URLs resolved server-side. Sidebar picks the entry
|
/** Per-port logo URLs resolved server-side. Sidebar picks the entry
|
||||||
* matching the currently-active port from the UI store. */
|
* matching the currently-active port from the UI store. */
|
||||||
portLogoUrls: Record<string, string | null>;
|
portLogoUrls: Record<string, string | null>;
|
||||||
|
/** Per-port `tenancies_module_enabled` resolution. Gates the Tenancies
|
||||||
|
* sidebar entry SSR-side so the nav doesn't flicker in/out. */
|
||||||
|
tenanciesModuleByPort: 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
|
||||||
@@ -86,6 +89,7 @@ export function AppShell({
|
|||||||
user,
|
user,
|
||||||
ports,
|
ports,
|
||||||
portLogoUrls,
|
portLogoUrls,
|
||||||
|
tenanciesModuleByPort,
|
||||||
initialFormFactor,
|
initialFormFactor,
|
||||||
children,
|
children,
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
@@ -137,6 +141,7 @@ export function AppShell({
|
|||||||
user,
|
user,
|
||||||
ports,
|
ports,
|
||||||
portLogoUrls,
|
portLogoUrls,
|
||||||
|
tenanciesModuleByPort,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Chrome subtree per tier.
|
// Chrome subtree per tier.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
Anchor,
|
Anchor,
|
||||||
|
KeyRound,
|
||||||
Ship,
|
Ship,
|
||||||
Building2,
|
Building2,
|
||||||
Receipt,
|
Receipt,
|
||||||
@@ -51,6 +52,9 @@ interface SidebarProps {
|
|||||||
* The sidebar header swaps to the current port's logo via the UI
|
* The sidebar header swaps to the current port's logo via the UI
|
||||||
* store's `currentPortId`. Null entries render the wordmark fallback. */
|
* store's `currentPortId`. Null entries render the wordmark fallback. */
|
||||||
portLogoUrls?: Record<string, string | null>;
|
portLogoUrls?: Record<string, string | null>;
|
||||||
|
/** Per-port `tenancies_module_enabled` resolution. Gates the Tenancies
|
||||||
|
* sidebar entry. Resolved server-side in the dashboard layout. */
|
||||||
|
tenanciesModuleByPort?: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -72,6 +76,12 @@ interface NavSection {
|
|||||||
umamiRequired?: boolean;
|
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[] {
|
function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||||
const base = portSlug ? `/${portSlug}` : '';
|
const base = portSlug ? `/${portSlug}` : '';
|
||||||
|
|
||||||
@@ -86,6 +96,12 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
|||||||
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
|
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
|
||||||
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
|
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
|
||||||
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
|
{ 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,
|
hasAdminAccess,
|
||||||
hasMarinaAccess,
|
hasMarinaAccess,
|
||||||
hasResidentialAccess,
|
hasResidentialAccess,
|
||||||
|
tenanciesModuleEnabled,
|
||||||
user,
|
user,
|
||||||
ports,
|
ports,
|
||||||
currentPort,
|
currentPort,
|
||||||
@@ -248,6 +265,7 @@ function SidebarContent({
|
|||||||
hasAdminAccess: boolean;
|
hasAdminAccess: boolean;
|
||||||
hasMarinaAccess: boolean;
|
hasMarinaAccess: boolean;
|
||||||
hasResidentialAccess: boolean;
|
hasResidentialAccess: boolean;
|
||||||
|
tenanciesModuleEnabled: boolean;
|
||||||
user?: SidebarProps['user'];
|
user?: SidebarProps['user'];
|
||||||
ports?: Port[];
|
ports?: Port[];
|
||||||
currentPort: Port | null;
|
currentPort: Port | null;
|
||||||
@@ -366,15 +384,22 @@ function SidebarContent({
|
|||||||
)}
|
)}
|
||||||
{(!section.adminRequired || adminExpanded || collapsed) && (
|
{(!section.adminRequired || adminExpanded || collapsed) && (
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{section.items.map((item) => (
|
{section.items
|
||||||
<li key={item.href}>
|
.filter((item) => {
|
||||||
<NavItemLink
|
const gated = item as NavItemGated;
|
||||||
item={item}
|
if (gated.requiresTenanciesModule && !tenanciesModuleEnabled)
|
||||||
collapsed={collapsed}
|
return false;
|
||||||
active={isActive(item.href, item.exact)}
|
return true;
|
||||||
/>
|
})
|
||||||
</li>
|
.map((item) => (
|
||||||
))}
|
<li key={item.href}>
|
||||||
|
<NavItemLink
|
||||||
|
item={item}
|
||||||
|
collapsed={collapsed}
|
||||||
|
active={isActive(item.href, item.exact)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<Separator className="mt-3 bg-slate-200" aria-hidden />
|
<Separator className="mt-3 bg-slate-200" aria-hidden />
|
||||||
@@ -456,6 +481,7 @@ export function Sidebar({
|
|||||||
user,
|
user,
|
||||||
ports,
|
ports,
|
||||||
portLogoUrls,
|
portLogoUrls,
|
||||||
|
tenanciesModuleByPort,
|
||||||
}: 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
|
||||||
@@ -465,6 +491,9 @@ export function Sidebar({
|
|||||||
const currentPortId = useUIStore((s) => s.currentPortId);
|
const currentPortId = useUIStore((s) => s.currentPortId);
|
||||||
const currentPort = ports?.find((p) => p.id === currentPortId) ?? ports?.[0] ?? null;
|
const currentPort = ports?.find((p) => p.id === currentPortId) ?? ports?.[0] ?? null;
|
||||||
const currentLogoUrl = currentPortId ? (portLogoUrls?.[currentPortId] ?? null) : null;
|
const currentLogoUrl = currentPortId ? (portLogoUrls?.[currentPortId] ?? null) : null;
|
||||||
|
const tenanciesModuleEnabled = currentPortId
|
||||||
|
? (tenanciesModuleByPort?.[currentPortId] ?? false)
|
||||||
|
: false;
|
||||||
|
|
||||||
// Super admins see every section regardless of role rows.
|
// Super admins see every section regardless of role rows.
|
||||||
const hasAdminAccess =
|
const hasAdminAccess =
|
||||||
@@ -496,6 +525,7 @@ export function Sidebar({
|
|||||||
hasAdminAccess={hasAdminAccess}
|
hasAdminAccess={hasAdminAccess}
|
||||||
hasMarinaAccess={hasMarinaAccess}
|
hasMarinaAccess={hasMarinaAccess}
|
||||||
hasResidentialAccess={hasResidentialAccess}
|
hasResidentialAccess={hasResidentialAccess}
|
||||||
|
tenanciesModuleEnabled={tenanciesModuleEnabled}
|
||||||
user={user}
|
user={user}
|
||||||
ports={ports}
|
ports={ports}
|
||||||
currentPort={currentPort}
|
currentPort={currentPort}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
|
|
||||||
import { listHandler } from '@/app/api/v1/tenancies/handlers';
|
import { listHandler } from '@/app/api/v1/tenancies/handlers';
|
||||||
import { createHandler as createTenancyHandler } from '@/app/api/v1/berths/[id]/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 { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';
|
||||||
import {
|
import {
|
||||||
makeBerth,
|
makeBerth,
|
||||||
@@ -19,6 +20,12 @@ import {
|
|||||||
makeYacht,
|
makeYacht,
|
||||||
} from '../../helpers/factories';
|
} from '../../helpers/factories';
|
||||||
|
|
||||||
|
async function makePortWithTenancies(): Promise<Awaited<ReturnType<typeof makePort>>> {
|
||||||
|
const port = await makePort();
|
||||||
|
await enableTenanciesModule(port.id);
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
async function seedTenancy(portId: string) {
|
async function seedTenancy(portId: string) {
|
||||||
const berth = await makeBerth({ portId });
|
const berth = await makeBerth({ portId });
|
||||||
const client = await makeClient({ portId });
|
const client = await makeClient({ portId });
|
||||||
@@ -40,7 +47,7 @@ async function seedTenancy(portId: string) {
|
|||||||
|
|
||||||
describe('GET /api/v1/tenancies', () => {
|
describe('GET /api/v1/tenancies', () => {
|
||||||
it('returns all tenancies for the requesting port', async () => {
|
it('returns all tenancies for the requesting port', async () => {
|
||||||
const port = await makePort();
|
const port = await makePortWithTenancies();
|
||||||
const r1 = await seedTenancy(port.id);
|
const r1 = await seedTenancy(port.id);
|
||||||
const r2 = 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 () => {
|
it('does not leak tenancies from a different port', async () => {
|
||||||
const portA = await makePort();
|
const portA = await makePortWithTenancies();
|
||||||
const portB = await makePort();
|
const portB = await makePortWithTenancies();
|
||||||
const tenancyInB = await seedTenancy(portB.id);
|
const tenancyInB = await seedTenancy(portB.id);
|
||||||
|
|
||||||
// Caller is operating in portA; portB's tenancy must not appear.
|
// 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 () => {
|
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);
|
await seedTenancy(port.id);
|
||||||
await seedTenancy(port.id);
|
await seedTenancy(port.id);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '@/app/api/v1/tenancies/[id]/handlers';
|
} from '@/app/api/v1/tenancies/[id]/handlers';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { berthTenancies } from '@/lib/db/schema/tenancies';
|
import { berthTenancies } from '@/lib/db/schema/tenancies';
|
||||||
|
import { enableTenanciesModule } from '@/lib/services/tenancies-module.service';
|
||||||
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';
|
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';
|
||||||
import {
|
import {
|
||||||
makeBerth,
|
makeBerth,
|
||||||
@@ -22,11 +23,20 @@ import {
|
|||||||
makeYacht,
|
makeYacht,
|
||||||
} from '../../helpers/factories';
|
} 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<Awaited<ReturnType<typeof makePort>>> {
|
||||||
|
const port = await makePort();
|
||||||
|
await enableTenanciesModule(port.id);
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── POST /api/v1/berths/[id]/tenancies ───────────────────────────────────
|
// ─── POST /api/v1/berths/[id]/tenancies ───────────────────────────────────
|
||||||
|
|
||||||
describe('POST /api/v1/berths/[id]/tenancies', () => {
|
describe('POST /api/v1/berths/[id]/tenancies', () => {
|
||||||
it('creates pending reservation (201)', async () => {
|
it('creates pending reservation (201)', async () => {
|
||||||
const port = await makePort();
|
const port = await makePortWithTenancies();
|
||||||
const berth = await makeBerth({ portId: port.id });
|
const berth = await makeBerth({ portId: port.id });
|
||||||
const client = await makeClient({ portId: port.id });
|
const client = await makeClient({ portId: port.id });
|
||||||
const yacht = await makeYacht({
|
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 () => {
|
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 berth = await makeBerth({ portId: port.id });
|
||||||
const ownerClient = await makeClient({ portId: port.id });
|
const ownerClient = await makeClient({ portId: port.id });
|
||||||
const otherClient = 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 () => {
|
it('returns 404 when berth is cross-tenant', async () => {
|
||||||
const portA = await makePort();
|
const portA = await makePortWithTenancies();
|
||||||
const portB = await makePort();
|
const portB = await makePortWithTenancies();
|
||||||
const berthA = await makeBerth({ portId: portA.id });
|
const berthA = await makeBerth({ portId: portA.id });
|
||||||
const client = await makeClient({ portId: portB.id });
|
const client = await makeClient({ portId: portB.id });
|
||||||
const yacht = await makeYacht({
|
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 () => {
|
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 urlBerth = await makeBerth({ portId: port.id });
|
||||||
const bodyBerth = await makeBerth({ portId: port.id });
|
const bodyBerth = await makeBerth({ portId: port.id });
|
||||||
const client = await makeClient({ 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', () => {
|
describe('GET /api/v1/berths/[id]/tenancies', () => {
|
||||||
it('returns reservations filtered by that berth', async () => {
|
it('returns reservations filtered by that berth', async () => {
|
||||||
const port = await makePort();
|
const port = await makePortWithTenancies();
|
||||||
const berthA = await makeBerth({ portId: port.id });
|
const berthA = await makeBerth({ portId: port.id });
|
||||||
const berthB = await makeBerth({ portId: port.id });
|
const berthB = await makeBerth({ portId: port.id });
|
||||||
const client = await makeClient({ 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]', () => {
|
describe('GET /api/v1/tenancies/[id]', () => {
|
||||||
it('returns the reservation', async () => {
|
it('returns the reservation', async () => {
|
||||||
const port = await makePort();
|
const port = await makePortWithTenancies();
|
||||||
const berth = await makeBerth({ portId: port.id });
|
const berth = await makeBerth({ portId: port.id });
|
||||||
const client = await makeClient({ portId: port.id });
|
const client = await makeClient({ portId: port.id });
|
||||||
const yacht = await makeYacht({
|
const yacht = await makeYacht({
|
||||||
@@ -218,8 +228,8 @@ describe('GET /api/v1/tenancies/[id]', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns 404 for cross-tenant', async () => {
|
it('returns 404 for cross-tenant', async () => {
|
||||||
const portA = await makePort();
|
const portA = await makePortWithTenancies();
|
||||||
const portB = await makePort();
|
const portB = await makePortWithTenancies();
|
||||||
const berth = await makeBerth({ portId: portA.id });
|
const berth = await makeBerth({ portId: portA.id });
|
||||||
const client = await makeClient({ portId: portA.id });
|
const client = await makeClient({ portId: portA.id });
|
||||||
const yacht = await makeYacht({
|
const yacht = await makeYacht({
|
||||||
@@ -256,7 +266,7 @@ describe('GET /api/v1/tenancies/[id]', () => {
|
|||||||
|
|
||||||
describe('PATCH /api/v1/tenancies/[id]', () => {
|
describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||||
async function seedReservation() {
|
async function seedReservation() {
|
||||||
const port = await makePort();
|
const port = await makePortWithTenancies();
|
||||||
const berth = await makeBerth({ portId: port.id });
|
const berth = await makeBerth({ portId: port.id });
|
||||||
const client = await makeClient({ portId: port.id });
|
const client = await makeClient({ portId: port.id });
|
||||||
const yacht = await makeYacht({
|
const yacht = await makeYacht({
|
||||||
@@ -438,7 +448,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
|||||||
|
|
||||||
describe('DELETE /api/v1/tenancies/[id]', () => {
|
describe('DELETE /api/v1/tenancies/[id]', () => {
|
||||||
it('cancels the reservation (204)', async () => {
|
it('cancels the reservation (204)', async () => {
|
||||||
const port = await makePort();
|
const port = await makePortWithTenancies();
|
||||||
const berth = await makeBerth({ portId: port.id });
|
const berth = await makeBerth({ portId: port.id });
|
||||||
const client = await makeClient({ portId: port.id });
|
const client = await makeClient({ portId: port.id });
|
||||||
const yacht = await makeYacht({
|
const yacht = await makeYacht({
|
||||||
|
|||||||
Reference in New Issue
Block a user