refactor(housekeeping): adopt ase route namespace

This commit is contained in:
Simo committed 2026-08-26 20:44:38 +02:00
1 parent 74756dfed2
commit f72a2b6c74
32 files changed
+388 -264

No files matched your search

@@ -35,8 +35,11 @@ export default async function AdminNextDomainLayout({
if (!satisfiesCapability(context, activeDomain.capability)) notFound(); if (!satisfiesCapability(context, activeDomain.capability)) notFound();
const translate = await getTranslations("pages.housekeeping"); const translate = await getTranslations("pages.housekeeping");
const navigation = buildHousekeepingNavigation(registry, context, (key) => const navigation = buildHousekeepingNavigation(
translate(namespaceKey(key) as never), registry,
context,
"preview",
(key) => translate(namespaceKey(key) as never),
); );
return ( return (
File renamed without changes.
File renamed without changes.
@@ -1,6 +1,7 @@
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context"; import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry"; 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 { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests"; import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
@@ -13,5 +14,5 @@ export default async function AdminNextPage() {
if (!firstVisibleDomain) notFound(); if (!firstVisibleDomain) notFound();
redirect(firstVisibleDomain.previewHref); redirect(toHousekeepingHref(firstVisibleDomain.canonicalHref, "preview"));
} }
@@ -9,7 +9,7 @@ export const contentManifest = {
labelKey: "pages.housekeeping.domains.content.title", labelKey: "pages.housekeeping.domains.content.title",
descriptionKey: "pages.housekeeping.domains.content.description", descriptionKey: "pages.housekeeping.domains.content.description",
iconId: "file-text", iconId: "file-text",
previewHref: "/admin-next/content", canonicalHref: "/ase/content",
capability: anyCapability( capability: anyCapability(
PERMS.NEWS_VIEW, PERMS.NEWS_VIEW,
PERMS.PAGES_VIEW, PERMS.PAGES_VIEW,
@@ -9,7 +9,7 @@ export const economyManifest = {
labelKey: "pages.housekeeping.domains.economy.title", labelKey: "pages.housekeeping.domains.economy.title",
descriptionKey: "pages.housekeeping.domains.economy.description", descriptionKey: "pages.housekeeping.domains.economy.description",
iconId: "gem", iconId: "gem",
previewHref: "/admin-next/economy", canonicalHref: "/ase/economy",
capability: anyCapability( capability: anyCapability(
PERMS.CATALOG_VIEW, PERMS.CATALOG_VIEW,
PERMS.SHOP_VIEW, PERMS.SHOP_VIEW,
@@ -9,7 +9,7 @@ export const hotelManifest = {
labelKey: "pages.housekeeping.domains.hotel.title", labelKey: "pages.housekeeping.domains.hotel.title",
descriptionKey: "pages.housekeeping.domains.hotel.description", descriptionKey: "pages.housekeeping.domains.hotel.description",
iconId: "hotel", iconId: "hotel",
previewHref: "/admin-next/hotel", canonicalHref: "/ase/hotel",
capability: anyCapability( capability: anyCapability(
PERMS.ROOMS_VIEW, PERMS.ROOMS_VIEW,
PERMS.RADIO_VIEW, PERMS.RADIO_VIEW,
@@ -9,7 +9,7 @@ export const operationsManifest = {
labelKey: "pages.housekeeping.domains.operations.title", labelKey: "pages.housekeeping.domains.operations.title",
descriptionKey: "pages.housekeeping.domains.operations.description", descriptionKey: "pages.housekeeping.domains.operations.description",
iconId: "inbox", iconId: "inbox",
previewHref: "/admin-next/operations", canonicalHref: "/ase",
capability: anyCapability(PERMS.ADMIN_DASHBOARD), capability: anyCapability(PERMS.ADMIN_DASHBOARD),
routes: [], routes: [],
searchProviders: [], searchProviders: [],
@@ -9,7 +9,7 @@ export const peopleManifest = {
labelKey: "pages.housekeeping.domains.people.title", labelKey: "pages.housekeeping.domains.people.title",
descriptionKey: "pages.housekeeping.domains.people.description", descriptionKey: "pages.housekeeping.domains.people.description",
iconId: "users", iconId: "users",
previewHref: "/admin-next/people", canonicalHref: "/ase/people",
capability: anyCapability( capability: anyCapability(
PERMS.USERS_VIEW, PERMS.USERS_VIEW,
PERMS.MODERATION_VIEW, PERMS.MODERATION_VIEW,
@@ -9,7 +9,7 @@ export const systemManifest = {
labelKey: "pages.housekeeping.domains.system.title", labelKey: "pages.housekeeping.domains.system.title",
descriptionKey: "pages.housekeeping.domains.system.description", descriptionKey: "pages.housekeeping.domains.system.description",
iconId: "settings", iconId: "settings",
previewHref: "/admin-next/system", canonicalHref: "/ase/system",
capability: anyCapability( capability: anyCapability(
PERMS.SETTINGS_VIEW, PERMS.SETTINGS_VIEW,
PERMS.LOGS_VIEW, PERMS.LOGS_VIEW,
@@ -28,7 +28,7 @@ const workItem = {
occurredAt: "2026-08-24T12:00:00.000Z", occurredAt: "2026-08-24T12:00:00.000Z",
titleKey: "pages.housekeeping.items.ticket", titleKey: "pages.housekeeping.items.ticket",
context: { ticketId: "42" }, context: { ticketId: "42" },
href: "/admin-next/people/tickets/42", href: "/ase-next/people/tickets/42",
freshness: "fresh", freshness: "fresh",
} satisfies HousekeepingWorkItem; } satisfies HousekeepingWorkItem;
@@ -36,7 +36,7 @@ const searchResult = {
id: "user-42", id: "user-42",
domain: "people", domain: "people",
title: "operator", title: "operator",
href: "/admin-next/people/users/42", href: "/ase-next/people/users/42",
} satisfies HousekeepingSearchResult; } satisfies HousekeepingSearchResult;
const searchProvider: HousekeepingSearchProvider = { const searchProvider: HousekeepingSearchProvider = {
@@ -87,7 +87,7 @@ const manifest: HousekeepingDomainManifest = {
labelKey: "pages.housekeeping.domains.people.title", labelKey: "pages.housekeeping.domains.people.title",
descriptionKey: "pages.housekeeping.domains.people.description", descriptionKey: "pages.housekeeping.domains.people.description",
iconId: "users", iconId: "users",
previewHref: "/admin-next/people", canonicalHref: "/ase-next/people",
capability, capability,
routes: [], routes: [],
searchProviders: [searchProvider], searchProviders: [searchProvider],
@@ -4,10 +4,12 @@ import type { HousekeepingInboxSource } from "./inbox";
import type { HousekeepingSearchProvider } from "./search"; import type { HousekeepingSearchProvider } from "./search";
import type { HousekeepingWidgetDefinition } from "./widget"; import type { HousekeepingWidgetDefinition } from "./widget";
export type HousekeepingSurface = "preview" | "canonical";
export type CanonicalHousekeepingHref = `/ase${string}`;
export interface HousekeepingRouteDefinition { export interface HousekeepingRouteDefinition {
id: string; id: string;
labelKey: string; labelKey: string;
href: string; href: CanonicalHousekeepingHref;
capability: CapabilityRequirement; capability: CapabilityRequirement;
matchPrefixes?: readonly string[]; matchPrefixes?: readonly string[];
} }
@@ -17,7 +19,7 @@ export interface HousekeepingDomainManifest {
labelKey: string; labelKey: string;
descriptionKey: string; descriptionKey: string;
iconId: "inbox" | "users" | "file-text" | "gem" | "hotel" | "settings"; iconId: "inbox" | "users" | "file-text" | "gem" | "hotel" | "settings";
previewHref: `/admin-next/${HousekeepingDomainId}`; canonicalHref: CanonicalHousekeepingHref;
capability: CapabilityRequirement; capability: CapabilityRequirement;
routes: readonly HousekeepingRouteDefinition[]; routes: readonly HousekeepingRouteDefinition[];
searchProviders: readonly HousekeepingSearchProvider[]; searchProviders: readonly HousekeepingSearchProvider[];
@@ -7,8 +7,10 @@ export {
} from "./capability"; } from "./capability";
export type { HousekeepingCommand } from "./command"; export type { HousekeepingCommand } from "./command";
export type { export type {
CanonicalHousekeepingHref,
HousekeepingDomainManifest, HousekeepingDomainManifest,
HousekeepingRouteDefinition, HousekeepingRouteDefinition,
HousekeepingSurface,
} from "./domain"; } from "./domain";
export type { export type {
HousekeepingInboxSource, HousekeepingInboxSource,
@@ -555,7 +555,7 @@ describe("housekeeping foundation completion contracts", () => {
for (const path of [ for (const path of [
"src/app/admin/layout.tsx", "src/app/admin/layout.tsx",
"src/app/mod/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); expect(existsSync(path), path).toBe(true);
} }
File diff suppressed because it is too large. Load diff
@@ -3,8 +3,10 @@ import { satisfiesCapability } from "./capability-context";
import type { import type {
HousekeepingCapabilityContext, HousekeepingCapabilityContext,
HousekeepingDomainManifest, HousekeepingDomainManifest,
HousekeepingSurface,
} from "./contracts"; } from "./contracts";
import type { HousekeepingRegistry } from "./registry"; import type { HousekeepingRegistry } from "./registry";
import { toHousekeepingHref } from "./routing/href";
export interface HousekeepingNavigationDomain { export interface HousekeepingNavigationDomain {
id: HousekeepingDomainId; id: HousekeepingDomainId;
@@ -18,13 +20,14 @@ export interface HousekeepingNavigationDomain {
export function buildHousekeepingNavigation( export function buildHousekeepingNavigation(
registry: HousekeepingRegistry, registry: HousekeepingRegistry,
context: HousekeepingCapabilityContext, context: HousekeepingCapabilityContext,
surface: HousekeepingSurface,
translate: (key: string) => string, translate: (key: string) => string,
): readonly HousekeepingNavigationDomain[] { ): readonly HousekeepingNavigationDomain[] {
return registry.domains return registry.domains
.filter((domain) => satisfiesCapability(context, domain.capability)) .filter((domain) => satisfiesCapability(context, domain.capability))
.map((domain) => ({ .map((domain) => ({
id: domain.id, id: domain.id,
href: domain.previewHref, href: toHousekeepingHref(domain.canonicalHref, surface),
iconId: domain.iconId, iconId: domain.iconId,
label: translate(domain.labelKey), label: translate(domain.labelKey),
description: translate(domain.descriptionKey), description: translate(domain.descriptionKey),
@@ -32,7 +35,7 @@ export function buildHousekeepingNavigation(
.filter((route) => satisfiesCapability(context, route.capability)) .filter((route) => satisfiesCapability(context, route.capability))
.map((route) => ({ .map((route) => ({
id: route.id, id: route.id,
href: route.href, href: toHousekeepingHref(route.href, surface),
label: translate(route.labelKey), label: translate(route.labelKey),
})), })),
})); }));
@@ -75,12 +75,12 @@ describe("HousekeepingPageState", () => {
state="error" state="error"
title="Could not load" title="Could not load"
description="Try again later" description="Try again later"
retryAction={<a href="/admin-next/operations">Retry preview</a>} retryAction={<a href="/ase-next/operations">Retry preview</a>}
/>, />,
); );
expect(html).toContain('role="alert"'); expect(html).toContain('role="alert"');
expect(html).toContain('href="/admin-next/operations"'); expect(html).toContain('href="/ase-next/operations"');
expect(html).toContain(">Retry preview<"); expect(html).toContain(">Retry preview<");
}); });
}); });
@@ -92,7 +92,7 @@ describe("HousekeepingPageShell", () => {
title="People" title="People"
description="Review operator-facing people data" description="Review operator-facing people data"
context={<span>Preview context</span>} context={<span>Preview context</span>}
primaryAction={<a href="/admin-next/people/new">Create preview</a>} primaryAction={<a href="/ase-next/people/new">Create preview</a>}
> >
<p>Page body</p> <p>Page body</p>
</HousekeepingPageShell>, </HousekeepingPageShell>,
@@ -102,7 +102,7 @@ describe("HousekeepingPageShell", () => {
expect(html).toContain(">People<"); expect(html).toContain(">People<");
expect(html).toContain(">Review operator-facing people data<"); expect(html).toContain(">Review operator-facing people data<");
expect(html).toContain(">Preview context<"); 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(">Create preview<");
expect(html).toContain(">Page body</p>"); expect(html).toContain(">Page body</p>");
}); });
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -34,8 +34,11 @@ export function createHousekeepingRegistry(
} }
domainIds.add(manifest.id); domainIds.add(manifest.id);
if (manifest.previewHref !== `/admin-next/${manifest.id}`) { if (
throw new Error(`invalid preview href: ${manifest.previewHref}`); manifest.canonicalHref !==
(manifest.id === "operations" ? "/ase" : `/ase/${manifest.id}`)
) {
throw new Error(`invalid canonical href: ${manifest.canonicalHref}`);
} }
validateNonEmpty(manifest.labelKey, "label key"); validateNonEmpty(manifest.labelKey, "label key");
validateNonEmpty(manifest.descriptionKey, "description key"); validateNonEmpty(manifest.descriptionKey, "description key");
@@ -72,6 +75,9 @@ export function createHousekeepingRegistry(
routeIds.add(route.id); routeIds.add(route.id);
validateNonEmpty(route.href, "route href"); 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)) { if (routeHrefs.has(route.href)) {
throw new Error(`duplicate route href: ${route.href}`); throw new Error(`duplicate route href: ${route.href}`);
} }
@@ -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");
});
});
@@ -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");
}
@@ -17,17 +17,17 @@ const labels = {
const domains: readonly HousekeepingNavigationDomain[] = [ const domains: readonly HousekeepingNavigationDomain[] = [
{ {
id: "operations", id: "operations",
href: "/admin-next/operations", href: "/ase-next/operations",
iconId: "inbox", iconId: "inbox",
label: "Operations", label: "Operations",
description: "Manage operations", description: "Manage operations",
items: [ items: [
{ id: "queue", href: "/admin-next/operations/queue", label: "Queue" }, { id: "queue", href: "/ase-next/operations/queue", label: "Queue" },
], ],
}, },
{ {
id: "people", id: "people",
href: "/admin-next/people", href: "/ase-next/people",
iconId: "users", iconId: "users",
label: "People", label: "People",
description: "Manage people", description: "Manage people",
@@ -35,7 +35,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [
}, },
{ {
id: "content", id: "content",
href: "/admin-next/content", href: "/ase-next/content",
iconId: "file-text", iconId: "file-text",
label: "Content", label: "Content",
description: "Manage content", description: "Manage content",
@@ -43,7 +43,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [
}, },
{ {
id: "economy", id: "economy",
href: "/admin-next/economy", href: "/ase-next/economy",
iconId: "gem", iconId: "gem",
label: "Economy", label: "Economy",
description: "Manage economy", description: "Manage economy",
@@ -51,7 +51,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [
}, },
{ {
id: "hotel", id: "hotel",
href: "/admin-next/hotel", href: "/ase-next/hotel",
iconId: "hotel", iconId: "hotel",
label: "Hotel", label: "Hotel",
description: "Manage hotel", description: "Manage hotel",
@@ -59,7 +59,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [
}, },
{ {
id: "system", id: "system",
href: "/admin-next/system", href: "/ase-next/system",
iconId: "settings", iconId: "settings",
label: "System", label: "System",
description: "Manage system", description: "Manage system",
@@ -178,7 +178,7 @@ describe("HousekeepingShell", () => {
expect(html).toContain('<nav aria-label="Operations navigation"'); expect(html).toContain('<nav aria-label="Operations navigation"');
expect(html).toContain('<main id="housekeeping-content"'); expect(html).toContain('<main id="housekeeping-content"');
expect(html).toContain(">Deck body</p>"); expect(html).toContain(">Deck body</p>");
expect(html).toContain('href="/admin-next/operations/queue"'); expect(html).toContain('href="/ase-next/operations/queue"');
expect(html).toContain(">Queue<"); expect(html).toContain(">Queue<");
expect(html).toContain(">Nora<"); expect(html).toContain(">Nora<");
expect(html).toContain(">6<"); expect(html).toContain(">6<");
@@ -196,7 +196,7 @@ describe("HousekeepingShell", () => {
const activeAnchors = allAnchors.filter((anchor) => const activeAnchors = allAnchors.filter((anchor) =>
anchor.includes('aria-current="page"'), anchor.includes('aria-current="page"'),
); );
const peopleAnchor = anchorForHref(html, "/admin-next/people"); const peopleAnchor = anchorForHref(html, "/ase-next/people");
for (const iconClass of [ for (const iconClass of [
"lucide-inbox", "lucide-inbox",
"lucide-users", "lucide-users",
@@ -222,7 +222,7 @@ describe("HousekeepingShell", () => {
); );
expect(() => renderShell("system", domainsWithoutSystem)).not.toThrow(); expect(() => renderShell("system", domainsWithoutSystem)).not.toThrow();
const html = renderShell("system", domainsWithoutSystem); const html = renderShell("system", domainsWithoutSystem);
expect(html).not.toContain('href="/admin-next/operations/queue"'); expect(html).not.toContain('href="/ase-next/operations/queue"');
expect(html).not.toContain(">Queue<"); expect(html).not.toContain(">Queue<");
}); });
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -7,7 +7,7 @@ export const operationsMigrationEntries: readonly MigrationEntry[] = [
legacyPath: "/admin", legacyPath: "/admin",
sourceFile: "src/app/admin/page.tsx", sourceFile: "src/app/admin/page.tsx",
targetDomain: "operations", targetDomain: "operations",
targetPath: "/admin/work", targetPath: "/ase",
decision: "REBUILD", decision: "REBUILD",
capabilities: { read: [PERMS.ADMIN_DASHBOARD], mutate: [] }, capabilities: { read: [PERMS.ADMIN_DASHBOARD], mutate: [] },
dependencies: { dependencies: {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -11,7 +11,7 @@ const page = (legacyPath: string): LegacyPage => ({
const entry = (legacyPath: string): MigrationEntry => ({ const entry = (legacyPath: string): MigrationEntry => ({
...page(legacyPath), ...page(legacyPath),
targetDomain: "system", targetDomain: "system",
targetPath: "/admin/system/example", targetPath: "/ase/system/example",
decision: "REHOST", decision: "REHOST",
capabilities: { read: ["admin.dashboard"], mutate: [] }, capabilities: { read: ["admin.dashboard"], mutate: [] },
dependencies: { queries: [], mutations: [] }, dependencies: { queries: [], mutations: [] },
@@ -112,7 +112,7 @@ describe("validateMigrationEntries", () => {
"MERGE requires requiredTests: /admin", "MERGE requires requiredTests: /admin",
"mutation dependencies require auditRequirement: /admin", "mutation dependencies require auditRequirement: /admin",
"mutation dependencies require mutate capabilities: /admin", "mutation dependencies require mutate capabilities: /admin",
"non-REMOVE targetPath must start with /admin/: /admin", "non-REMOVE targetPath must use /ase: /admin",
"prohibited placeholder: /admin", "prohibited placeholder: /admin",
"VERIFIED requires parityEvidence: /admin", "VERIFIED requires parityEvidence: /admin",
]); ]);
@@ -134,3 +134,19 @@ describe("validateMigrationEntries", () => {
).toEqual(["REHOST requires targetPath: /admin"]); ).toEqual(["REHOST requires targetPath: /admin"]);
}); });
}); });
it("requires non-removal targets to use the ase namespace", () => {
expect(
validateMigrationEntries(
[page("/admin")],
[{ ...entry("/admin"), targetPath: "/ase/system/example" }],
),
).toEqual([]);
expect(
validateMigrationEntries(
[page("/admin")],
[{ ...entry("/admin"), targetPath: "/admin/system/example" }],
),
).toContain("non-REMOVE targetPath must use /ase: /admin");
});
@@ -84,11 +84,9 @@ export function validateMigrationEntries(
if ( if (
entry.targetPath !== null && entry.targetPath !== null &&
entry.decision !== "REMOVE" && entry.decision !== "REMOVE" &&
!entry.targetPath.startsWith("/admin/") !(entry.targetPath === "/ase" || entry.targetPath.startsWith("/ase/"))
) { ) {
issues.add( issues.add(`non-REMOVE targetPath must use /ase: ${legacyPath}`);
`non-REMOVE targetPath must start with /admin/: ${legacyPath}`,
);
} }
if ( if (
+1 -1
View File
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
const ROOTS = [ const ROOTS = [
"src/app/admin", "src/app/admin",
"src/components/admin", "src/components/admin",
"src/app/admin-next", "src/app/ase-next",
"src/features/housekeeping", "src/features/housekeeping",
]; ];
const GRAPHICAL_ALLOWLIST = [ const GRAPHICAL_ALLOWLIST = [