feat(housekeeping): compose operations workspace
This commit is contained in:
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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user