From 85ad0452d367c8791290989409d44400625f1f99 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 30 Aug 2026 17:23:55 +0200 Subject: [PATCH] feat(housekeeping): compose operations workspace --- .../[domain]/[[...segments]]/page.tsx | 1 + src/app/ase-next/page.tsx | 105 +++++++-- .../housekeeping/domains/operations/inbox.ts | 97 ++++++++ .../domains/operations/manifest.ts | 12 +- .../operations/pages/personalization.tsx | 78 +++++++ .../operations/pages/workspace.test.tsx | 118 ++++++++++ .../domains/operations/pages/workspace.tsx | 106 +++++++++ .../domains/operations/queries.test.ts | 107 +++++++++ .../domains/operations/queries.ts | 211 ++++++++++++++++++ .../domains/operations/route-handlers.ts | 47 ++++ .../domains/operations/routes.test.ts | 91 ++++++++ .../housekeeping/domains/operations/routes.ts | 14 ++ .../housekeeping/domains/operations/search.ts | 43 ++++ .../domains/operations/widgets.ts | 82 +++++++ .../foundation-source-contract.test.ts | 42 +++- .../foundation/preview-route-contract.test.ts | 26 ++- .../housekeeping/foundation/registry.test.ts | 100 +++++---- .../server-capability-context.test.ts | 17 +- .../shell/housekeeping-shell.test.tsx | 10 + .../foundation/shell/housekeeping-shell.tsx | 8 +- .../housekeeping/route-handlers.test.ts | 2 + src/features/housekeeping/route-handlers.ts | 10 +- 22 files changed, 1240 insertions(+), 87 deletions(-) create mode 100644 src/features/housekeeping/domains/operations/inbox.ts create mode 100644 src/features/housekeeping/domains/operations/pages/personalization.tsx create mode 100644 src/features/housekeeping/domains/operations/pages/workspace.test.tsx create mode 100644 src/features/housekeeping/domains/operations/pages/workspace.tsx create mode 100644 src/features/housekeeping/domains/operations/queries.test.ts create mode 100644 src/features/housekeeping/domains/operations/queries.ts create mode 100644 src/features/housekeeping/domains/operations/route-handlers.ts create mode 100644 src/features/housekeeping/domains/operations/routes.test.ts create mode 100644 src/features/housekeeping/domains/operations/routes.ts create mode 100644 src/features/housekeeping/domains/operations/search.ts create mode 100644 src/features/housekeeping/domains/operations/widgets.ts diff --git a/src/app/ase-next/[domain]/[[...segments]]/page.tsx b/src/app/ase-next/[domain]/[[...segments]]/page.tsx index 937b06221c..6b2c0a527f 100644 --- a/src/app/ase-next/[domain]/[[...segments]]/page.tsx +++ b/src/app/ase-next/[domain]/[[...segments]]/page.tsx @@ -44,6 +44,7 @@ export default async function HousekeepingPreviewRoutePage({ return handler.render({ context, match, + surface: "preview", ...(searchParams ? { searchParams: await searchParams } : {}), }); } diff --git a/src/app/ase-next/page.tsx b/src/app/ase-next/page.tsx index c45421acf5..ae626b1cd5 100644 --- a/src/app/ase-next/page.tsx +++ b/src/app/ase-next/page.tsx @@ -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 ( + + {workspace} + + ); } diff --git a/src/features/housekeeping/domains/operations/inbox.ts b/src/features/housekeeping/domains/operations/inbox.ts new file mode 100644 index 0000000000..a1f962671c --- /dev/null +++ b/src/features/housekeeping/domains/operations/inbox.ts @@ -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; +} + +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, + }; + }, +}); diff --git a/src/features/housekeeping/domains/operations/manifest.ts b/src/features/housekeeping/domains/operations/manifest.ts index 931693bce5..0a5e55378e 100644 --- a/src/features/housekeeping/domains/operations/manifest.ts +++ b/src/features/housekeeping/domains/operations/manifest.ts @@ -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; diff --git a/src/features/housekeeping/domains/operations/pages/personalization.tsx b/src/features/housekeeping/domains/operations/pages/personalization.tsx new file mode 100644 index 0000000000..ad49f69f51 --- /dev/null +++ b/src/features/housekeeping/domains/operations/pages/personalization.tsx @@ -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 ( + <> +
+ + +
+ + + + ); +} diff --git a/src/features/housekeeping/domains/operations/pages/workspace.test.tsx b/src/features/housekeeping/domains/operations/pages/workspace.test.tsx new file mode 100644 index 0000000000..2b23730787 --- /dev/null +++ b/src/features/housekeeping/domains/operations/pages/workspace.test.tsx @@ -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( + , + ); + 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( + , + ); + expect(html).toContain("No operational domains are available"); + expect(html).not.toContain("Operational inbox"); + }); +}); diff --git a/src/features/housekeeping/domains/operations/pages/workspace.tsx b/src/features/housekeeping/domains/operations/pages/workspace.tsx new file mode 100644 index 0000000000..b50e38d6ad --- /dev/null +++ b/src/features/housekeeping/domains/operations/pages/workspace.tsx @@ -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 ( + +
+

+ No operational domains are available +

+

+ Your account can open Housekeeping, but no operational domain is + currently authorized. +

+
+
+ ); + } + + return ( + +
+ {result.errors.length > 0 ? ( +
+

+ Some workspace sections are unavailable +

+
    + {result.errors.map((error) => ( +
  • + {error.section}: {error.code} +
  • + ))} +
+
+ ) : null} + +
+ + +
+ + +
+
+ ); +} diff --git a/src/features/housekeeping/domains/operations/queries.test.ts b/src/features/housekeeping/domains/operations/queries.test.ts new file mode 100644 index 0000000000..b83d9062ed --- /dev/null +++ b/src/features/housekeeping/domains/operations/queries.test.ts @@ -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); + }); +}); diff --git a/src/features/housekeeping/domains/operations/queries.ts b/src/features/housekeeping/domains/operations/queries.ts new file mode 100644 index 0000000000..d978debe2e --- /dev/null +++ b/src/features/housekeeping/domains/operations/queries.ts @@ -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; + readonly loadRecent: ( + context: HousekeepingCapabilityContext, + ) => Promise>; + readonly loadPreferences: ( + context: HousekeepingCapabilityContext, + ) => Promise>; + readonly loadWidgets: ( + registry: HousekeepingRegistry, + preferences: HousekeepingPreferences, + context: HousekeepingCapabilityContext, + ) => Promise; + readonly createCorrelationId?: () => string; +} + +interface SectionOutcome { + 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 { + const correlationId = correlationIdFactory(); + const inboxPromise: Promise> = + loadInbox(context) + .then((value) => ({ value })) + .catch(() => ({ + value: internalInbox(correlationId), + error: "INTERNAL" as const, + })); + const recentPromise: Promise< + SectionOutcome + > = loadRecent(context) + .then((result) => + result.ok + ? { value: result.data } + : { value: [], error: result.error.code }, + ) + .catch(() => ({ value: [], error: "INTERNAL" as const })); + const preferencesPromise: Promise> = + 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 + > = 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> { + 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> { + const { loadHousekeepingRecentWork } = await import( + "@/lib/housekeeping-recent-work" + ); + return loadHousekeepingRecentWork(context); +} + +export const operationsWorkspaceQuery = createOperationsWorkspaceQuery({ + registry, + loadInbox: loadHousekeepingInbox, + loadRecent, + loadPreferences, + loadWidgets: loadHousekeepingWidgets, +}); diff --git a/src/features/housekeeping/domains/operations/route-handlers.ts b/src/features/housekeeping/domains/operations/route-handlers.ts new file mode 100644 index 0000000000..fade8b007f --- /dev/null +++ b/src/features/housekeeping/domains/operations/route-handlers.ts @@ -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[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, + }), + ]); diff --git a/src/features/housekeeping/domains/operations/routes.test.ts b/src/features/housekeeping/domains/operations/routes.test.ts new file mode 100644 index 0000000000..140756a0d6 --- /dev/null +++ b/src/features/housekeeping/domains/operations/routes.test.ts @@ -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 }, + }); + }); +}); diff --git a/src/features/housekeeping/domains/operations/routes.ts b/src/features/housekeeping/domains/operations/routes.ts new file mode 100644 index 0000000000..38e6e7ffd3 --- /dev/null +++ b/src/features/housekeeping/domains/operations/routes.ts @@ -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[]; diff --git a/src/features/housekeeping/domains/operations/search.ts b/src/features/housekeeping/domains/operations/search.ts new file mode 100644 index 0000000000..47bfb1742c --- /dev/null +++ b/src/features/housekeeping/domains/operations/search.ts @@ -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[]; diff --git a/src/features/housekeeping/domains/operations/widgets.ts b/src/features/housekeeping/domains/operations/widgets.ts new file mode 100644 index 0000000000..6cbac45913 --- /dev/null +++ b/src/features/housekeeping/domains/operations/widgets.ts @@ -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; +} + +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, + ), + }; + }, +}); diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index cc3ca132f9..2d31dc5de1 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -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>([ + [ + "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>([ "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") diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts index d7f54b72d5..ed2d88a0a6 100644 --- a/src/features/housekeeping/foundation/preview-route-contract.test.ts +++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts @@ -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", diff --git a/src/features/housekeeping/foundation/registry.test.ts b/src/features/housekeeping/foundation/registry.test.ts index 23169b29f4..ad8e2d518c 100644 --- a/src/features/housekeeping/foundation/registry.test.ts +++ b/src/features/housekeeping/foundation/registry.test.ts @@ -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 }); } diff --git a/src/features/housekeeping/foundation/server-capability-context.test.ts b/src/features/housekeeping/foundation/server-capability-context.test.ts index deb1e32046..4e0777d68a 100644 --- a/src/features/housekeeping/foundation/server-capability-context.test.ts +++ b/src/features/housekeeping/foundation/server-capability-context.test.ts @@ -40,7 +40,10 @@ const { }; }); -vi.mock("react", () => ({ cache: wrapCache })); +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + 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", diff --git a/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx b/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx index 885e65a2ac..740a63b96a 100644 --- a/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx +++ b/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx @@ -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(

Deck body

, @@ -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", diff --git a/src/features/housekeeping/foundation/shell/housekeeping-shell.tsx b/src/features/housekeeping/foundation/shell/housekeeping-shell.tsx index 0edfda2db7..0599e769d0 100644 --- a/src/features/housekeeping/foundation/shell/housekeeping-shell.tsx +++ b/src/features/housekeeping/foundation/shell/housekeeping-shell.tsx @@ -54,9 +54,11 @@ export function HousekeepingShell({
- - {labels.preview} - + {surface === "preview" ? ( + + {labels.preview} + + ) : null}
diff --git a/src/features/housekeeping/route-handlers.test.ts b/src/features/housekeeping/route-handlers.test.ts index 0fedcbded2..4fd342e8bc 100644 --- a/src/features/housekeeping/route-handlers.test.ts +++ b/src/features/housekeeping/route-handlers.test.ts @@ -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, diff --git a/src/features/housekeeping/route-handlers.ts b/src/features/housekeeping/route-handlers.ts index 0fe16e7b12..fd0a59c1ff 100644 --- a/src/features/housekeeping/route-handlers.ts +++ b/src/features/housekeeping/route-handlers.ts @@ -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 >; + 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,