diff --git a/src/app/ase-next/[domain]/layout.tsx b/src/app/ase-next/[domain]/layout.tsx index cedf4ca2..c865c568 100644 --- a/src/app/ase-next/[domain]/layout.tsx +++ b/src/app/ase-next/[domain]/layout.tsx @@ -4,9 +4,11 @@ import type { ReactNode } from "react"; import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context"; import { buildHousekeepingNavigation } from "@/features/housekeeping/foundation/navigation"; import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry"; +import { createHousekeepingRouteRuntime } from "@/features/housekeeping/foundation/routing/runtime"; import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context"; import { HousekeepingShell } from "@/features/housekeeping/foundation/shell/housekeeping-shell"; import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests"; +import { HOUSEKEEPING_ROUTE_HANDLERS } from "@/features/housekeeping/route-handlers"; const MESSAGE_PREFIX = "pages.housekeeping."; @@ -27,6 +29,10 @@ export default async function AdminNextDomainLayout({ }) { const { domain } = await params; const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS); + const runtime = createHousekeepingRouteRuntime( + registry, + HOUSEKEEPING_ROUTE_HANDLERS, + ); const activeDomain = registry.domains.find((entry) => entry.id === domain); if (!activeDomain) notFound(); @@ -35,7 +41,7 @@ export default async function AdminNextDomainLayout({ if (!satisfiesCapability(context, activeDomain.capability)) notFound(); const translate = await getTranslations("pages.housekeeping"); - const navigation = buildHousekeepingNavigation(registry, context, (key) => + const navigation = buildHousekeepingNavigation(runtime, context, (key) => translate(namespaceKey(key) as never), ); diff --git a/src/features/housekeeping/foundation/navigation.test.ts b/src/features/housekeeping/foundation/navigation.test.ts index 204a0fc7..954d1736 100644 --- a/src/features/housekeeping/foundation/navigation.test.ts +++ b/src/features/housekeeping/foundation/navigation.test.ts @@ -1,11 +1,17 @@ import { describe, expect, it, vi } from "vitest"; import { PERMS } from "@/lib/permission-slugs"; import { HOUSEKEEPING_MANIFESTS } from "../manifests"; -import { anyCapability, type HousekeepingCapabilityContext } from "./contracts"; +import { + anyCapability, + type HousekeepingCapabilityContext, + type HousekeepingDomainManifest, +} from "./contracts"; import { buildHousekeepingNavigation } from "./navigation"; import { createHousekeepingRegistry } from "./registry"; +import type { HousekeepingRouteHandler } from "./routing/route-handler"; +import { createHousekeepingRouteRuntime } from "./routing/runtime"; -const context = ( +const capabilityContext = ( granted: readonly string[], ): HousekeepingCapabilityContext => ({ actor: { id: 42, username: "moderator", rank: 3 }, @@ -15,159 +21,168 @@ const context = ( hasAll: (...slugs) => slugs.every((slug) => granted.includes(slug)), }); -describe("housekeeping navigation", () => { - it("shows People to a moderator with a mod view capability while hiding Economy", () => { - const registry = createHousekeepingRegistry([ - { - id: "people", - labelKey: "pages.housekeeping.domains.people.title", - descriptionKey: "pages.housekeeping.domains.people.description", - iconId: "users", - previewHref: "/ase-next/people", - capability: anyCapability(PERMS.MOD_CFH_VIEW), - landingRouteId: null, - routes: [], - searchProviders: [], - inboxSources: [], - widgets: [], - }, - { - id: "economy", - labelKey: "pages.housekeeping.domains.economy.title", - descriptionKey: "pages.housekeeping.domains.economy.description", - iconId: "gem", - previewHref: "/ase-next/economy", - capability: anyCapability(PERMS.CATALOG_VIEW), - landingRouteId: null, - routes: [], - searchProviders: [], - inboxSources: [], - widgets: [], - }, - ]); +const peopleManifest = { + id: "people", + labelKey: "people.title", + descriptionKey: "people.description", + iconId: "users", + previewHref: "/ase-next/people", + capability: anyCapability( + PERMS.USERS_VIEW, + PERMS.TICKETS_VIEW, + PERMS.BANS_VIEW, + ), + landingRouteId: "people.users", + routes: [ + { + id: "people.users", + labelKey: "people.users", + href: "/ase-next/people/users", + capability: anyCapability(PERMS.USERS_VIEW), + }, + { + id: "people.user-detail", + labelKey: "people.userDetail", + href: "/ase-next/people/users/:id", + capability: anyCapability(PERMS.USERS_VIEW), + }, + { + id: "people.tickets", + labelKey: "people.tickets", + href: "/ase-next/people/support/tickets", + capability: anyCapability(PERMS.TICKETS_VIEW), + }, + ], + searchProviders: [], + inboxSources: [], + widgets: [], +} satisfies HousekeepingDomainManifest; +const handler = (routeId: string): HousekeepingRouteHandler => ({ + routeId, + render: async () => `Rendered ${routeId}`, +}); + +const peopleRuntime = createHousekeepingRouteRuntime( + createHousekeepingRegistry([peopleManifest]), + peopleManifest.routes.map((route) => handler(route.id)), +); +const identityTranslate = (key: string) => key; + +describe("housekeeping navigation", () => { + it("links a domain to its accessible handled landing route", () => { const navigation = buildHousekeepingNavigation( - registry, - context([PERMS.MOD_CFH_VIEW]), - (key) => key, + peopleRuntime, + capabilityContext([PERMS.USERS_VIEW]), + identityTranslate, ); expect(navigation).toEqual([ { id: "people", - href: "/ase-next/people", + href: "/ase-next/people/users", iconId: "users", - label: "pages.housekeeping.domains.people.title", - description: "pages.housekeeping.domains.people.description", - items: [], + label: "people.title", + description: "people.description", + items: [ + { + id: "people.users", + href: "/ase-next/people/users", + label: "people.users", + }, + ], }, ]); }); - it("filters unauthorized domains and routes before translation", () => { - const registry = createHousekeepingRegistry([ - { - id: "people", - labelKey: "people.title", - descriptionKey: "people.description", - iconId: "users", - previewHref: "/ase-next/people", - capability: anyCapability(PERMS.USERS_VIEW), - landingRouteId: "users", - routes: [ - { - id: "users", - labelKey: "people.users", - href: "/ase-next/people/users", - capability: anyCapability(PERMS.USERS_VIEW), - }, - { - id: "bans", - labelKey: "people.bans", - href: "/ase-next/people/bans", - capability: anyCapability(PERMS.BANS_VIEW), - }, - ], - searchProviders: [], - inboxSources: [], - widgets: [], - }, - { - id: "system", - labelKey: "system.title", - descriptionKey: "system.description", - iconId: "settings", - previewHref: "/ase-next/system", - capability: anyCapability(PERMS.SETTINGS_VIEW), - landingRouteId: null, - routes: [], - searchProviders: [], - inboxSources: [], - widgets: [], - }, + it("falls back to the first accessible handled route", () => { + const navigation = buildHousekeepingNavigation( + peopleRuntime, + capabilityContext([PERMS.TICKETS_VIEW]), + identityTranslate, + ); + + expect(navigation[0]?.href).toBe("/ase-next/people/support/tickets"); + expect(navigation[0]?.items.map((item) => item.id)).toEqual([ + "people.tickets", ]); + }); + + it("omits a domain with no accessible handled concrete route", () => { + expect( + buildHousekeepingNavigation( + peopleRuntime, + capabilityContext([PERMS.BANS_VIEW]), + identityTranslate, + ), + ).toEqual([]); + }); + + it("does not expose dynamic route patterns as navigation links", () => { + const navigation = buildHousekeepingNavigation( + peopleRuntime, + capabilityContext([PERMS.USERS_VIEW]), + identityTranslate, + ); + + expect(navigation[0]?.items.map((item) => item.id)).not.toContain( + "people.user-detail", + ); + expect(navigation[0]?.items.map((item) => item.href)).not.toContain( + "/ase-next/people/users/:id", + ); + }); + + it("filters inaccessible routes before translation", () => { const translate = vi.fn((key: string) => `translated:${key}`); - const navigation = buildHousekeepingNavigation( - registry, - context([PERMS.USERS_VIEW]), + buildHousekeepingNavigation( + peopleRuntime, + capabilityContext([PERMS.TICKETS_VIEW]), translate, ); - expect(navigation).toEqual([ - { - id: "people", - href: "/ase-next/people", - iconId: "users", - label: "translated:people.title", - description: "translated:people.description", - items: [ - { - id: "users", - href: "/ase-next/people/users", - label: "translated:people.users", - }, - ], - }, - ]); - expect(translate).not.toHaveBeenCalledWith("people.bans"); - expect(translate).not.toHaveBeenCalledWith("system.title"); - expect(translate).not.toHaveBeenCalledWith("system.description"); + expect(translate).toHaveBeenCalledWith("people.title"); + expect(translate).toHaveBeenCalledWith("people.description"); + expect(translate).toHaveBeenCalledWith("people.tickets"); + expect(translate).not.toHaveBeenCalledWith("people.users"); + expect(translate).not.toHaveBeenCalledWith("people.userDetail"); }); - it("shows real domains to operators authorized by owned migration capabilities", () => { - const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS); - const translate = (key: string) => key; + + it("keeps current empty manifests out of navigation until a vertical ships", () => { + const emptyRuntime = createHousekeepingRouteRuntime( + createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS), + [], + ); expect( buildHousekeepingNavigation( - registry, - context([PERMS.MOD_CFH_VIEW]), - translate, - ).map((domain) => domain.id), - ).toContain("people"); - expect( - buildHousekeepingNavigation( - registry, - context([PERMS.MOD_CFH_VIEW]), - translate, - ).map((domain) => domain.id), - ).not.toContain("economy"); + emptyRuntime, + capabilityContext([PERMS.MOD_CFH_VIEW]), + identityTranslate, + ), + ).toEqual([]); + }); - for (const [slug, expectedDomain] of [ - [PERMS.MOD_ACTIONS, "people"], - [PERMS.USERS_EDIT, "people"], - [PERMS.SETTINGS_VIEW, "people"], - [PERMS.NEWS_EDIT, "content"], - [PERMS.SHOP_EDIT, "economy"], - [PERMS.ROOMS_EDIT, "hotel"], - ] as const) { - const visibleDomainIds = buildHousekeepingNavigation( - registry, - context([slug]), - translate, - ).map((domain) => domain.id); + it("projects only hrefs that the runtime can resolve", () => { + const capabilityProfiles = [ + capabilityContext([PERMS.USERS_VIEW]), + capabilityContext([PERMS.TICKETS_VIEW]), + capabilityContext([PERMS.BANS_VIEW]), + capabilityContext([PERMS.USERS_VIEW, PERMS.TICKETS_VIEW]), + ]; - expect(visibleDomainIds, slug).toContain(expectedDomain); + for (const profile of capabilityProfiles) { + for (const domain of buildHousekeepingNavigation( + peopleRuntime, + profile, + identityTranslate, + )) { + expect(peopleRuntime.match(domain.href), domain.href).not.toBeNull(); + for (const item of domain.items) { + expect(peopleRuntime.match(item.href), item.href).not.toBeNull(); + } + } } }); }); diff --git a/src/features/housekeeping/foundation/navigation.ts b/src/features/housekeeping/foundation/navigation.ts index 5af2037f..39a51fdb 100644 --- a/src/features/housekeeping/foundation/navigation.ts +++ b/src/features/housekeeping/foundation/navigation.ts @@ -3,37 +3,63 @@ import { satisfiesCapability } from "./capability-context"; import type { HousekeepingCapabilityContext, HousekeepingDomainManifest, + HousekeepingPreviewHref, } from "./contracts"; -import type { HousekeepingRegistry } from "./registry"; +import type { HousekeepingRouteRuntime } from "./routing/runtime"; export interface HousekeepingNavigationDomain { id: HousekeepingDomainId; - href: string; + href: HousekeepingPreviewHref; iconId: HousekeepingDomainManifest["iconId"]; label: string; description: string; - items: readonly { id: string; href: string; label: string }[]; + items: readonly { + id: string; + href: HousekeepingPreviewHref; + label: string; + }[]; } export function buildHousekeepingNavigation( - registry: HousekeepingRegistry, + runtime: HousekeepingRouteRuntime, context: HousekeepingCapabilityContext, translate: (key: string) => string, ): readonly HousekeepingNavigationDomain[] { - return registry.domains - .filter((domain) => satisfiesCapability(context, domain.capability)) - .map((domain) => ({ - id: domain.id, - href: domain.previewHref, - iconId: domain.iconId, - label: translate(domain.labelKey), - description: translate(domain.descriptionKey), - items: domain.routes - .filter((route) => satisfiesCapability(context, route.capability)) - .map((route) => ({ - id: route.id, - href: route.href, - label: translate(route.labelKey), - })), - })); + return runtime.registry.domains.flatMap((domain) => { + if (!satisfiesCapability(context, domain.capability)) return []; + + const items = domain.routes + .filter( + (route) => + runtime.handlers.has(route.id) && + isConcreteNavigationHref(route.href) && + satisfiesCapability(context, route.capability), + ) + .map((route) => ({ + id: route.id, + href: route.href, + label: translate(route.labelKey), + })); + + if (items.length === 0) return []; + + const landing = + items.find((item) => item.id === domain.landingRouteId) ?? items[0]; + if (!landing) return []; + + return [ + { + id: domain.id, + href: landing.href, + iconId: domain.iconId, + label: translate(domain.labelKey), + description: translate(domain.descriptionKey), + items, + }, + ]; + }); +} + +function isConcreteNavigationHref(href: HousekeepingPreviewHref): boolean { + return !href.split("/").some((segment) => segment.startsWith(":")); } diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts index 1791c959..834a769f 100644 --- a/src/features/housekeeping/foundation/preview-route-contract.test.ts +++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts @@ -483,7 +483,7 @@ describe("/ase-next/[domain] layout", () => { expect(routeMocks.getTranslations).not.toHaveBeenCalled(); }); - it("renders localized People shell from one refreshed capability context", async () => { + it("renders the shell without exposing a domain that has no concrete routes", async () => { routeMocks.getHousekeepingCapabilityContext.mockResolvedValue( capabilityContext([PERMS.MOD_CFH_VIEW]), ); @@ -502,9 +502,12 @@ describe("/ase-next/[domain] layout", () => { expect(html).toContain("HK::command-disabled"); expect(html).toContain("HK::preview-badge"); expect(html).toContain("HK::back-to-site"); - expect(html).toContain("HK::people-title"); + expect(html).not.toContain("HK::people-title"); expect(html).toContain("People body"); expect(html).not.toContain("Localized Economy"); + expect(routeMocks.translate).not.toHaveBeenCalledWith( + "domains.people.title", + ); expect(routeMocks.translate).not.toHaveBeenCalledWith( "domains.economy.title", ); diff --git a/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx b/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx index f462514a..157adbf8 100644 --- a/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx +++ b/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx @@ -17,7 +17,7 @@ const labels = { const domains: readonly HousekeepingNavigationDomain[] = [ { id: "operations", - href: "/ase-next/operations", + href: "/ase-next/operations/queue", iconId: "inbox", label: "Operations", description: "Manage operations", @@ -27,7 +27,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [ }, { id: "people", - href: "/ase-next/people", + href: "/ase-next/people/users", iconId: "users", label: "People", description: "Manage people", @@ -35,7 +35,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [ }, { id: "content", - href: "/ase-next/content", + href: "/ase-next/content/articles", iconId: "file-text", label: "Content", description: "Manage content", @@ -43,7 +43,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [ }, { id: "economy", - href: "/ase-next/economy", + href: "/ase-next/economy/catalog", iconId: "gem", label: "Economy", description: "Manage economy", @@ -51,7 +51,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [ }, { id: "hotel", - href: "/ase-next/hotel", + href: "/ase-next/hotel/rooms", iconId: "hotel", label: "Hotel", description: "Manage hotel", @@ -59,7 +59,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [ }, { id: "system", - href: "/ase-next/system", + href: "/ase-next/system/settings", iconId: "settings", label: "System", description: "Manage system", @@ -196,7 +196,7 @@ describe("HousekeepingShell", () => { const activeAnchors = allAnchors.filter((anchor) => anchor.includes('aria-current="page"'), ); - const peopleAnchor = anchorForHref(html, "/ase-next/people"); + const peopleAnchor = anchorForHref(html, "/ase-next/people/users"); for (const iconClass of [ "lucide-inbox", "lucide-users", @@ -222,7 +222,7 @@ describe("HousekeepingShell", () => { ); expect(() => renderShell("system", domainsWithoutSystem)).not.toThrow(); const html = renderShell("system", domainsWithoutSystem); - expect(html).not.toContain('href="/ase-next/operations/queue"'); + expect(html.match(/href="\/ase-next\/operations\/queue"/g)).toHaveLength(1); expect(html).not.toContain(">Queue<"); }); diff --git a/src/features/housekeeping/route-handlers.ts b/src/features/housekeeping/route-handlers.ts new file mode 100644 index 00000000..2014af29 --- /dev/null +++ b/src/features/housekeeping/route-handlers.ts @@ -0,0 +1,4 @@ +import type { HousekeepingRouteHandler } from "./foundation/routing/route-handler"; + +export const HOUSEKEEPING_ROUTE_HANDLERS = + [] as const satisfies readonly HousekeepingRouteHandler[];