From 300ac0ef95e4b716df052307195137df210680c9 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 30 Aug 2026 16:26:20 +0200 Subject: [PATCH] feat(housekeeping): compose operational inbox --- src/actions/housekeeping-inbox.test.ts | 52 ++++ src/actions/housekeeping-inbox.ts | 25 ++ .../housekeeping/domains/system/inbox.ts | 275 ++++++++++++++++++ .../housekeeping/domains/system/manifest.ts | 9 +- .../housekeeping/domains/system/search.ts | 148 ++++++++++ .../domains/system/system-providers.test.ts | 185 ++++++++++++ .../housekeeping/domains/system/widgets.ts | 118 ++++++++ .../foundation-source-contract.test.ts | 28 +- .../foundation/inbox/inbox-service.test.ts | 221 ++++++++++++++ .../foundation/inbox/inbox-service.ts | 206 +++++++++++++ .../housekeeping/foundation/registry.test.ts | 15 +- .../shell/operational-inbox.test.tsx | 128 ++++++++ .../foundation/shell/operational-inbox.tsx | 191 ++++++++++++ 13 files changed, 1594 insertions(+), 7 deletions(-) create mode 100644 src/actions/housekeeping-inbox.test.ts create mode 100644 src/actions/housekeeping-inbox.ts create mode 100644 src/features/housekeeping/domains/system/inbox.ts create mode 100644 src/features/housekeeping/domains/system/search.ts create mode 100644 src/features/housekeeping/domains/system/system-providers.test.ts create mode 100644 src/features/housekeeping/domains/system/widgets.ts create mode 100644 src/features/housekeeping/foundation/inbox/inbox-service.test.ts create mode 100644 src/features/housekeeping/foundation/inbox/inbox-service.ts create mode 100644 src/features/housekeeping/foundation/shell/operational-inbox.test.tsx create mode 100644 src/features/housekeeping/foundation/shell/operational-inbox.tsx diff --git a/src/actions/housekeeping-inbox.test.ts b/src/actions/housekeeping-inbox.test.ts new file mode 100644 index 00000000..dd6f3fa9 --- /dev/null +++ b/src/actions/housekeeping-inbox.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { HousekeepingCapabilityContext } from "@/features/housekeeping/foundation/contracts"; +import { PERMS } from "@/lib/permission-slugs"; + +const { getContextMock, loadInboxMock } = vi.hoisted(() => ({ + getContextMock: vi.fn(), + loadInboxMock: vi.fn(), +})); + +vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({ + getHousekeepingCapabilityContext: getContextMock, +})); + +vi.mock("@/features/housekeeping/foundation/inbox/inbox-service", () => ({ + loadHousekeepingInbox: loadInboxMock, +})); + +import { executeHousekeepingInbox } from "./housekeeping-inbox"; + +const context: HousekeepingCapabilityContext = { + actor: { id: 42, username: "operator", rank: 7 }, + isSuperAdmin: false, + has: (slug) => slug === PERMS.USERS_VIEW, + hasAny: (...slugs) => slugs.includes(PERMS.USERS_VIEW), + hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_VIEW), +}; + +describe("housekeeping inbox action", () => { + beforeEach(() => { + getContextMock.mockReset().mockResolvedValue(context); + loadInboxMock.mockReset().mockResolvedValue({ + items: [], + errors: [], + correlationId: "inbox-action", + }); + }); + + it("binds inbox composition to a fresh server capability context", async () => { + const response = await executeHousekeepingInbox(); + expect(getContextMock).toHaveBeenCalledOnce(); + expect(loadInboxMock).toHaveBeenCalledWith(context); + expect(response.correlationId).toBe("inbox-action"); + }); + + it("returns a typed partial envelope when the boundary throws", async () => { + loadInboxMock.mockRejectedValueOnce(new Error("inbox unavailable")); + const response = await executeHousekeepingInbox(); + expect(response.items).toEqual([]); + expect(response.errors).toEqual([{ sourceId: "inbox", code: "INTERNAL" }]); + expect(response.correlationId).toEqual(expect.any(String)); + }); +}); diff --git a/src/actions/housekeeping-inbox.ts b/src/actions/housekeeping-inbox.ts new file mode 100644 index 00000000..085dd48a --- /dev/null +++ b/src/actions/housekeeping-inbox.ts @@ -0,0 +1,25 @@ +"use server"; + +import { createCorrelationId } from "@/features/housekeeping/foundation/correlation"; +import { + type HousekeepingInboxResponse, + loadHousekeepingInbox, +} from "@/features/housekeeping/foundation/inbox/inbox-service"; +import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context"; + +function failedInbox(): HousekeepingInboxResponse { + return { + items: [], + errors: [{ sourceId: "inbox", code: "INTERNAL" }], + correlationId: createCorrelationId(), + }; +} + +export async function executeHousekeepingInbox(): Promise { + try { + const context = await getHousekeepingCapabilityContext(); + return await loadHousekeepingInbox(context); + } catch { + return failedInbox(); + } +} diff --git a/src/features/housekeeping/domains/system/inbox.ts b/src/features/housekeeping/domains/system/inbox.ts new file mode 100644 index 00000000..fe58e45f --- /dev/null +++ b/src/features/housekeeping/domains/system/inbox.ts @@ -0,0 +1,275 @@ +import "server-only"; + +import { PERMS } from "@/lib/permission-slugs"; +import { authorizeHousekeeping } from "../../foundation/authorization"; +import { satisfiesCapability } from "../../foundation/capability-context"; +import { + anyCapability, + type CapabilityRequirement, + fail, + type HousekeepingCapabilityContext, + type HousekeepingInboxSource, + type HousekeepingWorkItem, + ok, +} from "../../foundation/contracts"; +import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href"; +import type { + SystemHealthSnapshot, + SystemObservationRow, +} from "./queries/observability"; +import { systemObservabilityQuery } from "./queries/observability"; +import type { SystemAlertRow } from "./queries/operations"; +import { systemOperationsQuery } from "./queries/operations"; + +export const SYSTEM_INBOX_SOURCE_IDS = [ + "system.alerts", + "system.emulator-errors", + "system.operational-anomalies", +] as const; + +export interface SystemInboxAdapters { + readonly alerts: ( + context: HousekeepingCapabilityContext, + signal: AbortSignal, + ) => Promise; + readonly errors: ( + context: HousekeepingCapabilityContext, + signal: AbortSignal, + ) => Promise; + readonly health: ( + context: HousekeepingCapabilityContext, + signal: AbortSignal, + ) => Promise; + readonly now?: () => Date; +} + +type SystemInboxLoader = ( + context: HousekeepingCapabilityContext, + signal: AbortSignal, +) => Promise; + +function createSource( + id: (typeof SYSTEM_INBOX_SOURCE_IDS)[number], + capability: CapabilityRequirement, + load: SystemInboxLoader, +): HousekeepingInboxSource { + return { + id, + owner: "system", + capability, + async getItems(context, signal) { + const authorization = authorizeHousekeeping(context, capability); + if (!authorization.ok) return authorization; + try { + const items = await load(context, signal); + return ok( + { + availability: "available" as const, + items: items + .filter( + (item) => + isSafeHousekeepingHref(item.href) && + satisfiesCapability(context, item.capability), + ) + .slice(0, 25), + }, + authorization.correlationId, + ); + } catch { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + authorization.correlationId, + ); + } + }, + }; +} + +function dateValue( + value: string | number | Date | null | undefined, +): Date | null { + if (value instanceof Date) { + return Number.isFinite(value.getTime()) ? value : null; + } + if (typeof value === "number") { + const millis = Math.abs(value) < 10_000_000_000 ? value * 1_000 : value; + const date = new Date(millis); + return Number.isFinite(date.getTime()) ? date : null; + } + if (typeof value !== "string" || value.trim().length === 0) return null; + if (/^\d+$/u.test(value.trim())) return dateValue(Number(value)); + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date : null; +} + +function temporal(date: Date, now: Date) { + const ageMs = Math.max(0, now.getTime() - date.getTime()); + return { + occurredAt: date.toISOString(), + ageMs, + freshness: ageMs > 86_400_000 ? ("stale" as const) : ("fresh" as const), + }; +} + +function alertPriority(severity: string) { + const normalized = severity.toLocaleLowerCase(); + if (["critical", "fatal", "error"].includes(normalized)) { + return { severity: "critical" as const, priority: "critical" as const }; + } + if (["warning", "warn"].includes(normalized)) { + return { severity: "warning" as const, priority: "high" as const }; + } + return { severity: "info" as const, priority: "normal" as const }; +} + +export function createSystemInboxSources( + adapters: SystemInboxAdapters, +): readonly HousekeepingInboxSource[] { + const now = adapters.now ?? (() => new Date()); + const alertsCapability = anyCapability(PERMS.NOTIFICATIONS_VIEW); + const devopsCapability = anyCapability(PERMS.DEVOPS_VIEW); + return [ + createSource("system.alerts", alertsCapability, async (context, signal) => { + if (signal.aborted) throw new Error("aborted System alerts"); + const current = now(); + return (await adapters.alerts(context, signal)).flatMap((alert) => { + if (alert.isRead) return []; + const date = dateValue(alert.createdAt); + if (!date) return []; + return [ + { + sourceId: "system.alerts", + itemId: String(alert.id), + deduplicationKey: `system.alert:${alert.id}`, + domain: "system" as const, + capability: alertsCapability, + ...alertPriority(alert.severity), + ...temporal(date, current), + state: "unread", + titleKey: "pages.housekeeping.items.systemAlert", + context: { type: alert.type, message: alert.message }, + href: "/ase/system/operations/alerts" as const, + actions: [], + }, + ]; + }); + }), + createSource( + "system.emulator-errors", + devopsCapability, + async (context, signal) => { + if (signal.aborted) throw new Error("aborted emulator errors"); + const current = now(); + return (await adapters.errors(context, signal)).flatMap((error) => { + const date = dateValue(error.timestamp); + if (!date) return []; + return [ + { + sourceId: "system.emulator-errors", + itemId: error.id, + deduplicationKey: `system.emulator-error:${error.id}`, + domain: "system" as const, + capability: devopsCapability, + severity: "critical" as const, + priority: "high" as const, + ...temporal(date, current), + state: "open", + titleKey: "pages.housekeeping.items.emulatorError", + context: { + title: error.primary, + detail: error.secondary, + }, + href: "/ase/system/observability/devops/errors" as const, + actions: [], + }, + ]; + }); + }, + ), + createSource( + "system.operational-anomalies", + devopsCapability, + async (context, signal) => { + if (signal.aborted) throw new Error("aborted operational health"); + const health = await adapters.health(context, signal); + const current = now(); + const anomalies = [ + { + id: "database", + active: !health.dbOk, + severity: "critical" as const, + priority: "critical" as const, + context: { latencyMs: health.dbLatencyMs }, + }, + { + id: "redis", + active: health.redisOk === false, + severity: "warning" as const, + priority: "high" as const, + context: {}, + }, + { + id: "emulator", + active: !health.emulatorOk, + severity: "critical" as const, + priority: "critical" as const, + context: { onlineUsers: health.onlineUsers }, + }, + ]; + return anomalies + .filter((anomaly) => anomaly.active) + .map((anomaly) => ({ + sourceId: "system.operational-anomalies", + itemId: anomaly.id, + deduplicationKey: `system.operational-anomaly:${anomaly.id}`, + domain: "system" as const, + capability: devopsCapability, + severity: anomaly.severity, + priority: anomaly.priority, + ageMs: 0, + state: "degraded", + occurredAt: current.toISOString(), + titleKey: `pages.housekeeping.items.${anomaly.id}Anomaly`, + context: anomaly.context, + href: "/ase/system/observability/devops" as const, + freshness: "fresh" as const, + actions: [], + })); + }, + ), + ]; +} + +export const SYSTEM_INBOX_SOURCES = createSystemInboxSources({ + async alerts(context, signal) { + if (signal.aborted) throw new Error("aborted System alerts"); + const result = await systemOperationsQuery.run(context, { + routeId: "system.operations.alerts", + }); + if (!result.ok || result.data.kind !== "alerts") { + throw new Error("System alerts unavailable"); + } + return result.data.alerts; + }, + async errors(context, signal) { + if (signal.aborted) throw new Error("aborted emulator errors"); + const result = await systemObservabilityQuery.run(context, { + routeId: "system.observability.devops-errors", + }); + if (!result.ok || result.data.kind !== "devops-errors") { + throw new Error("System emulator errors unavailable"); + } + return result.data.errors; + }, + async health(context, signal) { + if (signal.aborted) throw new Error("aborted operational health"); + const result = await systemObservabilityQuery.run(context, { + routeId: "system.observability.devops", + }); + if (!result.ok || result.data.kind !== "devops" || !result.data.health) { + throw new Error("System health unavailable"); + } + return result.data.health; + }, +}); diff --git a/src/features/housekeeping/domains/system/manifest.ts b/src/features/housekeeping/domains/system/manifest.ts index 410f00d1..4f06fd5f 100644 --- a/src/features/housekeeping/domains/system/manifest.ts +++ b/src/features/housekeeping/domains/system/manifest.ts @@ -3,7 +3,10 @@ import { anyCapability, type HousekeepingDomainManifest, } from "../../foundation/contracts"; +import { SYSTEM_INBOX_SOURCES } from "./inbox"; import { SYSTEM_ROUTES } from "./routes"; +import { SYSTEM_SEARCH_PROVIDERS } from "./search"; +import { SYSTEM_WIDGETS } from "./widgets"; export const systemManifest = { id: "system", @@ -23,7 +26,7 @@ export const systemManifest = { PERMS.NOTIFICATIONS_EDIT, ), routes: SYSTEM_ROUTES, - searchProviders: [], - inboxSources: [], - widgets: [], + searchProviders: SYSTEM_SEARCH_PROVIDERS, + inboxSources: SYSTEM_INBOX_SOURCES, + widgets: SYSTEM_WIDGETS, } satisfies HousekeepingDomainManifest; diff --git a/src/features/housekeeping/domains/system/search.ts b/src/features/housekeeping/domains/system/search.ts new file mode 100644 index 00000000..ae218c00 --- /dev/null +++ b/src/features/housekeeping/domains/system/search.ts @@ -0,0 +1,148 @@ +import "server-only"; + +import { PERMS } from "@/lib/permission-slugs"; +import { authorizeHousekeeping } from "../../foundation/authorization"; +import { + anyCapability, + type CapabilityRequirement, + fail, + type HousekeepingCapabilityContext, + type HousekeepingSearchProvider, + type HousekeepingSearchResult, + ok, +} from "../../foundation/contracts"; +import type { SystemObservationRow } from "./queries/observability"; +import { systemObservabilityQuery } from "./queries/observability"; +import type { SystemAlertRow } from "./queries/operations"; +import { systemOperationsQuery } from "./queries/operations"; + +export const SYSTEM_SEARCH_PROVIDER_IDS = [ + "system.alerts", + "system.emulator-errors", +] as const; + +export interface SystemSearchAdapters { + readonly alerts: ( + context: HousekeepingCapabilityContext, + term: string, + limit: number, + ) => Promise; + readonly errors: ( + context: HousekeepingCapabilityContext, + term: string, + limit: number, + ) => Promise; +} + +function boundedLimit(limit: number): number { + return Number.isFinite(limit) + ? Math.min(25, Math.max(1, Math.trunc(limit))) + : 25; +} + +function normalizedTerm(term: string): string { + return term.normalize("NFC").trim().slice(0, 128); +} + +function createProvider( + id: (typeof SYSTEM_SEARCH_PROVIDER_IDS)[number], + capability: CapabilityRequirement, + load: ( + context: HousekeepingCapabilityContext, + term: string, + limit: number, + ) => Promise, + map: (row: T) => HousekeepingSearchResult, +): HousekeepingSearchProvider { + return { + id, + owner: "system", + capability, + async search(context, input) { + const authorization = authorizeHousekeeping(context, capability); + if (!authorization.ok) return authorization; + const term = normalizedTerm(input.term); + const limit = boundedLimit(input.limit); + try { + const rows = await load(context, term, limit); + return ok(rows.slice(0, limit).map(map), authorization.correlationId); + } catch { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + authorization.correlationId, + ); + } + }, + }; +} + +export function createSystemSearchProviders( + adapters: SystemSearchAdapters, +): readonly HousekeepingSearchProvider[] { + return [ + createProvider( + "system.alerts", + anyCapability(PERMS.NOTIFICATIONS_VIEW), + adapters.alerts, + (row) => ({ + id: `alert-${row.id}`, + domain: "system", + type: "entity", + title: row.message, + description: `${row.severity} · ${row.type}`, + href: "/ase/system/operations/alerts", + capability: anyCapability(PERMS.NOTIFICATIONS_VIEW), + }), + ), + createProvider( + "system.emulator-errors", + anyCapability(PERMS.DEVOPS_VIEW), + adapters.errors, + (row) => ({ + id: `emulator-error-${row.id}`, + domain: "system", + type: "entity", + title: row.primary, + description: row.secondary, + href: "/ase/system/observability/devops/errors", + capability: anyCapability(PERMS.DEVOPS_VIEW), + }), + ), + ]; +} + +function includesTerm(term: string, ...values: readonly unknown[]): boolean { + const needle = term.toLocaleLowerCase(); + if (needle.length === 0) return true; + return values + .map((value) => String(value ?? "")) + .join(" ") + .toLocaleLowerCase() + .includes(needle); +} + +export const SYSTEM_SEARCH_PROVIDERS = createSystemSearchProviders({ + async alerts(context, term, limit) { + const result = await systemOperationsQuery.run(context, { + routeId: "system.operations.alerts", + }); + if (!result.ok || result.data.kind !== "alerts") { + throw new Error("System alerts unavailable"); + } + return result.data.alerts + .filter((row) => includesTerm(term, row.message, row.type, row.severity)) + .slice(0, limit); + }, + async errors(context, term, limit) { + const result = await systemObservabilityQuery.run(context, { + routeId: "system.observability.devops-errors", + }); + if (!result.ok || result.data.kind !== "devops-errors") { + throw new Error("System emulator errors unavailable"); + } + return result.data.errors + .filter((row) => includesTerm(term, row.primary, row.secondary)) + .slice(0, limit); + }, +}); diff --git a/src/features/housekeeping/domains/system/system-providers.test.ts b/src/features/housekeeping/domains/system/system-providers.test.ts new file mode 100644 index 00000000..81e5233c --- /dev/null +++ b/src/features/housekeeping/domains/system/system-providers.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingCapabilityContext } from "../../foundation/contracts"; +import { createSystemInboxSources, SYSTEM_INBOX_SOURCE_IDS } from "./inbox"; +import { + createSystemSearchProviders, + SYSTEM_SEARCH_PROVIDER_IDS, +} from "./search"; +import { createSystemWidgets, SYSTEM_WIDGET_IDS } from "./widgets"; + +function context(granted: readonly string[]): HousekeepingCapabilityContext { + const permissions = new Set(granted); + return { + actor: { id: 42, username: "operator", rank: 7 }, + isSuperAdmin: false, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), + }; +} + +describe("System providers", () => { + it("registers capability-filtered alert and emulator-error search", async () => { + const alerts = vi.fn(async () => + Array.from({ length: 30 }, (_, index) => ({ + id: index + 1, + severity: "warning", + type: "disk", + message: `Disk alert ${index + 1}`, + isRead: false, + createdAt: "2026-08-30T10:00:00.000Z", + })), + ); + const errors = vi.fn(async () => []); + const providers = createSystemSearchProviders({ alerts, errors }); + + expect(SYSTEM_SEARCH_PROVIDER_IDS).toEqual([ + "system.alerts", + "system.emulator-errors", + ]); + expect(providers.map((provider) => provider.id)).toEqual( + SYSTEM_SEARCH_PROVIDER_IDS, + ); + const result = await providers[0].search( + context([PERMS.NOTIFICATIONS_VIEW]), + { term: " disk ", limit: 999 }, + ); + expect(alerts).toHaveBeenCalledWith(expect.anything(), "disk", 25); + expect(result).toMatchObject({ ok: true }); + if (!result.ok) return; + expect(result.data).toHaveLength(25); + expect(result.data[0]).toMatchObject({ + id: "alert-1", + domain: "system", + type: "entity", + href: "/ase/system/operations/alerts", + }); + + const forbidden = await providers[1].search( + context([PERMS.NOTIFICATIONS_VIEW]), + { term: "error", limit: 25 }, + ); + expect(forbidden).toMatchObject({ + ok: false, + error: { code: "FORBIDDEN" }, + }); + expect(errors).not.toHaveBeenCalled(); + }); + + it("derives unread alerts, emulator errors, and health anomalies", async () => { + const sources = createSystemInboxSources({ + alerts: vi.fn(async () => [ + { + id: 1, + severity: "critical", + type: "database", + message: "Database connection lost", + isRead: false, + createdAt: "2026-08-30T10:00:00.000Z", + }, + { + id: 2, + severity: "info", + type: "recovered", + message: "Recovered", + isRead: true, + createdAt: "2026-08-30T10:01:00.000Z", + }, + ]), + errors: vi.fn(async () => [ + { + id: "9", + primary: "NullPointerException (1.0)", + secondary: "stacktrace", + timestamp: 1_788_091_200, + }, + ]), + health: vi.fn(async () => ({ + dbOk: false, + dbLatencyMs: 2_100, + redisOk: false, + emulatorOk: true, + onlineUsers: 12, + })), + now: () => new Date("2026-08-30T12:00:00.000Z"), + }); + + expect(SYSTEM_INBOX_SOURCE_IDS).toEqual([ + "system.alerts", + "system.emulator-errors", + "system.operational-anomalies", + ]); + expect(sources.map((source) => source.id)).toEqual(SYSTEM_INBOX_SOURCE_IDS); + const notificationContext = context([PERMS.NOTIFICATIONS_VIEW]); + const alertResult = await sources[0].getItems( + notificationContext, + new AbortController().signal, + ); + expect(alertResult).toMatchObject({ + ok: true, + data: { + items: [ + { + itemId: "1", + priority: "critical", + state: "unread", + }, + ], + }, + }); + + const devopsContext = context([PERMS.DEVOPS_VIEW]); + const errorResult = await sources[1].getItems( + devopsContext, + new AbortController().signal, + ); + const anomalyResult = await sources[2].getItems( + devopsContext, + new AbortController().signal, + ); + expect(errorResult).toMatchObject({ + ok: true, + data: { items: [{ itemId: "9", state: "open" }] }, + }); + expect(anomalyResult).toMatchObject({ + ok: true, + data: { + items: [ + { itemId: "database", state: "degraded" }, + { itemId: "redis", state: "degraded" }, + ], + }, + }); + }); + + it("registers mandatory health and optional alert widgets", async () => { + const widgets = createSystemWidgets({ + operationalHealth: vi.fn(async () => ({ + dbOk: true, + redisOk: true, + emulatorOk: true, + onlineUsers: 12, + emulatorErrors: 1, + })), + alertSummary: vi.fn(async () => ({ unread: 3, critical: 1 })), + }); + expect(SYSTEM_WIDGET_IDS).toEqual([ + "system.operational-health", + "system.alert-summary", + ]); + expect(widgets.map((widget) => widget.kind)).toEqual([ + "mandatory", + "optional", + ]); + expect( + await widgets[0].load( + context([PERMS.DEVOPS_VIEW]), + new AbortController().signal, + ), + ).toMatchObject({ + ok: true, + data: { dbOk: true, emulatorErrors: 1 }, + }); + }); +}); diff --git a/src/features/housekeeping/domains/system/widgets.ts b/src/features/housekeeping/domains/system/widgets.ts new file mode 100644 index 00000000..11f9ec4b --- /dev/null +++ b/src/features/housekeeping/domains/system/widgets.ts @@ -0,0 +1,118 @@ +import "server-only"; + +import { PERMS } from "@/lib/permission-slugs"; +import { authorizeHousekeeping } from "../../foundation/authorization"; +import { + anyCapability, + type CapabilityRequirement, + fail, + type HousekeepingCapabilityContext, + type HousekeepingWidgetDefinition, + ok, +} from "../../foundation/contracts"; +import { systemObservabilityQuery } from "./queries/observability"; +import { systemOperationsQuery } from "./queries/operations"; + +export const SYSTEM_WIDGET_IDS = [ + "system.operational-health", + "system.alert-summary", +] as const; + +type SystemWidgetData = Readonly>; +type SystemWidgetLoader = ( + context: HousekeepingCapabilityContext, + signal: AbortSignal, +) => Promise; + +export interface SystemWidgetAdapters { + readonly operationalHealth: SystemWidgetLoader; + readonly alertSummary: SystemWidgetLoader; +} + +function createWidget( + id: (typeof SYSTEM_WIDGET_IDS)[number], + kind: "mandatory" | "optional", + capability: CapabilityRequirement, + load: SystemWidgetLoader, +): HousekeepingWidgetDefinition { + return { + id, + owner: "system", + kind, + capability, + async load(context, signal) { + const authorization = authorizeHousekeeping(context, capability); + if (!authorization.ok) return authorization; + try { + if (signal.aborted) throw new Error(`aborted ${id}`); + return ok(await load(context, signal), authorization.correlationId); + } catch { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + authorization.correlationId, + ); + } + }, + }; +} + +export function createSystemWidgets( + adapters: SystemWidgetAdapters, +): readonly HousekeepingWidgetDefinition[] { + return [ + createWidget( + "system.operational-health", + "mandatory", + anyCapability(PERMS.DEVOPS_VIEW), + adapters.operationalHealth, + ), + createWidget( + "system.alert-summary", + "optional", + anyCapability(PERMS.NOTIFICATIONS_VIEW), + adapters.alertSummary, + ), + ]; +} + +export const SYSTEM_WIDGETS = createSystemWidgets({ + async operationalHealth(context, signal) { + if (signal.aborted) throw new Error("aborted operational health widget"); + const result = await systemObservabilityQuery.run(context, { + routeId: "system.observability.devops", + }); + if (!result.ok || result.data.kind !== "devops" || !result.data.health) { + throw new Error("System health unavailable"); + } + return { + dbOk: result.data.health.dbOk, + redisOk: result.data.health.redisOk, + emulatorOk: result.data.health.emulatorOk, + onlineUsers: result.data.health.onlineUsers, + emulatorErrors: result.data.partialDependencies.includes( + "emulator-errors", + ) + ? null + : result.data.errors.length, + }; + }, + async alertSummary(context, signal) { + if (signal.aborted) throw new Error("aborted alert summary widget"); + const result = await systemOperationsQuery.run(context, { + routeId: "system.operations.alerts", + }); + if (!result.ok || result.data.kind !== "alerts") { + throw new Error("System alerts unavailable"); + } + const unread = result.data.alerts.filter((alert) => !alert.isRead); + return { + unread: unread.length, + critical: unread.filter((alert) => + ["critical", "fatal", "error"].includes( + alert.severity.toLocaleLowerCase(), + ), + ).length, + }; + }, +}); diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index f7b990ef..cc3ca132 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -614,6 +614,27 @@ const approvedRuntimeImports = new Map>([ "src/features/housekeeping/domains/system/commands/system-commands.ts", new Set(["src/features/housekeeping/domains/system/services/mutations"]), ], + [ + "src/features/housekeeping/domains/system/search.ts", + new Set([ + "src/features/housekeeping/domains/system/queries/observability", + "src/features/housekeeping/domains/system/queries/operations", + ]), + ], + [ + "src/features/housekeeping/domains/system/inbox.ts", + new Set([ + "src/features/housekeeping/domains/system/queries/observability", + "src/features/housekeeping/domains/system/queries/operations", + ]), + ], + [ + "src/features/housekeeping/domains/system/widgets.ts", + new Set([ + "src/features/housekeeping/domains/system/queries/observability", + "src/features/housekeeping/domains/system/queries/operations", + ]), + ], [ "src/features/housekeeping/domains/system/pages/access.tsx", new Set(["src/features/housekeeping/domains/system/queries/access"]), @@ -703,7 +724,12 @@ const approvedRuntimeImports = new Map>([ ], [ "src/features/housekeeping/domains/system/manifest.ts", - new Set(["src/features/housekeeping/domains/system/routes"]), + new Set([ + "src/features/housekeeping/domains/system/inbox", + "src/features/housekeeping/domains/system/routes", + "src/features/housekeeping/domains/system/search", + "src/features/housekeeping/domains/system/widgets", + ]), ], [ "src/features/housekeeping/domains/system/route-handlers.ts", diff --git a/src/features/housekeeping/foundation/inbox/inbox-service.test.ts b/src/features/housekeeping/foundation/inbox/inbox-service.test.ts new file mode 100644 index 00000000..4d8a97a3 --- /dev/null +++ b/src/features/housekeeping/foundation/inbox/inbox-service.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import { + anyCapability, + fail, + type HousekeepingCapabilityContext, + type HousekeepingDomainManifest, + type HousekeepingInboxSource, + type HousekeepingWorkItem, + ok, +} from "../contracts"; +import type { HousekeepingRegistry } from "../registry"; +import { createHousekeepingInboxService, INBOX_POLICY } from "./inbox-service"; + +function context(granted: readonly string[]): HousekeepingCapabilityContext { + const permissions = new Set(granted); + return { + actor: { id: 42, username: "operator", rank: 7 }, + isSuperAdmin: false, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), + }; +} + +function item( + itemId: string, + options: Partial = {}, +): HousekeepingWorkItem { + return { + sourceId: "people.queue", + itemId, + deduplicationKey: `people.queue:${itemId}`, + domain: "people", + capability: anyCapability(PERMS.USERS_VIEW), + severity: "info", + priority: "normal", + ageMs: 1_000, + state: "open", + occurredAt: "2026-08-30T10:00:00.000Z", + titleKey: "pages.housekeeping.items.test", + href: "/ase/people/users", + freshness: "fresh", + actions: [], + ...options, + }; +} + +function source( + id: string, + permission: string, + getItems: HousekeepingInboxSource["getItems"], + owner: "people" | "content" = "people", +): HousekeepingInboxSource { + return { + id, + owner, + capability: anyCapability(permission), + getItems, + }; +} + +function manifest( + id: "people" | "content", + permission: string, + inboxSources: readonly HousekeepingInboxSource[], +): HousekeepingDomainManifest { + return { + id, + labelKey: `pages.housekeeping.domains.${id}.title`, + descriptionKey: `pages.housekeeping.domains.${id}.description`, + iconId: id === "people" ? "users" : "file-text", + canonicalHref: `/ase/${id}`, + capability: anyCapability(permission), + routes: [], + searchProviders: [], + inboxSources, + widgets: [], + }; +} + +function registry( + ...domains: readonly HousekeepingDomainManifest[] +): HousekeepingRegistry { + return { domains }; +} + +describe("Housekeeping inbox service", () => { + it("locks timeout, cap, and deduplication policy", () => { + expect(INBOX_POLICY).toEqual({ + timeoutMs: 2_000, + combinedLimit: 200, + dedupe: "sourceId:itemId", + }); + }); + + it("filters domain and source capabilities before invoking sources", async () => { + const permitted = vi.fn(async () => + ok({ availability: "available" as const, items: [] }, "permitted"), + ); + const forbiddenSource = vi.fn(async () => + ok({ availability: "available" as const, items: [] }, "source"), + ); + const forbiddenDomain = vi.fn(async () => + ok({ availability: "available" as const, items: [] }, "domain"), + ); + const service = createHousekeepingInboxService({ + registry: registry( + manifest("people", PERMS.USERS_VIEW, [ + source("people.ready", PERMS.USERS_VIEW, permitted), + source("people.bans", PERMS.BANS_VIEW, forbiddenSource), + ]), + manifest("content", PERMS.NEWS_VIEW, [ + source("content.users", PERMS.USERS_VIEW, forbiddenDomain, "content"), + ]), + ), + createCorrelationId: () => "inbox-capabilities", + }); + + await service(context([PERMS.USERS_VIEW])); + expect(permitted).toHaveBeenCalledOnce(); + expect(forbiddenSource).not.toHaveBeenCalled(); + expect(forbiddenDomain).not.toHaveBeenCalled(); + }); + + it("deduplicates by source/item and orders by priority then oldest age", async () => { + const items = [ + item("normal", { priority: "normal", ageMs: 900 }), + item("high-new", { priority: "high", ageMs: 100 }), + item("critical", { priority: "critical", ageMs: 10 }), + item("high-old", { priority: "high", ageMs: 1_000 }), + item("low", { priority: "low", ageMs: 5_000 }), + item("high-old", { priority: "high", ageMs: 999 }), + ]; + const service = createHousekeepingInboxService({ + registry: registry( + manifest("people", PERMS.USERS_VIEW, [ + source("people.queue", PERMS.USERS_VIEW, async () => + ok({ availability: "available", items }, "ordered"), + ), + ]), + ), + createCorrelationId: () => "inbox-order", + }); + + const response = await service(context([PERMS.USERS_VIEW])); + expect(response.items.map((entry) => entry.itemId)).toEqual([ + "critical", + "high-old", + "high-new", + "normal", + "low", + ]); + }); + + it("filters item capabilities before enforcing the 200-item cap", async () => { + const items = Array.from({ length: 230 }, (_, index) => + item(String(index), { + capability: anyCapability( + index < 20 ? PERMS.BANS_VIEW : PERMS.USERS_VIEW, + ), + }), + ); + const service = createHousekeepingInboxService({ + registry: registry( + manifest("people", PERMS.USERS_VIEW, [ + source("people.queue", PERMS.USERS_VIEW, async () => + ok({ availability: "available", items }, "capped"), + ), + ]), + ), + createCorrelationId: () => "inbox-cap", + }); + + const response = await service(context([PERMS.USERS_VIEW])); + expect(response.items).toHaveLength(200); + expect(response.items[0]?.itemId).toBe("20"); + expect(response.items.at(-1)?.itemId).toBe("219"); + }); + + it("returns successful items with stable timeout and source errors", async () => { + let timeoutSignal: AbortSignal | undefined; + const service = createHousekeepingInboxService({ + registry: registry( + manifest("people", PERMS.USERS_VIEW, [ + source("people.timeout", PERMS.USERS_VIEW, (_context, signal) => { + timeoutSignal = signal; + return new Promise(() => undefined); + }), + source("people.failed", PERMS.USERS_VIEW, async () => + fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + "failed", + ), + ), + source("people.ready", PERMS.USERS_VIEW, async () => + ok( + { + availability: "available", + items: [item("ready", { sourceId: "people.ready" })], + }, + "ready", + ), + ), + ]), + ), + createCorrelationId: () => "inbox-partial", + timeoutMs: 5, + }); + + const response = await service(context([PERMS.USERS_VIEW])); + expect(response.items.map((entry) => entry.itemId)).toEqual(["ready"]); + expect(response.errors).toEqual([ + { sourceId: "people.timeout", code: "TIMEOUT" }, + { sourceId: "people.failed", code: "DEPENDENCY_UNAVAILABLE" }, + ]); + expect(timeoutSignal?.aborted).toBe(true); + expect(response.correlationId).toBe("inbox-partial"); + }); +}); diff --git a/src/features/housekeeping/foundation/inbox/inbox-service.ts b/src/features/housekeeping/foundation/inbox/inbox-service.ts new file mode 100644 index 00000000..c40a0487 --- /dev/null +++ b/src/features/housekeeping/foundation/inbox/inbox-service.ts @@ -0,0 +1,206 @@ +import "server-only"; + +import { satisfiesCapability } from "../capability-context"; +import { + fail, + type HousekeepingCapabilityContext, + type HousekeepingErrorCode, + type HousekeepingInboxSource, + type HousekeepingWorkItem, + ok, +} from "../contracts"; +import { createCorrelationId } from "../correlation"; +import { isSafeHousekeepingHref } from "../housekeeping-href"; +import { + type HousekeepingProvider, + runProvider, +} from "../providers/run-provider"; +import { + createHousekeepingRegistry, + type HousekeepingRegistry, +} from "../registry"; + +export const INBOX_POLICY = Object.freeze({ + timeoutMs: 2_000, + combinedLimit: 200, + dedupe: "sourceId:itemId", +} as const); + +export interface HousekeepingInboxResponse { + readonly items: readonly HousekeepingWorkItem[]; + readonly errors: readonly { + readonly sourceId: string; + readonly code: HousekeepingErrorCode; + }[]; + readonly correlationId: string; +} + +export interface HousekeepingInboxServiceDependencies { + readonly registry: HousekeepingRegistry; + readonly createCorrelationId?: () => string; + readonly timeoutMs?: number; +} + +const priorityRank = { + critical: 0, + high: 1, + normal: 2, + low: 3, +} as const; + +const severities = new Set(["info", "warning", "critical"]); +const priorities = new Set(Object.keys(priorityRank)); +const freshnessValues = new Set(["fresh", "stale"]); + +function hasUsableCapability( + value: unknown, +): value is HousekeepingWorkItem["capability"] { + if (value === null || typeof value !== "object") return false; + const capability = value as { mode?: unknown; slugs?: unknown }; + return ( + (capability.mode === "any" || capability.mode === "all") && + Array.isArray(capability.slugs) && + capability.slugs.length > 0 && + capability.slugs.every((slug) => typeof slug === "string") + ); +} + +function isValidWorkItem( + item: HousekeepingWorkItem, + source: HousekeepingInboxSource, + context: HousekeepingCapabilityContext, +): boolean { + return ( + item !== null && + typeof item === "object" && + item.sourceId === source.id && + item.domain === source.owner && + typeof item.itemId === "string" && + item.itemId.trim().length > 0 && + typeof item.deduplicationKey === "string" && + item.deduplicationKey.trim().length > 0 && + severities.has(item.severity) && + priorities.has(item.priority) && + Number.isFinite(item.ageMs) && + item.ageMs >= 0 && + typeof item.state === "string" && + item.state.trim().length > 0 && + typeof item.occurredAt === "string" && + Number.isFinite(Date.parse(item.occurredAt)) && + typeof item.titleKey === "string" && + item.titleKey.trim().length > 0 && + isSafeHousekeepingHref(item.href) && + freshnessValues.has(item.freshness) && + Array.isArray(item.actions) && + hasUsableCapability(item.capability) && + satisfiesCapability(context, item.capability) + ); +} + +function isPermittedAction( + action: HousekeepingWorkItem["actions"][number], + context: HousekeepingCapabilityContext, +): boolean { + return ( + action !== null && + typeof action === "object" && + typeof action.id === "string" && + action.id.trim().length > 0 && + typeof action.labelKey === "string" && + action.labelKey.trim().length > 0 && + typeof action.commandId === "string" && + action.commandId.trim().length > 0 && + hasUsableCapability(action.capability) && + satisfiesCapability(context, action.capability) + ); +} + +function sourceProvider( + source: HousekeepingInboxSource, +): HousekeepingProvider { + return { + id: source.id, + capability: source.capability, + async run(context, signal) { + const result = await source.getItems(context, signal); + if (!result.ok) return result; + if (result.data.availability !== "available") { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + result.correlationId, + ); + } + return ok(result.data.items, result.correlationId); + }, + }; +} + +export function createHousekeepingInboxService({ + registry, + createCorrelationId: correlationIdFactory = createCorrelationId, + timeoutMs = INBOX_POLICY.timeoutMs, +}: HousekeepingInboxServiceDependencies) { + return async function loadInbox( + context: HousekeepingCapabilityContext, + ): Promise { + const sources = registry.domains.flatMap((domain) => { + if (!satisfiesCapability(context, domain.capability)) return []; + return domain.inboxSources.filter((source) => + satisfiesCapability(context, source.capability), + ); + }); + const outcomes = await Promise.all( + sources.map((source) => + runProvider(sourceProvider(source), context, timeoutMs), + ), + ); + const sourceById = new Map(sources.map((source) => [source.id, source])); + const seen = new Set(); + const items: HousekeepingWorkItem[] = []; + + for (const outcome of outcomes) { + const source = sourceById.get(outcome.providerId); + if (!source) continue; + for (const item of outcome.items) { + if (!isValidWorkItem(item, source, context)) continue; + const key = JSON.stringify([item.sourceId, item.itemId]); + if (seen.has(key)) continue; + seen.add(key); + items.push({ + ...item, + actions: item.actions.filter((action) => + isPermittedAction(action, context), + ), + }); + } + } + + items.sort((left, right) => { + const priority = + priorityRank[left.priority] - priorityRank[right.priority]; + if (priority !== 0) return priority; + return right.ageMs - left.ageMs; + }); + + return { + items: items.slice(0, INBOX_POLICY.combinedLimit), + errors: outcomes.flatMap((outcome) => + outcome.error + ? [{ sourceId: outcome.providerId, code: outcome.error }] + : [], + ), + correlationId: correlationIdFactory(), + }; + }; +} + +export async function loadHousekeepingInbox( + context: HousekeepingCapabilityContext, +): Promise { + const { HOUSEKEEPING_MANIFESTS } = await import("../../manifests"); + const service = createHousekeepingInboxService({ + registry: createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS), + }); + return service(context); +} diff --git a/src/features/housekeeping/foundation/registry.test.ts b/src/features/housekeeping/foundation/registry.test.ts index ecb21888..bd0d5d29 100644 --- a/src/features/housekeeping/foundation/registry.test.ts +++ b/src/features/housekeeping/foundation/registry.test.ts @@ -16,7 +16,10 @@ import { PEOPLE_INBOX_SOURCES } from "../domains/people/inbox"; import { PEOPLE_ROUTES } from "../domains/people/routes"; import { PEOPLE_SEARCH_PROVIDERS } from "../domains/people/search"; import { PEOPLE_WIDGETS } from "../domains/people/widgets"; +import { SYSTEM_INBOX_SOURCES } from "../domains/system/inbox"; import { SYSTEM_ROUTES } from "../domains/system/routes"; +import { SYSTEM_SEARCH_PROVIDERS } from "../domains/system/search"; +import { SYSTEM_WIDGETS } from "../domains/system/widgets"; import { HOUSEKEEPING_MANIFESTS } from "../manifests"; import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix"; import { @@ -392,7 +395,9 @@ describe("housekeeping registry", () => { ? ECONOMY_SEARCH_PROVIDERS : expected.id === "hotel" ? HOTEL_SEARCH_PROVIDERS - : [], + : expected.id === "system" + ? SYSTEM_SEARCH_PROVIDERS + : [], ); expect(actual.inboxSources).toEqual( expected.id === "people" @@ -403,7 +408,9 @@ describe("housekeeping registry", () => { ? ECONOMY_INBOX_SOURCES : expected.id === "hotel" ? HOTEL_INBOX_SOURCES - : [], + : expected.id === "system" + ? SYSTEM_INBOX_SOURCES + : [], ); expect(actual.widgets).toEqual( expected.id === "people" @@ -414,7 +421,9 @@ describe("housekeeping registry", () => { ? ECONOMY_WIDGETS : expected.id === "hotel" ? HOTEL_WIDGETS - : [], + : expected.id === "system" + ? SYSTEM_WIDGETS + : [], ); expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs }); } diff --git a/src/features/housekeeping/foundation/shell/operational-inbox.test.tsx b/src/features/housekeeping/foundation/shell/operational-inbox.test.tsx new file mode 100644 index 00000000..f6f83494 --- /dev/null +++ b/src/features/housekeeping/foundation/shell/operational-inbox.test.tsx @@ -0,0 +1,128 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { anyCapability, type HousekeepingWorkItem } from "../contracts"; +import { + filterOperationalInboxItems, + OperationalInbox, + projectOperationalInboxHref, +} from "./operational-inbox"; + +const labels = { + title: "Operational inbox", + domainFilter: "Filter by domain", + stateFilter: "Filter by state", + priorityFilter: "Filter by priority", + all: "All", + loading: "Loading operational work", + empty: "No operational work", + partial: "Some sources are unavailable", + open: "Open workflow", +}; + +function item( + itemId: string, + options: Partial = {}, +): HousekeepingWorkItem { + return { + sourceId: "people.queue", + itemId, + deduplicationKey: `people.queue:${itemId}`, + domain: "people", + capability: anyCapability("admin.users.view"), + severity: "warning", + priority: "high", + ageMs: 1_000, + state: "open", + occurredAt: "2026-08-30T10:00:00.000Z", + titleKey: `Item ${itemId}`, + href: "/ase/people/users", + freshness: "fresh", + actions: [], + ...options, + }; +} + +describe("OperationalInbox", () => { + it("filters deterministically by domain, state, and priority", () => { + const items = [ + item("people-open"), + item("content-draft", { + domain: "content", + state: "draft", + priority: "normal", + href: "/ase/content/editorial/articles", + }), + ]; + expect( + filterOperationalInboxItems(items, { + domain: "people", + state: "open", + priority: "high", + }).map((entry) => entry.itemId), + ).toEqual(["people-open"]); + expect( + filterOperationalInboxItems(items, { + domain: "all", + state: "draft", + priority: "all", + }).map((entry) => entry.itemId), + ).toEqual(["content-draft"]); + }); + + it("renders accessible filters, partial errors, and preview links", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('aria-label="Filter by domain"'); + expect(html).toContain('aria-label="Filter by state"'); + expect(html).toContain('aria-label="Filter by priority"'); + expect(html).toContain("Some sources are unavailable"); + expect(html).toContain("content.publication"); + expect(html).toContain('href="/ase-next/people/users"'); + expect(html).not.toContain("Dismiss"); + expect(html).not.toContain("Assign"); + }); + + it("renders explicit loading and empty states", () => { + const response = { + items: [], + errors: [], + correlationId: "inbox-empty", + } as const; + const loading = renderToStaticMarkup( + , + ); + const empty = renderToStaticMarkup( + , + ); + expect(loading).toContain('role="status"'); + expect(loading).toContain("Loading operational work"); + expect(empty).toContain("No operational work"); + }); + + it("projects canonical hrefs without changing production destinations", () => { + expect( + projectOperationalInboxHref("/ase/system/operations/alerts", "preview"), + ).toBe("/ase-next/system/operations/alerts"); + expect( + projectOperationalInboxHref("/ase/system/operations/alerts", "canonical"), + ).toBe("/ase/system/operations/alerts"); + }); +}); diff --git a/src/features/housekeeping/foundation/shell/operational-inbox.tsx b/src/features/housekeeping/foundation/shell/operational-inbox.tsx new file mode 100644 index 00000000..6a252ffe --- /dev/null +++ b/src/features/housekeeping/foundation/shell/operational-inbox.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "@/components/link"; +import type { + CanonicalHousekeepingHref, + HousekeepingSurface, + HousekeepingWorkItem, +} from "../contracts"; +import type { HousekeepingInboxResponse } from "../inbox/inbox-service"; +import { toHousekeepingHref } from "../routing/href"; + +export interface OperationalInboxLabels { + readonly title: string; + readonly domainFilter: string; + readonly stateFilter: string; + readonly priorityFilter: string; + readonly all: string; + readonly loading: string; + readonly empty: string; + readonly partial: string; + readonly open: string; +} + +export interface OperationalInboxFilters { + readonly domain: string; + readonly state: string; + readonly priority: string; +} + +export function filterOperationalInboxItems( + items: readonly HousekeepingWorkItem[], + filters: OperationalInboxFilters, +): readonly HousekeepingWorkItem[] { + return items.filter( + (item) => + (filters.domain === "all" || item.domain === filters.domain) && + (filters.state === "all" || item.state === filters.state) && + (filters.priority === "all" || item.priority === filters.priority), + ); +} + +export function projectOperationalInboxHref( + href: CanonicalHousekeepingHref, + surface: HousekeepingSurface, +) { + return toHousekeepingHref(href, surface); +} + +function unique(values: readonly string[]): readonly string[] { + return [...new Set(values)].sort((left, right) => left.localeCompare(right)); +} + +export function OperationalInbox({ + response, + labels, + surface, + loading = false, + titles = {}, +}: { + readonly response: HousekeepingInboxResponse; + readonly labels: OperationalInboxLabels; + readonly surface: HousekeepingSurface; + readonly loading?: boolean; + readonly titles?: Readonly>; +}) { + const [domain, setDomain] = useState("all"); + const [state, setState] = useState("all"); + const [priority, setPriority] = useState("all"); + const domains = useMemo( + () => unique(response.items.map((item) => item.domain)), + [response.items], + ); + const states = useMemo( + () => unique(response.items.map((item) => item.state)), + [response.items], + ); + const priorities = useMemo( + () => unique(response.items.map((item) => item.priority)), + [response.items], + ); + const items = useMemo( + () => + filterOperationalInboxItems(response.items, { + domain, + state, + priority, + }), + [domain, priority, response.items, state], + ); + + return ( +
+

+ {labels.title} +

+ {loading ?

{labels.loading}

: null} + {!loading ? ( + <> +
+ + + +
+ + {response.errors.length > 0 ? ( +
+

{labels.partial}

+
    + {response.errors.map((error) => ( +
  • + {error.sourceId}: {error.code} +
  • + ))} +
+
+ ) : null} + + {items.length === 0 ?

{labels.empty}

: null} +
    + {items.map((item) => ( +
  • +
    +
    +

    + {titles[item.titleKey] ?? item.titleKey} +

    +

    + {item.domain} · {item.state} · {item.priority} +

    + +
    + + {labels.open} + +
    +
  • + ))} +
+ + ) : null} +
+ ); +}