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
@@ -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,
|
||||
),
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user