From f72a2b6c74a4333324a2c4cda1295c0ca8b7db39 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 26 Aug 2026 20:44:38 +0200 Subject: [PATCH] refactor(housekeeping): adopt ase route namespace --- .../[domain]/layout.tsx | 7 +- .../[domain]/page.tsx | 0 src/app/{admin-next => ase-next}/layout.tsx | 0 src/app/{admin-next => ase-next}/page.tsx | 3 +- .../housekeeping/domains/content/manifest.ts | 2 +- .../housekeeping/domains/economy/manifest.ts | 2 +- .../housekeeping/domains/hotel/manifest.ts | 2 +- .../domains/operations/manifest.ts | 2 +- .../housekeeping/domains/people/manifest.ts | 2 +- .../housekeeping/domains/system/manifest.ts | 2 +- .../foundation/contracts/contracts.test.ts | 6 +- .../foundation/contracts/domain.ts | 6 +- .../foundation/contracts/index.ts | 2 + .../foundation-source-contract.test.ts | 2 +- .../foundation/navigation.test.ts | 58 +++++++-- .../housekeeping/foundation/navigation.ts | 7 +- .../page/housekeeping-page-state.test.tsx | 8 +- .../foundation/preview-route-contract.test.ts | 122 +++++++++--------- .../housekeeping/foundation/registry.test.ts | 55 +++++--- .../housekeeping/foundation/registry.ts | 10 +- .../foundation/routing/href.test.ts | 21 +++ .../housekeeping/foundation/routing/href.ts | 15 +++ .../shell/housekeeping-shell.test.tsx | 20 +-- .../housekeeping/migration/content.ts | 58 ++++----- .../housekeeping/migration/economy.ts | 40 +++--- src/features/housekeeping/migration/hotel.ts | 58 ++++----- .../housekeeping/migration/operations.ts | 2 +- src/features/housekeeping/migration/people.ts | 78 +++++------ src/features/housekeeping/migration/system.ts | 34 ++--- .../migration/validate-matrix.test.ts | 20 ++- .../housekeeping/migration/validate-matrix.ts | 6 +- src/lib/admin-theme-source-audit.test.ts | 2 +- 32 files changed, 388 insertions(+), 264 deletions(-) rename src/app/{admin-next => ase-next}/[domain]/layout.tsx (93%) rename src/app/{admin-next => ase-next}/[domain]/page.tsx (100%) rename src/app/{admin-next => ase-next}/layout.tsx (100%) rename src/app/{admin-next => ase-next}/page.tsx (82%) create mode 100644 src/features/housekeeping/foundation/routing/href.test.ts create mode 100644 src/features/housekeeping/foundation/routing/href.ts diff --git a/src/app/admin-next/[domain]/layout.tsx b/src/app/ase-next/[domain]/layout.tsx similarity index 93% rename from src/app/admin-next/[domain]/layout.tsx rename to src/app/ase-next/[domain]/layout.tsx index cedf4ca2..72d73818 100644 --- a/src/app/admin-next/[domain]/layout.tsx +++ b/src/app/ase-next/[domain]/layout.tsx @@ -35,8 +35,11 @@ export default async function AdminNextDomainLayout({ if (!satisfiesCapability(context, activeDomain.capability)) notFound(); const translate = await getTranslations("pages.housekeeping"); - const navigation = buildHousekeepingNavigation(registry, context, (key) => - translate(namespaceKey(key) as never), + const navigation = buildHousekeepingNavigation( + registry, + context, + "preview", + (key) => translate(namespaceKey(key) as never), ); return ( diff --git a/src/app/admin-next/[domain]/page.tsx b/src/app/ase-next/[domain]/page.tsx similarity index 100% rename from src/app/admin-next/[domain]/page.tsx rename to src/app/ase-next/[domain]/page.tsx diff --git a/src/app/admin-next/layout.tsx b/src/app/ase-next/layout.tsx similarity index 100% rename from src/app/admin-next/layout.tsx rename to src/app/ase-next/layout.tsx diff --git a/src/app/admin-next/page.tsx b/src/app/ase-next/page.tsx similarity index 82% rename from src/app/admin-next/page.tsx rename to src/app/ase-next/page.tsx index b36e4e7d..7ff08c60 100644 --- a/src/app/admin-next/page.tsx +++ b/src/app/ase-next/page.tsx @@ -1,6 +1,7 @@ import { notFound, redirect } from "next/navigation"; import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context"; import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry"; +import { toHousekeepingHref } from "@/features/housekeeping/foundation/routing/href"; import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context"; import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests"; @@ -13,5 +14,5 @@ export default async function AdminNextPage() { if (!firstVisibleDomain) notFound(); - redirect(firstVisibleDomain.previewHref); + redirect(toHousekeepingHref(firstVisibleDomain.canonicalHref, "preview")); } diff --git a/src/features/housekeeping/domains/content/manifest.ts b/src/features/housekeeping/domains/content/manifest.ts index f367587c..418995ff 100644 --- a/src/features/housekeeping/domains/content/manifest.ts +++ b/src/features/housekeeping/domains/content/manifest.ts @@ -9,7 +9,7 @@ export const contentManifest = { labelKey: "pages.housekeeping.domains.content.title", descriptionKey: "pages.housekeeping.domains.content.description", iconId: "file-text", - previewHref: "/admin-next/content", + canonicalHref: "/ase/content", capability: anyCapability( PERMS.NEWS_VIEW, PERMS.PAGES_VIEW, diff --git a/src/features/housekeeping/domains/economy/manifest.ts b/src/features/housekeeping/domains/economy/manifest.ts index 8eddf3b5..d500e1e0 100644 --- a/src/features/housekeeping/domains/economy/manifest.ts +++ b/src/features/housekeeping/domains/economy/manifest.ts @@ -9,7 +9,7 @@ export const economyManifest = { labelKey: "pages.housekeeping.domains.economy.title", descriptionKey: "pages.housekeeping.domains.economy.description", iconId: "gem", - previewHref: "/admin-next/economy", + canonicalHref: "/ase/economy", capability: anyCapability( PERMS.CATALOG_VIEW, PERMS.SHOP_VIEW, diff --git a/src/features/housekeeping/domains/hotel/manifest.ts b/src/features/housekeeping/domains/hotel/manifest.ts index 8c0ef95b..1c89cd05 100644 --- a/src/features/housekeeping/domains/hotel/manifest.ts +++ b/src/features/housekeeping/domains/hotel/manifest.ts @@ -9,7 +9,7 @@ export const hotelManifest = { labelKey: "pages.housekeeping.domains.hotel.title", descriptionKey: "pages.housekeeping.domains.hotel.description", iconId: "hotel", - previewHref: "/admin-next/hotel", + canonicalHref: "/ase/hotel", capability: anyCapability( PERMS.ROOMS_VIEW, PERMS.RADIO_VIEW, diff --git a/src/features/housekeeping/domains/operations/manifest.ts b/src/features/housekeeping/domains/operations/manifest.ts index c06d2687..931693bc 100644 --- a/src/features/housekeeping/domains/operations/manifest.ts +++ b/src/features/housekeeping/domains/operations/manifest.ts @@ -9,7 +9,7 @@ export const operationsManifest = { labelKey: "pages.housekeeping.domains.operations.title", descriptionKey: "pages.housekeeping.domains.operations.description", iconId: "inbox", - previewHref: "/admin-next/operations", + canonicalHref: "/ase", capability: anyCapability(PERMS.ADMIN_DASHBOARD), routes: [], searchProviders: [], diff --git a/src/features/housekeeping/domains/people/manifest.ts b/src/features/housekeeping/domains/people/manifest.ts index 21d1f3ea..4fa1c1fa 100644 --- a/src/features/housekeeping/domains/people/manifest.ts +++ b/src/features/housekeeping/domains/people/manifest.ts @@ -9,7 +9,7 @@ export const peopleManifest = { labelKey: "pages.housekeeping.domains.people.title", descriptionKey: "pages.housekeeping.domains.people.description", iconId: "users", - previewHref: "/admin-next/people", + canonicalHref: "/ase/people", capability: anyCapability( PERMS.USERS_VIEW, PERMS.MODERATION_VIEW, diff --git a/src/features/housekeeping/domains/system/manifest.ts b/src/features/housekeeping/domains/system/manifest.ts index f4ba2345..e7fd86bb 100644 --- a/src/features/housekeeping/domains/system/manifest.ts +++ b/src/features/housekeeping/domains/system/manifest.ts @@ -9,7 +9,7 @@ export const systemManifest = { labelKey: "pages.housekeeping.domains.system.title", descriptionKey: "pages.housekeeping.domains.system.description", iconId: "settings", - previewHref: "/admin-next/system", + canonicalHref: "/ase/system", capability: anyCapability( PERMS.SETTINGS_VIEW, PERMS.LOGS_VIEW, diff --git a/src/features/housekeeping/foundation/contracts/contracts.test.ts b/src/features/housekeeping/foundation/contracts/contracts.test.ts index caf21dd0..dfc53c99 100644 --- a/src/features/housekeeping/foundation/contracts/contracts.test.ts +++ b/src/features/housekeeping/foundation/contracts/contracts.test.ts @@ -28,7 +28,7 @@ const workItem = { occurredAt: "2026-08-24T12:00:00.000Z", titleKey: "pages.housekeeping.items.ticket", context: { ticketId: "42" }, - href: "/admin-next/people/tickets/42", + href: "/ase-next/people/tickets/42", freshness: "fresh", } satisfies HousekeepingWorkItem; @@ -36,7 +36,7 @@ const searchResult = { id: "user-42", domain: "people", title: "operator", - href: "/admin-next/people/users/42", + href: "/ase-next/people/users/42", } satisfies HousekeepingSearchResult; const searchProvider: HousekeepingSearchProvider = { @@ -87,7 +87,7 @@ const manifest: HousekeepingDomainManifest = { labelKey: "pages.housekeeping.domains.people.title", descriptionKey: "pages.housekeeping.domains.people.description", iconId: "users", - previewHref: "/admin-next/people", + canonicalHref: "/ase-next/people", capability, routes: [], searchProviders: [searchProvider], diff --git a/src/features/housekeeping/foundation/contracts/domain.ts b/src/features/housekeeping/foundation/contracts/domain.ts index 9ccb4f54..a4c6f9eb 100644 --- a/src/features/housekeeping/foundation/contracts/domain.ts +++ b/src/features/housekeeping/foundation/contracts/domain.ts @@ -4,10 +4,12 @@ import type { HousekeepingInboxSource } from "./inbox"; import type { HousekeepingSearchProvider } from "./search"; import type { HousekeepingWidgetDefinition } from "./widget"; +export type HousekeepingSurface = "preview" | "canonical"; +export type CanonicalHousekeepingHref = `/ase${string}`; export interface HousekeepingRouteDefinition { id: string; labelKey: string; - href: string; + href: CanonicalHousekeepingHref; capability: CapabilityRequirement; matchPrefixes?: readonly string[]; } @@ -17,7 +19,7 @@ export interface HousekeepingDomainManifest { labelKey: string; descriptionKey: string; iconId: "inbox" | "users" | "file-text" | "gem" | "hotel" | "settings"; - previewHref: `/admin-next/${HousekeepingDomainId}`; + canonicalHref: CanonicalHousekeepingHref; capability: CapabilityRequirement; routes: readonly HousekeepingRouteDefinition[]; searchProviders: readonly HousekeepingSearchProvider[]; diff --git a/src/features/housekeeping/foundation/contracts/index.ts b/src/features/housekeeping/foundation/contracts/index.ts index 55f607fb..5f0e35ca 100644 --- a/src/features/housekeeping/foundation/contracts/index.ts +++ b/src/features/housekeeping/foundation/contracts/index.ts @@ -7,8 +7,10 @@ export { } from "./capability"; export type { HousekeepingCommand } from "./command"; export type { + CanonicalHousekeepingHref, HousekeepingDomainManifest, HousekeepingRouteDefinition, + HousekeepingSurface, } from "./domain"; export type { HousekeepingInboxSource, diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index 3c7f541a..561edd00 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -555,7 +555,7 @@ describe("housekeeping foundation completion contracts", () => { for (const path of [ "src/app/admin/layout.tsx", "src/app/mod/layout.tsx", - "src/app/admin-next/layout.tsx", + "src/app/ase-next/layout.tsx", ]) { expect(existsSync(path), path).toBe(true); } diff --git a/src/features/housekeeping/foundation/navigation.test.ts b/src/features/housekeeping/foundation/navigation.test.ts index 88dce236..328732aa 100644 --- a/src/features/housekeeping/foundation/navigation.test.ts +++ b/src/features/housekeeping/foundation/navigation.test.ts @@ -23,7 +23,7 @@ describe("housekeeping navigation", () => { labelKey: "pages.housekeeping.domains.people.title", descriptionKey: "pages.housekeeping.domains.people.description", iconId: "users", - previewHref: "/admin-next/people", + canonicalHref: "/ase/people", capability: anyCapability(PERMS.MOD_CFH_VIEW), routes: [], searchProviders: [], @@ -35,7 +35,7 @@ describe("housekeeping navigation", () => { labelKey: "pages.housekeeping.domains.economy.title", descriptionKey: "pages.housekeeping.domains.economy.description", iconId: "gem", - previewHref: "/admin-next/economy", + canonicalHref: "/ase/economy", capability: anyCapability(PERMS.CATALOG_VIEW), routes: [], searchProviders: [], @@ -47,13 +47,14 @@ describe("housekeeping navigation", () => { const navigation = buildHousekeepingNavigation( registry, context([PERMS.MOD_CFH_VIEW]), + "canonical", (key) => key, ); expect(navigation).toEqual([ { id: "people", - href: "/admin-next/people", + href: "/ase/people", iconId: "users", label: "pages.housekeeping.domains.people.title", description: "pages.housekeeping.domains.people.description", @@ -69,19 +70,19 @@ describe("housekeeping navigation", () => { labelKey: "people.title", descriptionKey: "people.description", iconId: "users", - previewHref: "/admin-next/people", + canonicalHref: "/ase/people", capability: anyCapability(PERMS.USERS_VIEW), routes: [ { id: "users", labelKey: "people.users", - href: "/admin-next/people/users", + href: "/ase/people/users", capability: anyCapability(PERMS.USERS_VIEW), }, { id: "bans", labelKey: "people.bans", - href: "/admin-next/people/bans", + href: "/ase/people/bans", capability: anyCapability(PERMS.BANS_VIEW), }, ], @@ -94,7 +95,7 @@ describe("housekeeping navigation", () => { labelKey: "system.title", descriptionKey: "system.description", iconId: "settings", - previewHref: "/admin-next/system", + canonicalHref: "/ase/system", capability: anyCapability(PERMS.SETTINGS_VIEW), routes: [], searchProviders: [], @@ -107,20 +108,21 @@ describe("housekeeping navigation", () => { const navigation = buildHousekeepingNavigation( registry, context([PERMS.USERS_VIEW]), + "canonical", translate, ); expect(navigation).toEqual([ { id: "people", - href: "/admin-next/people", + href: "/ase/people", iconId: "users", label: "translated:people.title", description: "translated:people.description", items: [ { id: "users", - href: "/admin-next/people/users", + href: "/ase/people/users", label: "translated:people.users", }, ], @@ -138,6 +140,7 @@ describe("housekeeping navigation", () => { buildHousekeepingNavigation( registry, context([PERMS.MOD_CFH_VIEW]), + "preview", translate, ).map((domain) => domain.id), ).toContain("people"); @@ -145,6 +148,7 @@ describe("housekeeping navigation", () => { buildHousekeepingNavigation( registry, context([PERMS.MOD_CFH_VIEW]), + "preview", translate, ).map((domain) => domain.id), ).not.toContain("economy"); @@ -160,6 +164,7 @@ describe("housekeeping navigation", () => { const visibleDomainIds = buildHousekeepingNavigation( registry, context([slug]), + "preview", translate, ).map((domain) => domain.id); @@ -167,3 +172,38 @@ describe("housekeeping navigation", () => { } }); }); + +it("maps canonical navigation hrefs onto the requested surface", () => { + const registry = createHousekeepingRegistry([ + { + id: "people", + labelKey: "people.title", + descriptionKey: "people.description", + iconId: "users", + canonicalHref: "/ase/people", + capability: anyCapability(PERMS.USERS_VIEW), + routes: [ + { + id: "users", + labelKey: "people.users", + href: "/ase/people/users", + capability: anyCapability(PERMS.USERS_VIEW), + }, + ], + searchProviders: [], + inboxSources: [], + widgets: [], + }, + ]); + const navigation = buildHousekeepingNavigation( + registry, + context([PERMS.USERS_VIEW]), + "preview", + (key) => key, + ); + + expect(navigation[0]).toMatchObject({ + href: "/ase-next/people", + items: [{ href: "/ase-next/people/users" }], + }); +}); diff --git a/src/features/housekeeping/foundation/navigation.ts b/src/features/housekeeping/foundation/navigation.ts index 5af2037f..02b63cc7 100644 --- a/src/features/housekeeping/foundation/navigation.ts +++ b/src/features/housekeeping/foundation/navigation.ts @@ -3,8 +3,10 @@ import { satisfiesCapability } from "./capability-context"; import type { HousekeepingCapabilityContext, HousekeepingDomainManifest, + HousekeepingSurface, } from "./contracts"; import type { HousekeepingRegistry } from "./registry"; +import { toHousekeepingHref } from "./routing/href"; export interface HousekeepingNavigationDomain { id: HousekeepingDomainId; @@ -18,13 +20,14 @@ export interface HousekeepingNavigationDomain { export function buildHousekeepingNavigation( registry: HousekeepingRegistry, context: HousekeepingCapabilityContext, + surface: HousekeepingSurface, translate: (key: string) => string, ): readonly HousekeepingNavigationDomain[] { return registry.domains .filter((domain) => satisfiesCapability(context, domain.capability)) .map((domain) => ({ id: domain.id, - href: domain.previewHref, + href: toHousekeepingHref(domain.canonicalHref, surface), iconId: domain.iconId, label: translate(domain.labelKey), description: translate(domain.descriptionKey), @@ -32,7 +35,7 @@ export function buildHousekeepingNavigation( .filter((route) => satisfiesCapability(context, route.capability)) .map((route) => ({ id: route.id, - href: route.href, + href: toHousekeepingHref(route.href, surface), label: translate(route.labelKey), })), })); diff --git a/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx b/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx index b6c9ecbf..13c02396 100644 --- a/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx +++ b/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx @@ -75,12 +75,12 @@ describe("HousekeepingPageState", () => { state="error" title="Could not load" description="Try again later" - retryAction={Retry preview} + retryAction={Retry preview} />, ); expect(html).toContain('role="alert"'); - expect(html).toContain('href="/admin-next/operations"'); + expect(html).toContain('href="/ase-next/operations"'); expect(html).toContain(">Retry preview<"); }); }); @@ -92,7 +92,7 @@ describe("HousekeepingPageShell", () => { title="People" description="Review operator-facing people data" context={Preview context} - primaryAction={Create preview} + primaryAction={Create preview} >

Page body

, @@ -102,7 +102,7 @@ describe("HousekeepingPageShell", () => { expect(html).toContain(">People<"); expect(html).toContain(">Review operator-facing people data<"); expect(html).toContain(">Preview context<"); - expect(html).toContain('href="/admin-next/people/new"'); + expect(html).toContain('href="/ase-next/people/new"'); expect(html).toContain(">Create preview<"); expect(html).toContain(">Page body

"); }); diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts index ae9bd9b6..f3ad2a55 100644 --- a/src/features/housekeeping/foundation/preview-route-contract.test.ts +++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts @@ -78,16 +78,16 @@ vi.mock("@/app/actions", () => { throw new Error("preview routes must not import actions"); }); -import AdminNextDomainLayout from "@/app/admin-next/[domain]/layout"; -import AdminNextDomainPage from "@/app/admin-next/[domain]/page"; -import AdminNextLayout from "@/app/admin-next/layout"; -import AdminNextPage from "@/app/admin-next/page"; +import AdminNextDomainLayout from "@/app/ase-next/[domain]/layout"; +import AdminNextDomainPage from "@/app/ase-next/[domain]/page"; +import AdminNextLayout from "@/app/ase-next/layout"; +import AdminNextPage from "@/app/ase-next/page"; const routeFiles = [ - "src/app/admin-next/layout.tsx", - "src/app/admin-next/page.tsx", - "src/app/admin-next/[domain]/layout.tsx", - "src/app/admin-next/[domain]/page.tsx", + "src/app/ase-next/layout.tsx", + "src/app/ase-next/page.tsx", + "src/app/ase-next/[domain]/layout.tsx", + "src/app/ase-next/[domain]/page.tsx", ] as const; const forbiddenModuleRoots = [ @@ -360,7 +360,7 @@ async function renderRoute(route: ReactNode | Promise) { return renderToStaticMarkup(await route); } -describe("/admin-next preview gate", () => { +describe("/ase-next preview gate", () => { beforeEach(() => { vi.clearAllMocks(); routeMocks.env.NODE_ENV = "test"; @@ -402,7 +402,7 @@ describe("/admin-next preview gate", () => { ); }); -describe("/admin-next first visible domain", () => { +describe("/ase-next first visible domain", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -412,10 +412,8 @@ describe("/admin-next first visible domain", () => { capabilityContext([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW]), ); - await expect(AdminNextPage()).rejects.toThrow( - "NEXT_REDIRECT:/admin-next/operations", - ); - expect(routeMocks.redirect).toHaveBeenCalledWith("/admin-next/operations"); + await expect(AdminNextPage()).rejects.toThrow("NEXT_REDIRECT:/ase-next"); + expect(routeMocks.redirect).toHaveBeenCalledWith("/ase-next"); expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes( 1, ); @@ -428,9 +426,9 @@ describe("/admin-next first visible domain", () => { ); await expect(AdminNextPage()).rejects.toThrow( - "NEXT_REDIRECT:/admin-next/people", + "NEXT_REDIRECT:/ase-next/people", ); - expect(routeMocks.redirect).toHaveBeenCalledWith("/admin-next/people"); + expect(routeMocks.redirect).toHaveBeenCalledWith("/ase-next/people"); expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes( 1, ); @@ -450,7 +448,7 @@ describe("/admin-next first visible domain", () => { }); }); -describe("/admin-next/[domain] layout", () => { +describe("/ase-next/[domain] layout", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -515,7 +513,7 @@ describe("/admin-next/[domain] layout", () => { }); }); -describe("/admin-next/[domain] page", () => { +describe("/ase-next/[domain] page", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -561,247 +559,247 @@ describe("preview route import boundary", () => { it.each([ [ "relative database import with resolver extension", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import db from "../../lib/db.js";', "src/lib/db", ], [ "aliased database import with resolver extension", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import db from "@/lib/db.js";', "src/lib/db", ], [ "action root import with resolver extension", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import actions from "../../actions.mjs";', "src/actions", ], [ "legacy mod root import with resolver extension", - "src/app/admin-next/layout.tsx", + "src/app/ase-next/layout.tsx", 'import mod from "../mod.cjs";', "src/app/mod", ], [ "database import with query suffix", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import db from "../../lib/db?server-only";', "src/lib/db", ], [ "action import with hash suffix", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import("../../actions/users#server")', "src/actions/users", ], [ "Windows-style relative database import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", String.raw`import db from "..\\..\\lib\\db";`, "src/lib/db", ], [ "Windows-style aliased database import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", String.raw`import db from "@\\lib\\db";`, "src/lib/db", ], [ "percent-encoded database import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import db from "../../lib/%64%62";', "src/lib/db", ], [ "optional CommonJS database require", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'require?.("../../lib/db")', "src/lib/db", ], [ "module database require", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'module.require("../../lib/db")', "src/lib/db", ], [ "require.resolve database access", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'require.resolve("../../lib/db")', "src/lib/db", ], [ "optional module database require", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'module.require?.("../../lib/db")', "src/lib/db", ], [ "optional require.resolve database access", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'require.resolve?.("../../lib/db")', "src/lib/db", ], [ "TypeScript import-equals database access", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import db = require("../../lib/db");', "src/lib/db", ], [ "U+2028 line-continuation database import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import db from "../\\' + "\u2028" + '../lib/db";', "src/lib/db", ], [ "U+2029 line-continuation database import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import db from "../\\' + "\u2029" + '../lib/db";', "src/lib/db", ], [ "parenthesized dynamic action import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import(("../../actions/users"))', "src/actions/users", ], [ "regex-brace template-expression action import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", `const x = \`${interpolationOpen}/}/.test(value) ? import("../../actions/users") : null}\`;`, "src/actions/users", ], [ "CommonJS database require", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'require("../../lib/db")', "src/lib/db", ], [ "template-literal dynamic action import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", "import(`../../actions/users`)", "src/actions/users", ], [ "TypeScript-asserted dynamic action import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import(("../../actions/users" as string))', "src/actions/users", ], [ "template-expression dynamic action import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", `const x = \`${interpolationOpen}import("../../actions/users")}\`;`, "src/actions/users", ], [ "nested template-expression dynamic action import", - "src/app/admin-next/[domain]/page.tsx", + "src/app/ase-next/[domain]/page.tsx", `const x = \`${interpolationOpen}ready ? \`${interpolationOpen}import("../../../actions/nested")}\` : ""}\`;`, "src/actions/nested", ], [ "unicode escaped dynamic app-action import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import("\\u002e\\u002e/actions/users")', "src/app/actions/users", ], [ "code-point escaped dynamic app-action import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import("\\u{2e}\\u{2e}/actions/users")', "src/app/actions/users", ], [ "hex escaped export-from auth import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'export * from "\\x2e\\x2e/\\x2e\\x2e/lib/auth";', "src/lib/auth", ], [ "escaped-slash permissions import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import permissions from "..\\/..\\/lib\\/permissions";', "src/lib/permissions", ], [ "unknown escape database import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import db from "../../\\lib/db";', "src/lib/db", ], [ "line-continuation database import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import db from "../\\' + "\n" + '../lib/db";', "src/lib/db", ], [ "aliased database descendant import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import { query } from "@/lib/db/query";', "src/lib/db/query", ], [ "root relative database import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import { db } from "../../lib/db";', "src/lib/db", ], [ "domain relative auth side-effect import", - "src/app/admin-next/[domain]/layout.tsx", + "src/app/ase-next/[domain]/layout.tsx", 'import "../../../lib/auth";', "src/lib/auth", ], [ "domain relative permissions export", - "src/app/admin-next/[domain]/page.tsx", + "src/app/ase-next/[domain]/page.tsx", 'export { getAdminContext } from "../../../lib/permissions";', "src/lib/permissions", ], [ "root relative action dynamic import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import("../../actions/users")', "src/actions/users", ], [ "root relative app action export", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'export * from "../actions";', "src/app/actions", ], [ "domain relative legacy admin import", - "src/app/admin-next/[domain]/layout.tsx", + "src/app/ase-next/[domain]/layout.tsx", 'import page from "../../admin/users/page";', "src/app/admin/users/page", ], [ "root relative legacy mod dynamic import", - "src/app/admin-next/layout.tsx", + "src/app/ase-next/layout.tsx", 'import("../mod/users/page")', "src/app/mod/users/page", ], [ "Prisma TypeScript import type", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'type PrismaClient = import("@prisma/client").PrismaClient;', "@prisma/client", ], [ "Drizzle package import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import { sql } from "drizzle-orm";', "drizzle-orm", ], [ "mysql2 package import", - "src/app/admin-next/page.tsx", + "src/app/ase-next/page.tsx", 'import type { Pool } from "mysql2";', "mysql2", ], @@ -834,7 +832,7 @@ describe("preview route import boundary", () => { ], ] as const)("fails closed for non-literal %s", (_name, source, violation) => { expect( - findRouteImportBoundaryViolations(source, "src/app/admin-next/page.tsx"), + findRouteImportBoundaryViolations(source, "src/app/ase-next/page.tsx"), ).toContain(violation); }); @@ -843,7 +841,7 @@ describe("preview route import boundary", () => { 'import database from "@/lib/database.js?raw";', 'import dbTools from "../../lib/db-tools.ts";', 'import auth from "../../lib/authentication";', - 'import preview from "../admin-next-shared";', + 'import preview from "../ase-next-shared";', 'import prismaTools from "@prisma/client-tools";', 'import drizzleTools from "drizzle-orm-kit";', 'import mysqlTools from "mysql2-wrapper";', @@ -853,7 +851,7 @@ describe("preview route import boundary", () => { ].join("\n"); expect( - findRouteImportBoundaryViolations(source, "src/app/admin-next/page.tsx"), + findRouteImportBoundaryViolations(source, "src/app/ase-next/page.tsx"), ).toEqual([]); }); }); diff --git a/src/features/housekeeping/foundation/registry.test.ts b/src/features/housekeeping/foundation/registry.test.ts index 5bbc528d..5dd58fb1 100644 --- a/src/features/housekeeping/foundation/registry.test.ts +++ b/src/features/housekeeping/foundation/registry.test.ts @@ -25,7 +25,7 @@ const manifest = ( labelKey: `pages.housekeeping.domains.${id}.title`, descriptionKey: `pages.housekeeping.domains.${id}.description`, iconId: "settings", - previewHref: `/admin-next/${id}`, + canonicalHref: id === "operations" ? "/ase" : `/ase/${id}`, capability: anyCapability(capabilitySlug), routes: [], searchProviders: [], @@ -73,7 +73,7 @@ const expectedManifests = [ { id: "operations", iconId: "inbox", - previewHref: "/admin-next/operations", + canonicalHref: "/ase", labelKey: "pages.housekeeping.domains.operations.title", descriptionKey: "pages.housekeeping.domains.operations.description", slugs: [PERMS.ADMIN_DASHBOARD], @@ -81,7 +81,7 @@ const expectedManifests = [ { id: "people", iconId: "users", - previewHref: "/admin-next/people", + canonicalHref: "/ase/people", labelKey: "pages.housekeeping.domains.people.title", descriptionKey: "pages.housekeeping.domains.people.description", slugs: [ @@ -112,7 +112,7 @@ const expectedManifests = [ { id: "content", iconId: "file-text", - previewHref: "/admin-next/content", + canonicalHref: "/ase/content", labelKey: "pages.housekeeping.domains.content.title", descriptionKey: "pages.housekeeping.domains.content.description", slugs: [ @@ -135,7 +135,7 @@ const expectedManifests = [ { id: "economy", iconId: "gem", - previewHref: "/admin-next/economy", + canonicalHref: "/ase/economy", labelKey: "pages.housekeeping.domains.economy.title", descriptionKey: "pages.housekeeping.domains.economy.description", slugs: [ @@ -148,7 +148,7 @@ const expectedManifests = [ { id: "hotel", iconId: "hotel", - previewHref: "/admin-next/hotel", + canonicalHref: "/ase/hotel", labelKey: "pages.housekeeping.domains.hotel.title", descriptionKey: "pages.housekeeping.domains.hotel.description", slugs: [ @@ -165,7 +165,7 @@ const expectedManifests = [ { id: "system", iconId: "settings", - previewHref: "/admin-next/system", + canonicalHref: "/ase/system", labelKey: "pages.housekeeping.domains.system.title", descriptionKey: "pages.housekeeping.domains.system.description", slugs: [ @@ -208,9 +208,9 @@ describe("housekeeping registry", () => { it("rejects invalid preview and empty translation keys", () => { expect(() => createHousekeepingRegistry([ - { ...manifest("people"), previewHref: "/admin-next/operations" }, + { ...manifest("people"), canonicalHref: "/ase" }, ]), - ).toThrow("invalid preview href"); + ).toThrow("invalid canonical href"); expect(() => createHousekeepingRegistry([{ ...manifest("people"), labelKey: " " }]), ).toThrow("empty label key"); @@ -232,13 +232,13 @@ describe("housekeeping registry", () => { { id: "users", labelKey: "pages.housekeeping.domains.people.title", - href: "/admin-next/people/users", + href: "/ase/people/users", capability: anyCapability(PERMS.USERS_VIEW), }, { id: "users", labelKey: "pages.housekeeping.domains.people.title", - href: "/admin-next/people/staff", + href: "/ase/people/staff", capability: anyCapability(PERMS.USERS_VIEW), }, ], @@ -253,13 +253,13 @@ describe("housekeeping registry", () => { { id: "users", labelKey: "pages.housekeeping.domains.people.title", - href: "/admin-next/people/users", + href: "/ase/people/users", capability: anyCapability(PERMS.USERS_VIEW), }, { id: "staff", labelKey: "pages.housekeeping.domains.people.title", - href: "/admin-next/people/users", + href: "/ase/people/users", capability: anyCapability(PERMS.USERS_VIEW), }, ], @@ -272,7 +272,7 @@ describe("housekeeping registry", () => { const route = { id: "users", labelKey: "pages.housekeeping.domains.people.title", - href: "/admin-next/people/users", + href: "/ase/people/users", capability: anyCapability(PERMS.USERS_VIEW), }; @@ -319,7 +319,7 @@ describe("housekeeping registry", () => { expect(actual).toMatchObject({ id: expected.id, iconId: expected.iconId, - previewHref: expected.previewHref, + canonicalHref: expected.canonicalHref, labelKey: expected.labelKey, descriptionKey: expected.descriptionKey, }); @@ -481,7 +481,7 @@ describe("housekeeping registry", () => { { id: "users", labelKey: "pages.housekeeping.domains.people.title", - href: "/admin-next/people/users", + href: "/ase/people/users", capability: { mode: "any", slugs: [] }, }, ], @@ -494,7 +494,7 @@ describe("housekeeping registry", () => { const route = { id: "shared", labelKey: "pages.housekeeping.domains.people.title", - href: "/admin-next/shared", + href: "/ase/shared", capability: anyCapability(PERMS.USERS_VIEW), }; @@ -503,7 +503,7 @@ describe("housekeeping registry", () => { { ...manifest("people"), routes: [route] }, { ...manifest("content"), - routes: [{ ...route, href: "/admin-next/content/shared" }], + routes: [{ ...route, href: "/ase/content/shared" }], }, ]), ).toThrow("duplicate route id"); @@ -518,3 +518,22 @@ describe("housekeeping registry", () => { ).toThrow("duplicate route href"); }); }); + +it("accepts the canonical operations root and rejects legacy admin hrefs", () => { + const canonicalOperations = manifest("operations") as Record; + delete canonicalOperations.canonicalHref; + canonicalOperations.canonicalHref = "/ase"; + + expect(() => + createHousekeepingRegistry([ + canonicalOperations as unknown as HousekeepingDomainManifest, + ]), + ).not.toThrow(); + + canonicalOperations.canonicalHref = "/admin"; + expect(() => + createHousekeepingRegistry([ + canonicalOperations as unknown as HousekeepingDomainManifest, + ]), + ).toThrow("invalid canonical href"); +}); diff --git a/src/features/housekeeping/foundation/registry.ts b/src/features/housekeeping/foundation/registry.ts index f301ebbe..bc63f55b 100644 --- a/src/features/housekeeping/foundation/registry.ts +++ b/src/features/housekeeping/foundation/registry.ts @@ -34,8 +34,11 @@ export function createHousekeepingRegistry( } domainIds.add(manifest.id); - if (manifest.previewHref !== `/admin-next/${manifest.id}`) { - throw new Error(`invalid preview href: ${manifest.previewHref}`); + if ( + manifest.canonicalHref !== + (manifest.id === "operations" ? "/ase" : `/ase/${manifest.id}`) + ) { + throw new Error(`invalid canonical href: ${manifest.canonicalHref}`); } validateNonEmpty(manifest.labelKey, "label key"); validateNonEmpty(manifest.descriptionKey, "description key"); @@ -72,6 +75,9 @@ export function createHousekeepingRegistry( routeIds.add(route.id); validateNonEmpty(route.href, "route href"); + if (route.href !== "/ase" && !route.href.startsWith("/ase/")) { + throw new Error(`invalid canonical route href: ${route.href}`); + } if (routeHrefs.has(route.href)) { throw new Error(`duplicate route href: ${route.href}`); } diff --git a/src/features/housekeeping/foundation/routing/href.test.ts b/src/features/housekeeping/foundation/routing/href.test.ts new file mode 100644 index 00000000..5f70f312 --- /dev/null +++ b/src/features/housekeeping/foundation/routing/href.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import type { CanonicalHousekeepingHref } from "../contracts"; +import { toHousekeepingHref } from "./href"; + +describe("toHousekeepingHref", () => { + it("keeps the canonical operations root unchanged", () => { + expect(toHousekeepingHref("/ase", "canonical")).toBe("/ase"); + }); + + it("maps a canonical domain route to the preview namespace", () => { + expect(toHousekeepingHref("/ase/people/users", "preview")).toBe( + "/ase-next/people/users", + ); + }); + + it("rejects paths outside the canonical ase namespace", () => { + expect(() => + toHousekeepingHref("/admin" as CanonicalHousekeepingHref, "canonical"), + ).toThrow("invalid canonical housekeeping href"); + }); +}); diff --git a/src/features/housekeeping/foundation/routing/href.ts b/src/features/housekeeping/foundation/routing/href.ts new file mode 100644 index 00000000..dfc9218a --- /dev/null +++ b/src/features/housekeeping/foundation/routing/href.ts @@ -0,0 +1,15 @@ +import type { + CanonicalHousekeepingHref, + HousekeepingSurface, +} from "../contracts"; + +export function toHousekeepingHref( + href: CanonicalHousekeepingHref, + surface: HousekeepingSurface, +): CanonicalHousekeepingHref | `/ase-next${string}` { + if (href !== "/ase" && !href.startsWith("/ase/")) { + throw new Error(`invalid canonical housekeeping href: ${href}`); + } + + return surface === "canonical" ? href : href.replace(/^\/ase/, "/ase-next"); +} diff --git a/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx b/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx index 0aac7178..f462514a 100644 --- a/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx +++ b/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx @@ -17,17 +17,17 @@ const labels = { const domains: readonly HousekeepingNavigationDomain[] = [ { id: "operations", - href: "/admin-next/operations", + href: "/ase-next/operations", iconId: "inbox", label: "Operations", description: "Manage operations", items: [ - { id: "queue", href: "/admin-next/operations/queue", label: "Queue" }, + { id: "queue", href: "/ase-next/operations/queue", label: "Queue" }, ], }, { id: "people", - href: "/admin-next/people", + href: "/ase-next/people", iconId: "users", label: "People", description: "Manage people", @@ -35,7 +35,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [ }, { id: "content", - href: "/admin-next/content", + href: "/ase-next/content", iconId: "file-text", label: "Content", description: "Manage content", @@ -43,7 +43,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [ }, { id: "economy", - href: "/admin-next/economy", + href: "/ase-next/economy", iconId: "gem", label: "Economy", description: "Manage economy", @@ -51,7 +51,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [ }, { id: "hotel", - href: "/admin-next/hotel", + href: "/ase-next/hotel", iconId: "hotel", label: "Hotel", description: "Manage hotel", @@ -59,7 +59,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [ }, { id: "system", - href: "/admin-next/system", + href: "/ase-next/system", iconId: "settings", label: "System", description: "Manage system", @@ -178,7 +178,7 @@ describe("HousekeepingShell", () => { expect(html).toContain('