feat(housekeeping): compose operational inbox
This commit is contained in:
1 parent
bcb0ca977f
commit
300ac0ef95
13 files changed
+1594
-7
No files matched your search
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<HousekeepingInboxResponse> {
|
||||||
|
try {
|
||||||
|
const context = await getHousekeepingCapabilityContext();
|
||||||
|
return await loadHousekeepingInbox(context);
|
||||||
|
} catch {
|
||||||
|
return failedInbox();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 SystemAlertRow[]>;
|
||||||
|
readonly errors: (
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
signal: AbortSignal,
|
||||||
|
) => Promise<readonly SystemObservationRow[]>;
|
||||||
|
readonly health: (
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
signal: AbortSignal,
|
||||||
|
) => Promise<SystemHealthSnapshot>;
|
||||||
|
readonly now?: () => Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemInboxLoader = (
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
signal: AbortSignal,
|
||||||
|
) => Promise<readonly HousekeepingWorkItem[]>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,7 +3,10 @@ import {
|
|||||||
anyCapability,
|
anyCapability,
|
||||||
type HousekeepingDomainManifest,
|
type HousekeepingDomainManifest,
|
||||||
} from "../../foundation/contracts";
|
} from "../../foundation/contracts";
|
||||||
|
import { SYSTEM_INBOX_SOURCES } from "./inbox";
|
||||||
import { SYSTEM_ROUTES } from "./routes";
|
import { SYSTEM_ROUTES } from "./routes";
|
||||||
|
import { SYSTEM_SEARCH_PROVIDERS } from "./search";
|
||||||
|
import { SYSTEM_WIDGETS } from "./widgets";
|
||||||
|
|
||||||
export const systemManifest = {
|
export const systemManifest = {
|
||||||
id: "system",
|
id: "system",
|
||||||
@@ -23,7 +26,7 @@ export const systemManifest = {
|
|||||||
PERMS.NOTIFICATIONS_EDIT,
|
PERMS.NOTIFICATIONS_EDIT,
|
||||||
),
|
),
|
||||||
routes: SYSTEM_ROUTES,
|
routes: SYSTEM_ROUTES,
|
||||||
searchProviders: [],
|
searchProviders: SYSTEM_SEARCH_PROVIDERS,
|
||||||
inboxSources: [],
|
inboxSources: SYSTEM_INBOX_SOURCES,
|
||||||
widgets: [],
|
widgets: SYSTEM_WIDGETS,
|
||||||
} satisfies HousekeepingDomainManifest;
|
} satisfies HousekeepingDomainManifest;
|
||||||
@@ -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 SystemAlertRow[]>;
|
||||||
|
readonly errors: (
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
term: string,
|
||||||
|
limit: number,
|
||||||
|
) => Promise<readonly SystemObservationRow[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T>(
|
||||||
|
id: (typeof SYSTEM_SEARCH_PROVIDER_IDS)[number],
|
||||||
|
capability: CapabilityRequirement,
|
||||||
|
load: (
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
term: string,
|
||||||
|
limit: number,
|
||||||
|
) => Promise<readonly T[]>,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Record<string, boolean | number | null>>;
|
||||||
|
type SystemWidgetLoader = (
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
signal: AbortSignal,
|
||||||
|
) => Promise<SystemWidgetData>;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -614,6 +614,27 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
|||||||
"src/features/housekeeping/domains/system/commands/system-commands.ts",
|
"src/features/housekeeping/domains/system/commands/system-commands.ts",
|
||||||
new Set(["src/features/housekeeping/domains/system/services/mutations"]),
|
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",
|
"src/features/housekeeping/domains/system/pages/access.tsx",
|
||||||
new Set(["src/features/housekeeping/domains/system/queries/access"]),
|
new Set(["src/features/housekeeping/domains/system/queries/access"]),
|
||||||
@@ -703,7 +724,12 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
"src/features/housekeeping/domains/system/manifest.ts",
|
"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",
|
"src/features/housekeeping/domains/system/route-handlers.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> = {},
|
||||||
|
): 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<HousekeepingWorkItem> {
|
||||||
|
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<HousekeepingInboxResponse> {
|
||||||
|
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<string>();
|
||||||
|
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<HousekeepingInboxResponse> {
|
||||||
|
const { HOUSEKEEPING_MANIFESTS } = await import("../../manifests");
|
||||||
|
const service = createHousekeepingInboxService({
|
||||||
|
registry: createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS),
|
||||||
|
});
|
||||||
|
return service(context);
|
||||||
|
}
|
||||||
@@ -16,7 +16,10 @@ import { PEOPLE_INBOX_SOURCES } from "../domains/people/inbox";
|
|||||||
import { PEOPLE_ROUTES } from "../domains/people/routes";
|
import { PEOPLE_ROUTES } from "../domains/people/routes";
|
||||||
import { PEOPLE_SEARCH_PROVIDERS } from "../domains/people/search";
|
import { PEOPLE_SEARCH_PROVIDERS } from "../domains/people/search";
|
||||||
import { PEOPLE_WIDGETS } from "../domains/people/widgets";
|
import { PEOPLE_WIDGETS } from "../domains/people/widgets";
|
||||||
|
import { SYSTEM_INBOX_SOURCES } from "../domains/system/inbox";
|
||||||
import { SYSTEM_ROUTES } from "../domains/system/routes";
|
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_MANIFESTS } from "../manifests";
|
||||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
|
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
|
||||||
import {
|
import {
|
||||||
@@ -392,7 +395,9 @@ describe("housekeeping registry", () => {
|
|||||||
? ECONOMY_SEARCH_PROVIDERS
|
? ECONOMY_SEARCH_PROVIDERS
|
||||||
: expected.id === "hotel"
|
: expected.id === "hotel"
|
||||||
? HOTEL_SEARCH_PROVIDERS
|
? HOTEL_SEARCH_PROVIDERS
|
||||||
: [],
|
: expected.id === "system"
|
||||||
|
? SYSTEM_SEARCH_PROVIDERS
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
expect(actual.inboxSources).toEqual(
|
expect(actual.inboxSources).toEqual(
|
||||||
expected.id === "people"
|
expected.id === "people"
|
||||||
@@ -403,7 +408,9 @@ describe("housekeeping registry", () => {
|
|||||||
? ECONOMY_INBOX_SOURCES
|
? ECONOMY_INBOX_SOURCES
|
||||||
: expected.id === "hotel"
|
: expected.id === "hotel"
|
||||||
? HOTEL_INBOX_SOURCES
|
? HOTEL_INBOX_SOURCES
|
||||||
: [],
|
: expected.id === "system"
|
||||||
|
? SYSTEM_INBOX_SOURCES
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
expect(actual.widgets).toEqual(
|
expect(actual.widgets).toEqual(
|
||||||
expected.id === "people"
|
expected.id === "people"
|
||||||
@@ -414,7 +421,9 @@ describe("housekeeping registry", () => {
|
|||||||
? ECONOMY_WIDGETS
|
? ECONOMY_WIDGETS
|
||||||
: expected.id === "hotel"
|
: expected.id === "hotel"
|
||||||
? HOTEL_WIDGETS
|
? HOTEL_WIDGETS
|
||||||
: [],
|
: expected.id === "system"
|
||||||
|
? SYSTEM_WIDGETS
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs });
|
expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> = {},
|
||||||
|
): 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(
|
||||||
|
<OperationalInbox
|
||||||
|
labels={labels}
|
||||||
|
surface="preview"
|
||||||
|
response={{
|
||||||
|
items: [item("ticket-1")],
|
||||||
|
errors: [{ sourceId: "content.publication", code: "TIMEOUT" }],
|
||||||
|
correlationId: "inbox-ui",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<OperationalInbox
|
||||||
|
labels={labels}
|
||||||
|
surface="preview"
|
||||||
|
response={response}
|
||||||
|
loading
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const empty = renderToStaticMarkup(
|
||||||
|
<OperationalInbox
|
||||||
|
labels={labels}
|
||||||
|
surface="preview"
|
||||||
|
response={response}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Record<string, string>>;
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<section aria-labelledby="operational-inbox-title" className="space-y-4">
|
||||||
|
<h2 id="operational-inbox-title" className="text-lg font-semibold">
|
||||||
|
{labels.title}
|
||||||
|
</h2>
|
||||||
|
{loading ? <p role="status">{labels.loading}</p> : null}
|
||||||
|
{!loading ? (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<select
|
||||||
|
aria-label={labels.domainFilter}
|
||||||
|
value={domain}
|
||||||
|
onChange={(event) => setDomain(event.target.value)}
|
||||||
|
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2"
|
||||||
|
>
|
||||||
|
<option value="all">{labels.all}</option>
|
||||||
|
{domains.map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label={labels.stateFilter}
|
||||||
|
value={state}
|
||||||
|
onChange={(event) => setState(event.target.value)}
|
||||||
|
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2"
|
||||||
|
>
|
||||||
|
<option value="all">{labels.all}</option>
|
||||||
|
{states.map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label={labels.priorityFilter}
|
||||||
|
value={priority}
|
||||||
|
onChange={(event) => setPriority(event.target.value)}
|
||||||
|
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2"
|
||||||
|
>
|
||||||
|
<option value="all">{labels.all}</option>
|
||||||
|
{priorities.map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{response.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)]">{labels.partial}</p>
|
||||||
|
<ul className="mt-2 list-disc pl-5 text-sm">
|
||||||
|
{response.errors.map((error) => (
|
||||||
|
<li key={error.sourceId}>
|
||||||
|
{error.sourceId}: {error.code}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{items.length === 0 ? <p>{labels.empty}</p> : null}
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li
|
||||||
|
key={`${item.sourceId}:${item.itemId}`}
|
||||||
|
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{titles[item.titleKey] ?? item.titleKey}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||||
|
{item.domain} · {item.state} · {item.priority}
|
||||||
|
</p>
|
||||||
|
<time dateTime={item.occurredAt} className="text-xs">
|
||||||
|
{item.occurredAt}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={projectOperationalInboxHref(item.href, surface)}
|
||||||
|
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{labels.open}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user