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('