feat(housekeeping): dispatch canonical domain routes
This commit is contained in:
1 parent
2970dff563
commit
e5c230ba35
13 files changed
+680
-82
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
|
||||||
|
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
||||||
|
import { matchHousekeepingRoute } from "@/features/housekeeping/foundation/routing/match-route";
|
||||||
|
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||||
|
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
|
||||||
|
import { HOUSEKEEPING_ROUTE_HANDLERS } from "@/features/housekeeping/route-handlers";
|
||||||
|
|
||||||
|
export default async function HousekeepingPreviewRoutePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ domain: string; segments?: readonly string[] }>;
|
||||||
|
}) {
|
||||||
|
const { domain, segments = [] } = await params;
|
||||||
|
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||||
|
const activeDomain = registry.domains.find((entry) => entry.id === domain);
|
||||||
|
if (!activeDomain) notFound();
|
||||||
|
|
||||||
|
const suffix = segments
|
||||||
|
.map((segment) => encodeURIComponent(segment))
|
||||||
|
.join("/");
|
||||||
|
const canonicalPath = suffix
|
||||||
|
? `${activeDomain.canonicalHref}/${suffix}`
|
||||||
|
: activeDomain.canonicalHref;
|
||||||
|
const match = matchHousekeepingRoute(registry, canonicalPath);
|
||||||
|
if (!match || match.domain !== activeDomain.id) notFound();
|
||||||
|
|
||||||
|
const route = activeDomain.routes.find((entry) => entry.id === match.routeId);
|
||||||
|
const handler = HOUSEKEEPING_ROUTE_HANDLERS.find(
|
||||||
|
(entry) => entry.routeId === match.routeId,
|
||||||
|
);
|
||||||
|
if (!route || !handler) notFound();
|
||||||
|
|
||||||
|
const context = await getHousekeepingCapabilityContext();
|
||||||
|
if (
|
||||||
|
!satisfiesCapability(context, activeDomain.capability) ||
|
||||||
|
!satisfiesCapability(context, route.capability)
|
||||||
|
) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler.render({ context, match });
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ function namespaceKey(key: string): string {
|
|||||||
return key.slice(MESSAGE_PREFIX.length);
|
return key.slice(MESSAGE_PREFIX.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminNextDomainLayout({
|
export default async function HousekeepingPreviewDomainLayout({
|
||||||
children,
|
children,
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import { notFound } from "next/navigation";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { HousekeepingPageShell } from "@/features/housekeeping/foundation/page/housekeeping-page-shell";
|
|
||||||
import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
|
|
||||||
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
|
||||||
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
|
|
||||||
|
|
||||||
const MESSAGE_PREFIX = "pages.housekeeping.";
|
|
||||||
|
|
||||||
function namespaceKey(key: string): string {
|
|
||||||
if (!key.startsWith(MESSAGE_PREFIX)) {
|
|
||||||
throw new Error(`invalid housekeeping message key: ${key}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return key.slice(MESSAGE_PREFIX.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AdminNextDomainPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ domain: string }>;
|
|
||||||
}) {
|
|
||||||
const { domain } = await params;
|
|
||||||
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
|
||||||
const activeDomain = registry.domains.find((entry) => entry.id === domain);
|
|
||||||
|
|
||||||
if (!activeDomain) notFound();
|
|
||||||
|
|
||||||
const translate = await getTranslations("pages.housekeeping");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HousekeepingPageShell
|
|
||||||
title={translate(namespaceKey(activeDomain.labelKey) as never)}
|
|
||||||
description={translate(
|
|
||||||
namespaceKey(activeDomain.descriptionKey) as never,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<HousekeepingPageState
|
|
||||||
state="empty"
|
|
||||||
title={translate("states.empty.title")}
|
|
||||||
description={translate("states.empty.description")}
|
|
||||||
/>
|
|
||||||
</HousekeepingPageShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+14
-10
@@ -5,19 +5,23 @@ import { toHousekeepingHref } from "@/features/housekeeping/foundation/routing/h
|
|||||||
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";
|
||||||
|
|
||||||
export default async function AdminNextPage() {
|
export default async function HousekeepingPreviewRootPage() {
|
||||||
const context = await getHousekeepingCapabilityContext();
|
const context = await getHousekeepingCapabilityContext();
|
||||||
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||||
const firstVisibleDomain = registry.domains.find((domain) =>
|
|
||||||
satisfiesCapability(context, domain.capability),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!firstVisibleDomain) notFound();
|
for (const domain of registry.domains) {
|
||||||
|
if (!satisfiesCapability(context, domain.capability)) continue;
|
||||||
|
|
||||||
const previewHref = toHousekeepingHref(
|
const route = domain.routes.find((entry) =>
|
||||||
firstVisibleDomain.canonicalHref,
|
satisfiesCapability(context, entry.capability),
|
||||||
"preview",
|
);
|
||||||
);
|
if (!route) continue;
|
||||||
|
|
||||||
redirect(previewHref === "/ase-next" ? "/ase-next/operations" : previewHref);
|
const previewHref = toHousekeepingHref(route.href, "preview");
|
||||||
|
redirect(
|
||||||
|
previewHref === "/ase-next" ? "/ase-next/operations" : previewHref,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
notFound();
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -269,6 +269,32 @@ describe("housekeeping registry", () => {
|
|||||||
).toThrow("duplicate route href");
|
).toThrow("duplicate route href");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate dynamic route shapes regardless of parameter name", () => {
|
||||||
|
const base = manifest("people");
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
createHousekeepingRegistry([
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
id: "people.user-detail",
|
||||||
|
labelKey: "pages.housekeeping.routes.people.user-detail",
|
||||||
|
href: "/ase/people/users/:id",
|
||||||
|
capability: anyCapability(PERMS.USERS_VIEW),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "people.user-profile",
|
||||||
|
labelKey: "pages.housekeeping.routes.people.user-profile",
|
||||||
|
href: "/ase/people/users/:username",
|
||||||
|
capability: anyCapability(PERMS.USERS_VIEW),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toThrow("duplicate dynamic route shape");
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects empty route fields and unknown capability slugs", () => {
|
it("rejects empty route fields and unknown capability slugs", () => {
|
||||||
const route = {
|
const route = {
|
||||||
id: "users",
|
id: "users",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function createHousekeepingRegistry(
|
|||||||
const domainIds = new Set<string>();
|
const domainIds = new Set<string>();
|
||||||
const routeIds = new Set<string>();
|
const routeIds = new Set<string>();
|
||||||
const routeHrefs = new Set<string>();
|
const routeHrefs = new Set<string>();
|
||||||
|
const routeShapes = new Set<string>();
|
||||||
const registryEntryIds = new Set<string>();
|
const registryEntryIds = new Set<string>();
|
||||||
|
|
||||||
for (const manifest of manifests) {
|
for (const manifest of manifests) {
|
||||||
@@ -83,6 +84,12 @@ export function createHousekeepingRegistry(
|
|||||||
}
|
}
|
||||||
routeHrefs.add(route.href);
|
routeHrefs.add(route.href);
|
||||||
|
|
||||||
|
const routeShape = dynamicRouteShape(route.href);
|
||||||
|
if (routeShape && routeShapes.has(routeShape)) {
|
||||||
|
throw new Error(`duplicate dynamic route shape: ${routeShape}`);
|
||||||
|
}
|
||||||
|
if (routeShape) routeShapes.add(routeShape);
|
||||||
|
|
||||||
validateNonEmpty(route.labelKey, "route label key");
|
validateNonEmpty(route.labelKey, "route label key");
|
||||||
validateCapability(route.capability);
|
validateCapability(route.capability);
|
||||||
}
|
}
|
||||||
@@ -91,6 +98,20 @@ export function createHousekeepingRegistry(
|
|||||||
return { domains: Object.freeze([...manifests]) };
|
return { domains: Object.freeze([...manifests]) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dynamicRouteShape(href: string): string | null {
|
||||||
|
let hasDynamicSegment = false;
|
||||||
|
const shape = href
|
||||||
|
.split("/")
|
||||||
|
.map((segment) => {
|
||||||
|
if (!segment.startsWith(":")) return segment;
|
||||||
|
hasDynamicSegment = true;
|
||||||
|
return ":";
|
||||||
|
})
|
||||||
|
.join("/");
|
||||||
|
|
||||||
|
return hasDynamicSegment ? shape : null;
|
||||||
|
}
|
||||||
|
|
||||||
function validateOwnedRegistryEntries(
|
function validateOwnedRegistryEntries(
|
||||||
entries: readonly OwnedRegistryEntry[],
|
entries: readonly OwnedRegistryEntry[],
|
||||||
owner: HousekeepingDomainManifest["id"],
|
owner: HousekeepingDomainManifest["id"],
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -79,9 +79,7 @@ async function invokeRequestConsumers() {
|
|||||||
children: null,
|
children: null,
|
||||||
params: Promise.resolve({ domain: "operations" }),
|
params: Promise.resolve({ domain: "operations" }),
|
||||||
});
|
});
|
||||||
await expect(AdminNextPage()).rejects.toThrow(
|
await expect(AdminNextPage()).rejects.toThrow("NEXT_NOT_FOUND");
|
||||||
"NEXT_REDIRECT:/ase-next/operations",
|
|
||||||
);
|
|
||||||
const commandContext = await requireHousekeepingCapability(
|
const commandContext = await requireHousekeepingCapability(
|
||||||
anyCapability("admin.dashboard"),
|
anyCapability("admin.dashboard"),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createHousekeepingRegistry } from "./foundation/registry";
|
||||||
|
import { HOUSEKEEPING_MANIFESTS } from "./manifests";
|
||||||
|
import { HOUSEKEEPING_ROUTE_HANDLERS } from "./route-handlers";
|
||||||
|
|
||||||
|
describe("housekeeping route handlers", () => {
|
||||||
|
it("has exactly one handler for every registered route and no placeholders", () => {
|
||||||
|
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||||
|
const routeIds = registry.domains.flatMap((domain) =>
|
||||||
|
domain.routes.map((route) => route.id),
|
||||||
|
);
|
||||||
|
const handlerIds = HOUSEKEEPING_ROUTE_HANDLERS.map(
|
||||||
|
(handler) => handler.routeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(new Set(handlerIds).size).toBe(handlerIds.length);
|
||||||
|
expect([...handlerIds].sort()).toEqual([...routeIds].sort());
|
||||||
|
expect(handlerIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { HousekeepingCapabilityContext } from "./foundation/contracts";
|
||||||
|
import type { HousekeepingRouteMatch } from "./foundation/routing/match-route";
|
||||||
|
|
||||||
|
export interface HousekeepingPageInput {
|
||||||
|
readonly context: HousekeepingCapabilityContext;
|
||||||
|
readonly match: HousekeepingRouteMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HousekeepingRouteHandler {
|
||||||
|
routeId: string;
|
||||||
|
render(input: HousekeepingPageInput): Promise<ReactNode>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
||||||
|
Object.freeze([]);
|
||||||
Reference in new issue
Block a user