feat(housekeeping): compose operations workspace
CI / check (pull_request) Successful in 37s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped

This commit is contained in:
Simo committed 2026-08-30 17:23:55 +02:00
1 parent 5574e601bb
commit 85ad0452d3
22 files changed
+1240 -87

No files matched your search

@@ -44,6 +44,7 @@ export default async function HousekeepingPreviewRoutePage({
return handler.render({
context,
match,
surface: "preview",
...(searchParams ? { searchParams: await searchParams } : {}),
});
}
+92 -13
View File
@@ -1,27 +1,106 @@
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
import { buildHousekeepingNavigation } from "@/features/housekeeping/foundation/navigation";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { toHousekeepingHref } from "@/features/housekeeping/foundation/routing/href";
import { matchHousekeepingRoute } from "@/features/housekeeping/foundation/routing/match-route";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { HousekeepingShell } from "@/features/housekeeping/foundation/shell/housekeeping-shell";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
import { HOUSEKEEPING_ROUTE_HANDLERS } from "@/features/housekeeping/route-handlers";
const MESSAGE_PREFIX = "pages.housekeeping.";
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 HousekeepingPreviewRootPage() {
const context = await getHousekeepingCapabilityContext();
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const operations = registry.domains.find(
(domain) => domain.id === "operations",
);
const route = operations?.routes.find(
(entry) => entry.id === "operations.workspace",
);
const match = matchHousekeepingRoute(registry, "/ase");
const handler = HOUSEKEEPING_ROUTE_HANDLERS.find(
(entry) => entry.routeId === "operations.workspace",
);
const canOpenWorkspace =
operations !== undefined &&
route !== undefined &&
match !== null &&
handler !== undefined &&
satisfiesCapability(context, operations.capability) &&
satisfiesCapability(context, route.capability);
for (const domain of registry.domains) {
if (!satisfiesCapability(context, domain.capability)) continue;
const route = domain.routes.find((entry) =>
satisfiesCapability(context, entry.capability),
);
if (!route) continue;
const previewHref = toHousekeepingHref(route.href, "preview");
redirect(
previewHref === "/ase-next" ? "/ase-next/operations" : previewHref,
);
if (!canOpenWorkspace) {
for (const domain of registry.domains) {
if (domain.id === "operations") continue;
if (!satisfiesCapability(context, domain.capability)) continue;
const fallback = domain.routes.find((entry) =>
satisfiesCapability(context, entry.capability),
);
if (fallback) redirect(toHousekeepingHref(fallback.href, "preview"));
}
notFound();
}
if (
!operations ||
!route ||
!match ||
!handler ||
!satisfiesCapability(context, operations.capability) ||
!satisfiesCapability(context, route.capability)
) {
notFound();
}
notFound();
const translate = await getTranslations("pages.housekeeping");
const navigation = buildHousekeepingNavigation(
registry,
context,
"preview",
(key) => translate(namespaceKey(key) as never),
);
const workspace = await handler.render({
context,
match,
surface: "preview",
});
return (
<HousekeepingShell
actor={context.actor}
activeDomainId="operations"
domains={navigation}
labels={{
skipToContent: translate("navigation.skipToContent"),
primaryNavigation: translate("navigation.primary"),
contextualNavigation: translate("navigation.contextual"),
command: translate("preview.commandDisabled"),
preview: translate("preview.badge"),
backToSite: translate("preview.backToSite"),
commandDeck: {
placeholder: translate("commandDeck.placeholder"),
navigation: translate("commandDeck.navigation"),
commands: translate("commandDeck.commands"),
entities: translate("commandDeck.entities"),
loading: translate("commandDeck.loading"),
empty: translate("commandDeck.empty"),
partial: translate("commandDeck.partial"),
close: translate("commandDeck.close"),
},
}}
surface="preview"
>
{workspace}
</HousekeepingShell>
);
}
@@ -0,0 +1,97 @@
import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import { authorizeHousekeeping } from "../../foundation/authorization";
import { satisfiesCapability } from "../../foundation/capability-context";
import {
anyCapability,
fail,
type HousekeepingCapabilityContext,
type HousekeepingInboxSource,
ok,
} from "../../foundation/contracts";
const capability = anyCapability(PERMS.ADMIN_DASHBOARD);
export interface OperationsAccessSummary {
readonly accessibleDomains: number;
}
export interface OperationsInboxAdapters {
accessSummary(
context: HousekeepingCapabilityContext,
signal: AbortSignal,
): Promise<OperationsAccessSummary>;
}
export function createOperationsInboxSources(
adapters: OperationsInboxAdapters,
): readonly HousekeepingInboxSource[] {
return [
{
id: "operations.access-summary",
owner: "operations",
capability,
async getItems(context, signal) {
const authorization = authorizeHousekeeping(context, capability);
if (!authorization.ok) return authorization;
try {
const summary = await adapters.accessSummary(context, signal);
const occurredAt = new Date().toISOString();
return ok(
{
availability: "available" as const,
items:
summary.accessibleDomains === 0
? [
{
sourceId: "operations.access-summary",
itemId: "limited-access",
deduplicationKey:
"operations.access-summary:limited-access",
domain: "operations" as const,
capability,
severity: "warning" as const,
priority: "high" as const,
ageMs: 0,
state: "limited-access",
occurredAt,
titleKey:
"pages.housekeeping.items.operationsLimitedAccess",
href: "/ase" as const,
freshness: "fresh" as const,
actions: [],
},
]
: [],
},
authorization.correlationId,
);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
},
];
}
export const OPERATIONS_INBOX_SOURCES = createOperationsInboxSources({
async accessSummary(context, signal) {
if (signal.aborted) throw new Error("aborted operations access summary");
const { HOUSEKEEPING_MANIFESTS } = await import("../../manifests");
return {
accessibleDomains: HOUSEKEEPING_MANIFESTS.filter(
(domain) =>
domain.id !== "operations" &&
satisfiesCapability(context, domain.capability) &&
domain.routes.some((route) =>
satisfiesCapability(context, route.capability),
),
).length,
};
},
});
@@ -3,6 +3,10 @@ import {
anyCapability,
type HousekeepingDomainManifest,
} from "../../foundation/contracts";
import { OPERATIONS_INBOX_SOURCES } from "./inbox";
import { OPERATIONS_ROUTES } from "./routes";
import { OPERATIONS_SEARCH_PROVIDERS } from "./search";
import { OPERATIONS_WIDGETS } from "./widgets";
export const operationsManifest = {
id: "operations",
@@ -11,8 +15,8 @@ export const operationsManifest = {
iconId: "inbox",
canonicalHref: "/ase",
capability: anyCapability(PERMS.ADMIN_DASHBOARD),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
routes: OPERATIONS_ROUTES,
searchProviders: OPERATIONS_SEARCH_PROVIDERS,
inboxSources: OPERATIONS_INBOX_SOURCES,
widgets: OPERATIONS_WIDGETS,
} satisfies HousekeepingDomainManifest;
@@ -0,0 +1,78 @@
"use client";
import type {
HousekeepingRouteDefinition,
HousekeepingSurface,
HousekeepingWidgetDefinition,
} from "../../../foundation/contracts";
import type { HousekeepingPreferences } from "../../../foundation/preferences/schema";
import type { HousekeepingWidgetLoadResult } from "../../../foundation/registry";
import { Favorites } from "../../../foundation/shell/favorites";
import { WidgetGrid } from "../../../foundation/shell/widget-grid";
import {
type HousekeepingPreferenceSaver,
WidgetSettings,
} from "../../../foundation/shell/widget-settings";
const persistPreferences: HousekeepingPreferenceSaver = async (preferences) => {
const { saveHousekeepingPreferences } = await import(
"@/actions/housekeeping-preferences"
);
return saveHousekeepingPreferences(preferences);
};
export function OperationsPersonalization({
preferences,
routes,
widgets,
widgetResult,
surface,
savePreferences = persistPreferences,
}: {
readonly preferences: HousekeepingPreferences;
readonly routes: readonly HousekeepingRouteDefinition[];
readonly widgets: readonly HousekeepingWidgetDefinition[];
readonly widgetResult: HousekeepingWidgetLoadResult;
readonly surface: HousekeepingSurface;
readonly savePreferences?: HousekeepingPreferenceSaver;
}) {
return (
<>
<div className="grid gap-6 xl:grid-cols-2">
<Favorites
preferences={preferences}
routes={routes}
surface={surface}
savePreferences={savePreferences}
labels={{
title: "Favorites",
pin: "Pin",
unpin: "Unpin",
error: "Preferences could not be saved",
}}
/>
<WidgetSettings
preferences={preferences}
widgets={widgets}
savePreferences={savePreferences}
labels={{
title: "Widget settings",
mandatory: "Mandatory",
error: "Preferences could not be saved",
}}
/>
</div>
<WidgetGrid
preferences={preferences}
result={widgetResult}
savePreferences={savePreferences}
labels={{
title: "Widgets",
partial: "Some widgets are unavailable",
error: "Widget order could not be saved",
}}
/>
</>
);
}
@@ -0,0 +1,118 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingRouteDefinition,
type HousekeepingWidgetDefinition,
ok,
} from "../../../foundation/contracts";
import { defaultHousekeepingPreferences } from "../../../foundation/preferences/schema";
import type { OperationsWorkspaceLoadResult } from "../queries";
import { OperationsWorkspacePage } from "./workspace";
const capability = anyCapability(PERMS.USERS_VIEW);
const routes: readonly HousekeepingRouteDefinition[] = [
{
id: "people.users",
labelKey: "Users",
href: "/ase/people/users",
capability,
},
];
const widgets: readonly HousekeepingWidgetDefinition[] = [
{
id: "people.queue",
owner: "people",
capability,
kind: "mandatory",
load: async () => ok({}, "widget"),
},
];
const preferences = {
...defaultHousekeepingPreferences(),
pinnedRouteIds: ["people.users"],
shortcutOrder: ["people.users"],
widgetOrder: ["people.queue"],
};
const result: OperationsWorkspaceLoadResult = {
model: {
inbox: {
items: [
{
sourceId: "people.queue",
itemId: "ticket-1",
deduplicationKey: "people.queue:ticket-1",
domain: "people",
capability,
severity: "warning",
priority: "high",
ageMs: 1_000,
state: "open",
occurredAt: "2026-08-30T10:00:00.000Z",
titleKey: "Ticket",
href: "/ase/people/users",
freshness: "fresh",
actions: [],
},
],
errors: [{ sourceId: "system.alerts", code: "TIMEOUT" }],
correlationId: "inbox",
},
recent: [
{
routeId: "people.users",
canonicalHref: "/ase/people/users",
labelKey: "Users",
occurredAt: "2026-08-30T09:00:00.000Z",
source: "route-visit",
},
],
favorites: preferences,
widgets: {
widgets: [{ id: "people.queue", data: { count: 1 } }],
errors: [{ widgetId: "system.health", code: "TIMEOUT" }],
},
},
errors: [{ section: "recent", code: "DEPENDENCY_UNAVAILABLE" }],
correlationId: "workspace",
};
const savePreferences = vi.fn(async (value) => ok(value, "saved"));
describe("OperationsWorkspacePage", () => {
it("renders capability-filtered sections, partial failures, and preview links", () => {
const html = renderToStaticMarkup(
<OperationsWorkspacePage
result={result}
routes={routes}
widgetDefinitions={widgets}
surface="preview"
savePreferences={savePreferences}
/>,
);
expect(html).toContain("Operations workspace");
expect(html).toContain("Operational inbox");
expect(html).toContain("Recent work");
expect(html).toContain("Favorites");
expect(html).toContain("Widget settings");
expect(html).toContain("Some workspace sections are unavailable");
expect(html).toContain("system.alerts");
expect(html).toContain("system.health");
expect(html).toContain('href="/ase-next/people/users"');
});
it("renders an explicit state when no operational domain is accessible", () => {
const html = renderToStaticMarkup(
<OperationsWorkspacePage
result={result}
routes={[]}
widgetDefinitions={[]}
surface="preview"
savePreferences={savePreferences}
/>,
);
expect(html).toContain("No operational domains are available");
expect(html).not.toContain("Operational inbox");
});
});
@@ -0,0 +1,106 @@
import type {
HousekeepingRouteDefinition,
HousekeepingSurface,
HousekeepingWidgetDefinition,
} from "../../../foundation/contracts";
import { HousekeepingPageShell } from "../../../foundation/page/housekeeping-page-shell";
import { OperationalInbox } from "../../../foundation/shell/operational-inbox";
import { RecentWork } from "../../../foundation/shell/recent-work";
import type { HousekeepingPreferenceSaver } from "../../../foundation/shell/widget-settings";
import type { OperationsWorkspaceLoadResult } from "../queries";
import { OperationsPersonalization } from "./personalization";
export function OperationsWorkspacePage({
result,
routes,
widgetDefinitions,
surface,
savePreferences,
}: {
readonly result: OperationsWorkspaceLoadResult;
readonly routes: readonly HousekeepingRouteDefinition[];
readonly widgetDefinitions: readonly HousekeepingWidgetDefinition[];
readonly surface: HousekeepingSurface;
readonly savePreferences?: HousekeepingPreferenceSaver;
}) {
if (routes.length === 0) {
return (
<HousekeepingPageShell
title="Operations workspace"
description="Your capability-derived operational home."
>
<div className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
<h2 className="font-semibold">
No operational domains are available
</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
Your account can open Housekeeping, but no operational domain is
currently authorized.
</p>
</div>
</HousekeepingPageShell>
);
}
return (
<HousekeepingPageShell
title="Operations workspace"
description="Inbox, recent work, favorites, and widgets for the domains you can operate."
>
<div className="space-y-6">
{result.errors.length > 0 ? (
<div
role="status"
className="rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] p-3"
>
<p className="text-[var(--admin-warning)]">
Some workspace sections are unavailable
</p>
<ul className="mt-2 list-disc pl-5 text-sm">
{result.errors.map((error) => (
<li key={error.section}>
{error.section}: {error.code}
</li>
))}
</ul>
</div>
) : null}
<div className="grid gap-6 xl:grid-cols-2">
<OperationalInbox
response={result.model.inbox}
surface={surface}
labels={{
title: "Operational inbox",
domainFilter: "Filter by domain",
stateFilter: "Filter by state",
priorityFilter: "Filter by priority",
all: "All",
loading: "Loading operational inbox",
empty: "No operational work is waiting",
partial: "Some inbox sources are unavailable",
open: "Open",
}}
/>
<RecentWork
items={result.model.recent}
surface={surface}
labels={{
title: "Recent work",
empty: "No recent Housekeeping work",
}}
/>
</div>
<OperationsPersonalization
preferences={result.model.favorites}
routes={routes}
widgets={widgetDefinitions}
widgetResult={result.model.widgets}
surface={surface}
{...(savePreferences ? { savePreferences } : {})}
/>
</div>
</HousekeepingPageShell>
);
}
@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
fail,
type HousekeepingCapabilityContext,
ok,
} from "../../foundation/contracts";
import { defaultHousekeepingPreferences } from "../../foundation/preferences/schema";
import type { HousekeepingRegistry } from "../../foundation/registry";
import { createOperationsWorkspaceQuery } from "./queries";
const capability = anyCapability(PERMS.ADMIN_DASHBOARD);
const registry: HousekeepingRegistry = {
domains: [
{
id: "operations",
labelKey: "operations",
descriptionKey: "operations.description",
iconId: "inbox",
canonicalHref: "/ase",
capability,
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
},
],
};
const context: HousekeepingCapabilityContext = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
};
describe("Operations workspace query", () => {
it("retains successful sections while mapping every dependency failure independently", async () => {
const widgets = vi.fn().mockRejectedValue(new Error("widgets down"));
const query = createOperationsWorkspaceQuery({
registry,
loadInbox: vi.fn().mockRejectedValue(new Error("inbox down")),
loadRecent: vi
.fn()
.mockResolvedValue(
fail("DEPENDENCY_UNAVAILABLE", "recent down", "recent"),
),
loadPreferences: vi
.fn()
.mockResolvedValue(fail("INTERNAL", "preferences down", "preferences")),
loadWidgets: widgets,
createCorrelationId: () => "operations-partial",
});
const result = await query(context);
expect(result.model.inbox).toMatchObject({
items: [],
errors: [{ sourceId: "operations.inbox", code: "INTERNAL" }],
});
expect(result.model.recent).toEqual([]);
expect(result.model.favorites).toEqual(defaultHousekeepingPreferences());
expect(result.model.widgets).toEqual({
widgets: [],
errors: [{ widgetId: "operations.workspace", code: "INTERNAL" }],
});
expect(result.errors.map((error) => error.section)).toEqual([
"inbox",
"recent",
"favorites",
"widgets",
]);
expect(widgets).toHaveBeenCalledWith(
registry,
defaultHousekeepingPreferences(),
context,
);
});
it("loads all four sections and passes reconciled preferences to widgets", async () => {
const preferences = {
...defaultHousekeepingPreferences(),
pinnedRouteIds: ["people.users"],
};
const loadWidgets = vi.fn().mockResolvedValue({ widgets: [], errors: [] });
const query = createOperationsWorkspaceQuery({
registry,
loadInbox: vi.fn().mockResolvedValue({
items: [],
errors: [],
correlationId: "inbox",
}),
loadRecent: vi.fn().mockResolvedValue(ok([], "recent")),
loadPreferences: vi
.fn()
.mockResolvedValue(ok(preferences, "preferences")),
loadWidgets,
});
const result = await query(context);
expect(result.errors).toEqual([]);
expect(result.model.favorites).toEqual(preferences);
expect(loadWidgets).toHaveBeenCalledWith(registry, preferences, context);
});
});
@@ -0,0 +1,211 @@
import "server-only";
import {
createCorrelationId,
fail,
type HousekeepingCapabilityContext,
type HousekeepingErrorCode,
type HousekeepingResult,
ok,
} from "../../foundation/contracts";
import type { HousekeepingInboxResponse } from "../../foundation/inbox/inbox-service";
import { loadHousekeepingInbox } from "../../foundation/inbox/inbox-service";
import { reconcilePreferences } from "../../foundation/preferences/reconcile";
import {
defaultHousekeepingPreferences,
type HousekeepingPreferences,
} from "../../foundation/preferences/schema";
import type { HousekeepingRecentItem } from "../../foundation/recent/recent-work";
import {
createHousekeepingRegistry,
type HousekeepingRegistry,
type HousekeepingWidgetLoadResult,
loadHousekeepingWidgets,
} from "../../foundation/registry";
import { HOUSEKEEPING_MANIFESTS } from "../../manifests";
export interface OperationsWorkspaceModel {
readonly inbox: HousekeepingInboxResponse;
readonly recent: readonly HousekeepingRecentItem[];
readonly favorites: HousekeepingPreferences;
readonly widgets: HousekeepingWidgetLoadResult;
}
export interface OperationsWorkspaceLoadResult {
readonly model: OperationsWorkspaceModel;
readonly errors: readonly {
readonly section: "inbox" | "recent" | "favorites" | "widgets";
readonly code: HousekeepingErrorCode;
}[];
readonly correlationId: string;
}
export interface OperationsWorkspaceDependencies {
readonly registry: HousekeepingRegistry;
readonly loadInbox: (
context: HousekeepingCapabilityContext,
) => Promise<HousekeepingInboxResponse>;
readonly loadRecent: (
context: HousekeepingCapabilityContext,
) => Promise<HousekeepingResult<readonly HousekeepingRecentItem[]>>;
readonly loadPreferences: (
context: HousekeepingCapabilityContext,
) => Promise<HousekeepingResult<HousekeepingPreferences>>;
readonly loadWidgets: (
registry: HousekeepingRegistry,
preferences: HousekeepingPreferences,
context: HousekeepingCapabilityContext,
) => Promise<HousekeepingWidgetLoadResult>;
readonly createCorrelationId?: () => string;
}
interface SectionOutcome<T> {
readonly value: T;
readonly error?: HousekeepingErrorCode;
}
function internalInbox(correlationId: string): HousekeepingInboxResponse {
return {
items: [],
errors: [{ sourceId: "operations.inbox", code: "INTERNAL" }],
correlationId,
};
}
function internalWidgets(): HousekeepingWidgetLoadResult {
return {
widgets: [],
errors: [{ widgetId: "operations.workspace", code: "INTERNAL" }],
};
}
export function createOperationsWorkspaceQuery({
registry,
loadInbox,
loadRecent,
loadPreferences,
loadWidgets,
createCorrelationId: correlationIdFactory = createCorrelationId,
}: OperationsWorkspaceDependencies) {
return async function operationsWorkspaceQuery(
context: HousekeepingCapabilityContext,
): Promise<OperationsWorkspaceLoadResult> {
const correlationId = correlationIdFactory();
const inboxPromise: Promise<SectionOutcome<HousekeepingInboxResponse>> =
loadInbox(context)
.then((value) => ({ value }))
.catch(() => ({
value: internalInbox(correlationId),
error: "INTERNAL" as const,
}));
const recentPromise: Promise<
SectionOutcome<readonly HousekeepingRecentItem[]>
> = loadRecent(context)
.then((result) =>
result.ok
? { value: result.data }
: { value: [], error: result.error.code },
)
.catch(() => ({ value: [], error: "INTERNAL" as const }));
const preferencesPromise: Promise<SectionOutcome<HousekeepingPreferences>> =
loadPreferences(context)
.then((result) =>
result.ok
? { value: result.data }
: {
value: defaultHousekeepingPreferences(),
error: result.error.code,
},
)
.catch(() => ({
value: defaultHousekeepingPreferences(),
error: "INTERNAL" as const,
}));
const widgetsPromise: Promise<
SectionOutcome<HousekeepingWidgetLoadResult>
> = preferencesPromise.then(({ value }) =>
loadWidgets(registry, value, context)
.then((widgets) => ({ value: widgets }))
.catch(() => ({
value: internalWidgets(),
error: "INTERNAL" as const,
})),
);
const [inbox, recent, favorites, widgets] = await Promise.all([
inboxPromise,
recentPromise,
preferencesPromise,
widgetsPromise,
]);
const errors = [
...(inbox.error
? [{ section: "inbox" as const, code: inbox.error }]
: []),
...(recent.error
? [{ section: "recent" as const, code: recent.error }]
: []),
...(favorites.error
? [{ section: "favorites" as const, code: favorites.error }]
: []),
...(widgets.error
? [{ section: "widgets" as const, code: widgets.error }]
: []),
];
return {
model: {
inbox: inbox.value,
recent: recent.value,
favorites: favorites.value,
widgets: widgets.value,
},
errors,
correlationId,
};
};
}
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
async function loadPreferences(
context: HousekeepingCapabilityContext,
): Promise<HousekeepingResult<HousekeepingPreferences>> {
const correlationId = createCorrelationId();
try {
const { housekeepingPreferencesRepository } = await import(
"@/lib/housekeeping-preferences-repository"
);
return ok(
reconcilePreferences(
await housekeepingPreferencesRepository.read(context.actor.id),
registry,
context,
),
correlationId,
);
} catch {
return fail(
"INTERNAL",
"errors.housekeeping.preferences.read",
correlationId,
);
}
}
async function loadRecent(
context: HousekeepingCapabilityContext,
): Promise<HousekeepingResult<readonly HousekeepingRecentItem[]>> {
const { loadHousekeepingRecentWork } = await import(
"@/lib/housekeeping-recent-work"
);
return loadHousekeepingRecentWork(context);
}
export const operationsWorkspaceQuery = createOperationsWorkspaceQuery({
registry,
loadInbox: loadHousekeepingInbox,
loadRecent,
loadPreferences,
loadWidgets: loadHousekeepingWidgets,
});
@@ -0,0 +1,47 @@
import { createElement } from "react";
import { satisfiesCapability } from "../../foundation/capability-context";
import { createHousekeepingRegistry } from "../../foundation/registry";
import { HOUSEKEEPING_MANIFESTS } from "../../manifests";
import type { HousekeepingRouteHandler } from "../../route-handlers";
import { OperationsWorkspacePage } from "./pages/workspace";
import { operationsWorkspaceQuery } from "./queries";
export async function renderOperationsWorkspacePage(
input: Parameters<HousekeepingRouteHandler["render"]>[0],
) {
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const accessibleDomains = registry.domains.filter(
(domain) =>
domain.id !== "operations" &&
satisfiesCapability(input.context, domain.capability),
);
const routes = accessibleDomains.flatMap((domain) =>
domain.routes.filter((route) =>
satisfiesCapability(input.context, route.capability),
),
);
const widgetDefinitions = registry.domains
.filter((domain) => satisfiesCapability(input.context, domain.capability))
.flatMap((domain) =>
domain.widgets.filter((widget) =>
satisfiesCapability(input.context, widget.capability),
),
);
return createElement(OperationsWorkspacePage, {
result: await operationsWorkspaceQuery(input.context),
routes,
widgetDefinitions,
surface: input.surface ?? "preview",
...(input.savePreferences
? { savePreferences: input.savePreferences }
: {}),
});
}
export const OPERATIONS_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
Object.freeze([
Object.freeze({
routeId: "operations.workspace",
render: renderOperationsWorkspacePage,
}),
]);
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
import { operationsMigrationEntries } from "../../migration/operations";
import { OPERATIONS_INBOX_SOURCES } from "./inbox";
import { operationsManifest } from "./manifest";
import { OPERATIONS_ROUTES } from "./routes";
import { OPERATIONS_SEARCH_PROVIDERS } from "./search";
import { OPERATIONS_WIDGETS } from "./widgets";
const context: HousekeepingCapabilityContext = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => slug === PERMS.ADMIN_DASHBOARD,
hasAny: (...slugs) => slugs.includes(PERMS.ADMIN_DASHBOARD),
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.ADMIN_DASHBOARD),
};
describe("Operations workspace registration", () => {
it("maps the one legacy dashboard row to operations.workspace at canonical /ase", () => {
expect(OPERATIONS_ROUTES).toEqual([
expect.objectContaining({
id: "operations.workspace",
href: "/ase",
capability: { mode: "any", slugs: [PERMS.ADMIN_DASHBOARD] },
}),
]);
expect(operationsMigrationEntries).toEqual([
expect.objectContaining({
legacyPath: "/admin",
targetDomain: "operations",
targetPath: OPERATIONS_ROUTES[0]?.href,
}),
]);
});
it("publishes a real route, safe navigation search, inbox summary, and mandatory widget", async () => {
expect(operationsManifest.routes).toEqual(OPERATIONS_ROUTES);
expect(operationsManifest.searchProviders).toEqual(
OPERATIONS_SEARCH_PROVIDERS,
);
expect(operationsManifest.inboxSources).toEqual(OPERATIONS_INBOX_SOURCES);
expect(operationsManifest.widgets).toEqual(OPERATIONS_WIDGETS);
const search = await OPERATIONS_SEARCH_PROVIDERS[0]?.search(context, {
term: "operations",
limit: 25,
});
expect(search).toMatchObject({
ok: true,
data: [
expect.objectContaining({
id: "operations.workspace",
type: "navigation",
href: "/ase",
}),
],
});
const inbox = await OPERATIONS_INBOX_SOURCES[0]?.getItems(
context,
new AbortController().signal,
);
expect(inbox).toMatchObject({
ok: true,
data: {
availability: "available",
items: [
expect.objectContaining({
sourceId: "operations.access-summary",
href: "/ase",
}),
],
},
});
expect(OPERATIONS_WIDGETS[0]).toMatchObject({
id: "operations.workspace-summary",
kind: "mandatory",
owner: "operations",
});
const widget = await OPERATIONS_WIDGETS[0]?.load(
context,
new AbortController().signal,
);
expect(widget).toMatchObject({
ok: true,
data: { accessibleDomains: 0, accessibleRoutes: 0 },
});
});
});
@@ -0,0 +1,14 @@
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingRouteDefinition,
} from "../../foundation/contracts";
export const OPERATIONS_ROUTES = [
{
id: "operations.workspace",
labelKey: "pages.housekeeping.routes.operations.workspace",
href: "/ase",
capability: anyCapability(PERMS.ADMIN_DASHBOARD),
},
] as const satisfies readonly HousekeepingRouteDefinition[];
@@ -0,0 +1,43 @@
import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import { authorizeHousekeeping } from "../../foundation/authorization";
import {
anyCapability,
type HousekeepingSearchProvider,
ok,
} from "../../foundation/contracts";
const capability = anyCapability(PERMS.ADMIN_DASHBOARD);
export const OPERATIONS_SEARCH_PROVIDERS = [
{
id: "operations.workspace",
owner: "operations",
capability,
async search(context, input) {
const authorization = authorizeHousekeeping(context, capability);
if (!authorization.ok) return authorization;
const term = input.term.normalize("NFC").trim().toLocaleLowerCase();
const matches = ["operations", "workspace", "dashboard"].some((value) =>
value.includes(term),
);
return ok(
matches
? [
{
id: "operations.workspace",
domain: "operations" as const,
type: "navigation" as const,
title: "Operations workspace",
description: "Open the operational home",
href: "/ase" as const,
capability,
},
].slice(0, Math.max(0, Math.trunc(input.limit)))
: [],
authorization.correlationId,
);
},
},
] as const satisfies readonly HousekeepingSearchProvider[];
@@ -0,0 +1,82 @@
import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import { authorizeHousekeeping } from "../../foundation/authorization";
import { satisfiesCapability } from "../../foundation/capability-context";
import {
anyCapability,
fail,
type HousekeepingCapabilityContext,
type HousekeepingWidgetDefinition,
ok,
} from "../../foundation/contracts";
const capability = anyCapability(PERMS.ADMIN_DASHBOARD);
export interface OperationsWorkspaceSummary {
readonly accessibleDomains: number;
readonly accessibleRoutes: number;
}
export interface OperationsWidgetAdapters {
workspaceSummary(
context: HousekeepingCapabilityContext,
signal: AbortSignal,
): Promise<OperationsWorkspaceSummary>;
}
export function createOperationsWidgets(
adapters: OperationsWidgetAdapters,
): readonly HousekeepingWidgetDefinition[] {
return [
{
id: "operations.workspace-summary",
owner: "operations",
capability,
kind: "mandatory",
async load(context, signal) {
const authorization = authorizeHousekeeping(context, capability);
if (!authorization.ok) return authorization;
try {
return ok(
await adapters.workspaceSummary(context, signal),
authorization.correlationId,
);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
},
];
}
export const OPERATIONS_WIDGETS = createOperationsWidgets({
async workspaceSummary(context, signal) {
if (signal.aborted) throw new Error("aborted operations workspace summary");
const { HOUSEKEEPING_MANIFESTS } = await import("../../manifests");
const domains = HOUSEKEEPING_MANIFESTS.filter(
(domain) =>
domain.id !== "operations" &&
satisfiesCapability(context, domain.capability),
);
return {
accessibleDomains: domains.filter((domain) =>
domain.routes.some((route) =>
satisfiesCapability(context, route.capability),
),
).length,
accessibleRoutes: domains.reduce(
(total, domain) =>
total +
domain.routes.filter((route) =>
satisfiesCapability(context, route.capability),
).length,
0,
),
};
},
});
@@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest";
import { CONTENT_ROUTE_IDS } from "../domains/content/routes";
import { ECONOMY_ROUTE_IDS } from "../domains/economy/routes";
import { HOTEL_ROUTE_IDS } from "../domains/hotel/routes";
import { OPERATIONS_ROUTES } from "../domains/operations/routes";
import { PEOPLE_ROUTE_IDS } from "../domains/people/routes";
import { SYSTEM_ROUTE_IDS } from "../domains/system/routes";
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
@@ -27,6 +28,33 @@ const SERVER_CAPABILITY_CONTEXT =
const PERMISSIONS_ADAPTER = "src/lib/permissions";
const DOMAIN_MODULE_ROOT = "src/features/housekeeping/domains";
const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
[
"src/features/housekeeping/domains/operations/manifest.ts",
new Set([
"src/features/housekeeping/domains/operations/inbox",
"src/features/housekeeping/domains/operations/routes",
"src/features/housekeeping/domains/operations/search",
"src/features/housekeeping/domains/operations/widgets",
]),
],
[
"src/features/housekeeping/domains/operations/pages/personalization.tsx",
new Set(["src/actions/housekeeping-preferences"]),
],
[
"src/features/housekeeping/domains/operations/pages/workspace.tsx",
new Set([
"src/features/housekeeping/domains/operations/pages/personalization",
"src/features/housekeeping/domains/operations/queries",
]),
],
[
"src/features/housekeeping/domains/operations/route-handlers.ts",
new Set([
"src/features/housekeeping/domains/operations/pages/workspace",
"src/features/housekeeping/domains/operations/queries",
]),
],
[
"src/features/housekeeping/foundation/shell/command-deck.tsx",
new Set(["src/actions/housekeeping-search"]),
@@ -923,6 +951,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
"src/features/housekeeping/domains/content/route-handlers",
"src/features/housekeeping/domains/economy/route-handlers",
"src/features/housekeeping/domains/hotel/route-handlers",
"src/features/housekeeping/domains/operations/route-handlers",
"src/features/housekeeping/domains/people/route-handlers",
"src/features/housekeeping/domains/system/route-handlers",
]),
@@ -1523,7 +1552,7 @@ describe("housekeeping foundation completion contracts", () => {
}
});
it("creates the real six-domain registry with People, Content, Economy, Hotel, and System routes enabled", () => {
it("creates the real six-domain registry with every domain route enabled", () => {
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
expect(registry.domains.map((domain) => domain.id)).toEqual([
@@ -1536,14 +1565,9 @@ describe("housekeeping foundation completion contracts", () => {
]);
expect(
registry.domains
.filter(
(domain) =>
!["people", "content", "economy", "hotel", "system"].includes(
domain.id,
),
)
.every((domain) => domain.routes.length === 0),
).toBe(true);
.find((domain) => domain.id === "operations")
?.routes.map((route) => route.id),
).toEqual(OPERATIONS_ROUTES.map((route) => route.id));
expect(
registry.domains
.find((domain) => domain.id === "people")
@@ -148,6 +148,7 @@ const routeMocks = vi.hoisted(() => {
"routes.hotel.studio.repair-icons": "HK::hotel-studio-repair-icons",
"routes.hotel.studio.sync": "HK::hotel-studio-sync",
"routes.hotel.studio.upload": "HK::hotel-studio-upload",
"routes.operations.workspace": "HK::operations-workspace",
"states.empty.title": "Localized empty title",
"states.empty.description": "Localized empty description",
};
@@ -276,6 +277,10 @@ vi.mock("@/features/housekeeping/manifests", async (importOriginal) => {
});
vi.mock("@/features/housekeeping/route-handlers", () => ({
HOUSEKEEPING_ROUTE_HANDLERS: [
{
routeId: "operations.workspace",
render: routeMocks.renderHousekeepingRoute,
},
{ routeId: "people.users", render: routeMocks.renderHousekeepingRoute },
{
routeId: "people.user-detail",
@@ -617,24 +622,30 @@ describe("/ase-next preview gate", () => {
);
});
describe("/ase-next first visible domain", () => {
describe("/ase-next operational home", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("redirects an administrator to the first permitted registered route", async () => {
it("renders the Operations workspace directly for an administrator", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW]),
);
await expect(AdminNextPage()).rejects.toThrow(
"NEXT_REDIRECT:/ase-next/people/users",
const html = await renderRoute(AdminNextPage());
expect(html).toContain("Rendered operations.workspace");
expect(html).toContain("HK::operations-workspace");
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith(
expect.objectContaining({
match: expect.objectContaining({ routeId: "operations.workspace" }),
surface: "preview",
}),
);
expect(routeMocks.redirect).toHaveBeenCalledWith("/ase-next/people/users");
expect(routeMocks.redirect).not.toHaveBeenCalled();
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
1,
);
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1);
});
it("redirects a moderator with only an approved mod view capability to People", async () => {
@@ -790,6 +801,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledTimes(1);
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
context,
surface: "preview",
match: {
routeId: "people.users",
domain: "people",
@@ -815,6 +827,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
context,
surface: "preview",
match: {
routeId: "people.user-detail",
domain: "people",
@@ -839,6 +852,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
context,
surface: "preview",
match: {
routeId: "people.literal-tool",
domain: "people",
@@ -12,6 +12,10 @@ import { HOTEL_INBOX_SOURCES } from "../domains/hotel/inbox";
import { HOTEL_ROUTES } from "../domains/hotel/routes";
import { HOTEL_SEARCH_PROVIDERS } from "../domains/hotel/search";
import { HOTEL_WIDGETS } from "../domains/hotel/widgets";
import { OPERATIONS_INBOX_SOURCES } from "../domains/operations/inbox";
import { OPERATIONS_ROUTES } from "../domains/operations/routes";
import { OPERATIONS_SEARCH_PROVIDERS } from "../domains/operations/search";
import { OPERATIONS_WIDGETS } from "../domains/operations/widgets";
import { PEOPLE_INBOX_SOURCES } from "../domains/people/inbox";
import { PEOPLE_ROUTES } from "../domains/people/routes";
import { PEOPLE_SEARCH_PROVIDERS } from "../domains/people/search";
@@ -380,56 +384,64 @@ describe("housekeeping registry", () => {
descriptionKey: expected.descriptionKey,
});
expect(actual.routes).toEqual(
expected.id === "people"
? PEOPLE_ROUTES
: expected.id === "content"
? CONTENT_ROUTES
: expected.id === "economy"
? ECONOMY_ROUTES
: expected.id === "hotel"
? HOTEL_ROUTES
: expected.id === "system"
? SYSTEM_ROUTES
: [],
expected.id === "operations"
? OPERATIONS_ROUTES
: expected.id === "people"
? PEOPLE_ROUTES
: expected.id === "content"
? CONTENT_ROUTES
: expected.id === "economy"
? ECONOMY_ROUTES
: expected.id === "hotel"
? HOTEL_ROUTES
: expected.id === "system"
? SYSTEM_ROUTES
: [],
);
expect(actual.searchProviders).toEqual(
expected.id === "people"
? PEOPLE_SEARCH_PROVIDERS
: expected.id === "content"
? CONTENT_SEARCH_PROVIDERS
: expected.id === "economy"
? ECONOMY_SEARCH_PROVIDERS
: expected.id === "hotel"
? HOTEL_SEARCH_PROVIDERS
: expected.id === "system"
? SYSTEM_SEARCH_PROVIDERS
: [],
expected.id === "operations"
? OPERATIONS_SEARCH_PROVIDERS
: expected.id === "people"
? PEOPLE_SEARCH_PROVIDERS
: expected.id === "content"
? CONTENT_SEARCH_PROVIDERS
: expected.id === "economy"
? ECONOMY_SEARCH_PROVIDERS
: expected.id === "hotel"
? HOTEL_SEARCH_PROVIDERS
: expected.id === "system"
? SYSTEM_SEARCH_PROVIDERS
: [],
);
expect(actual.inboxSources).toEqual(
expected.id === "people"
? PEOPLE_INBOX_SOURCES
: expected.id === "content"
? CONTENT_INBOX_SOURCES
: expected.id === "economy"
? ECONOMY_INBOX_SOURCES
: expected.id === "hotel"
? HOTEL_INBOX_SOURCES
: expected.id === "system"
? SYSTEM_INBOX_SOURCES
: [],
expected.id === "operations"
? OPERATIONS_INBOX_SOURCES
: expected.id === "people"
? PEOPLE_INBOX_SOURCES
: expected.id === "content"
? CONTENT_INBOX_SOURCES
: expected.id === "economy"
? ECONOMY_INBOX_SOURCES
: expected.id === "hotel"
? HOTEL_INBOX_SOURCES
: expected.id === "system"
? SYSTEM_INBOX_SOURCES
: [],
);
expect(actual.widgets).toEqual(
expected.id === "people"
? PEOPLE_WIDGETS
: expected.id === "content"
? CONTENT_WIDGETS
: expected.id === "economy"
? ECONOMY_WIDGETS
: expected.id === "hotel"
? HOTEL_WIDGETS
: expected.id === "system"
? SYSTEM_WIDGETS
: [],
expected.id === "operations"
? OPERATIONS_WIDGETS
: expected.id === "people"
? PEOPLE_WIDGETS
: expected.id === "content"
? CONTENT_WIDGETS
: expected.id === "economy"
? ECONOMY_WIDGETS
: expected.id === "hotel"
? HOTEL_WIDGETS
: expected.id === "system"
? SYSTEM_WIDGETS
: [],
);
expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs });
}
@@ -40,7 +40,10 @@ const {
};
});
vi.mock("react", () => ({ cache: wrapCache }));
vi.mock("react", async (importOriginal) => ({
...(await importOriginal<typeof import("react")>()),
cache: wrapCache,
}));
vi.mock("next/navigation", () => ({ notFound, redirect }));
vi.mock("next-intl/server", () => ({ getTranslations }));
vi.mock("@/lib/auth", () => ({ auth }));
@@ -74,17 +77,17 @@ function adminContext(
};
}
async function invokeRequestConsumers(expectedPageError = "NEXT_NOT_FOUND") {
async function invokeRequestConsumers() {
const shell = await AdminNextDomainLayout({
children: null,
params: Promise.resolve({ domain: "operations" }),
});
await expect(AdminNextPage()).rejects.toThrow(expectedPageError);
const page = await AdminNextPage();
const commandContext = await requireHousekeepingCapability(
anyCapability("admin.dashboard"),
);
return { commandContext, shell };
return { commandContext, page, shell };
}
describe("getHousekeepingCapabilityContext", () => {
@@ -102,11 +105,10 @@ describe("getHousekeepingCapabilityContext", () => {
});
it("isolates real shell, page, and command preflight consumers between logical requests", async () => {
const first = await invokeRequestConsumers(
"NEXT_REDIRECT:/ase-next/people/users",
);
const first = await invokeRequestConsumers();
expect(first.shell).toBeDefined();
expect(first.page).toBeDefined();
expect(first.commandContext.actor).toEqual({
id: 42,
username: "operator",
@@ -123,6 +125,7 @@ describe("getHousekeepingCapabilityContext", () => {
const second = await invokeRequestConsumers();
expect(second.shell).toBeDefined();
expect(second.page).toBeDefined();
expect(second.commandContext.actor).toEqual({
id: 84,
username: "second-operator",
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import type { HousekeepingSurface } from "../contracts";
import type { HousekeepingNavigationDomain } from "../navigation";
import { HousekeepingShell } from "./housekeeping-shell";
@@ -147,6 +148,7 @@ const componentBoundaryRules = [
function renderShell(
activeDomainId: HousekeepingNavigationDomain["id"] = "operations",
availableDomains: readonly HousekeepingNavigationDomain[] = domains,
surface: HousekeepingSurface = "preview",
): string {
return renderToStaticMarkup(
<HousekeepingShell
@@ -154,6 +156,7 @@ function renderShell(
activeDomainId={activeDomainId}
domains={availableDomains}
labels={labels}
surface={surface}
>
<p>Deck body</p>
</HousekeepingShell>,
@@ -230,6 +233,13 @@ describe("HousekeepingShell", () => {
}
});
it("only marks the preview surface as preview", () => {
expect(renderShell()).toContain(">Preview<");
expect(renderShell("operations", domains, "canonical")).not.toContain(
">Preview<",
);
});
it("does not throw or render contextual items when a valid active domain is absent", () => {
const domainsWithoutSystem = domains.filter(
(domain) => domain.id !== "system",
@@ -54,9 +54,11 @@ export function HousekeepingShell({
</a>
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
<div className="flex items-center gap-3">
<span className="rounded-full border border-[var(--admin-border)] px-2 py-1 text-xs text-[var(--admin-accent)]">
{labels.preview}
</span>
{surface === "preview" ? (
<span className="rounded-full border border-[var(--admin-border)] px-2 py-1 text-xs text-[var(--admin-accent)]">
{labels.preview}
</span>
) : null}
<OperatorSummary actor={actor} />
</div>
<div className="flex items-center gap-2">
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { CONTENT_ROUTE_IDS } from "./domains/content/routes";
import { ECONOMY_ROUTE_IDS } from "./domains/economy/routes";
import { HOTEL_ROUTE_IDS } from "./domains/hotel/routes";
import { OPERATIONS_ROUTES } from "./domains/operations/routes";
import { PEOPLE_ROUTE_IDS } from "./domains/people/routes";
import { SYSTEM_ROUTE_IDS } from "./domains/system/routes";
import { createHousekeepingRegistry } from "./foundation/registry";
@@ -21,6 +22,7 @@ describe("housekeeping route handlers", () => {
expect(new Set(handlerIds).size).toBe(handlerIds.length);
expect([...handlerIds].sort()).toEqual([...routeIds].sort());
expect(handlerIds).toEqual([
...OPERATIONS_ROUTES.map((route) => route.id),
...PEOPLE_ROUTE_IDS,
...CONTENT_ROUTE_IDS,
...ECONOMY_ROUTE_IDS,
+9 -1
View File
@@ -2,10 +2,15 @@ import type { ReactNode } from "react";
import { CONTENT_ROUTE_HANDLERS } from "./domains/content/route-handlers";
import { ECONOMY_ROUTE_HANDLERS } from "./domains/economy/route-handlers";
import { HOTEL_ROUTE_HANDLERS } from "./domains/hotel/route-handlers";
import { OPERATIONS_ROUTE_HANDLERS } from "./domains/operations/route-handlers";
import { PEOPLE_ROUTE_HANDLERS } from "./domains/people/route-handlers";
import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers";
import type { HousekeepingCapabilityContext } from "./foundation/contracts";
import type {
HousekeepingCapabilityContext,
HousekeepingSurface,
} from "./foundation/contracts";
import type { HousekeepingRouteMatch } from "./foundation/routing/match-route";
import type { HousekeepingPreferenceSaver } from "./foundation/shell/widget-settings";
export interface HousekeepingPageInput {
readonly context: HousekeepingCapabilityContext;
@@ -13,6 +18,8 @@ export interface HousekeepingPageInput {
readonly searchParams?: Readonly<
Record<string, string | readonly string[] | undefined>
>;
readonly surface?: HousekeepingSurface;
readonly savePreferences?: HousekeepingPreferenceSaver;
}
export interface HousekeepingRouteHandler {
@@ -22,6 +29,7 @@ export interface HousekeepingRouteHandler {
export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
Object.freeze([
...OPERATIONS_ROUTE_HANDLERS,
...PEOPLE_ROUTE_HANDLERS,
...CONTENT_ROUTE_HANDLERS,
...ECONOMY_ROUTE_HANDLERS,