feat(housekeeping): personalize command deck
CI / check (pull_request) Successful in 36s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped

This commit is contained in:
Simo committed 2026-08-30 16:55:10 +02:00
1 parent 300ac0ef95
commit 5574e601bb
12 files changed
+1549 -1

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getContextMock, loadRecentMock, recordVisitMock } = vi.hoisted(() => ({
getContextMock: vi.fn(),
loadRecentMock: vi.fn(),
recordVisitMock: vi.fn(),
}));
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: getContextMock,
}));
vi.mock("@/lib/housekeeping-recent-work", () => ({
loadHousekeepingRecentWork: loadRecentMock,
recordHousekeepingRouteVisit: recordVisitMock,
}));
import {
executeHousekeepingRecent,
recordHousekeepingRouteVisitAction,
} from "./housekeeping-recent";
const context = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
};
describe("housekeeping recent actions", () => {
beforeEach(() => {
vi.clearAllMocks();
getContextMock.mockResolvedValue(context);
loadRecentMock.mockResolvedValue({
ok: true,
data: [],
correlationId: "recent-action",
});
recordVisitMock.mockResolvedValue({
ok: true,
data: { routeId: "people.users" },
correlationId: "visit-action",
});
});
it("binds load and visit recording to a fresh server capability context", async () => {
await expect(executeHousekeepingRecent()).resolves.toMatchObject({
ok: true,
});
await expect(
recordHousekeepingRouteVisitAction("people.users"),
).resolves.toMatchObject({ ok: true });
expect(loadRecentMock).toHaveBeenCalledWith(context);
expect(recordVisitMock).toHaveBeenCalledWith("people.users", context);
});
it("rejects a forged route identifier before resolving server context", async () => {
const result = await recordHousekeepingRouteVisitAction({
routeId: "people.users",
});
expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION" } });
expect(getContextMock).not.toHaveBeenCalled();
expect(recordVisitMock).not.toHaveBeenCalled();
});
it("maps unexpected boundary failures to typed internal results", async () => {
loadRecentMock.mockRejectedValueOnce(new Error("audit unavailable"));
const result = await executeHousekeepingRecent();
expect(result).toMatchObject({ ok: false, error: { code: "INTERNAL" } });
});
});
+49
View File
@@ -0,0 +1,49 @@
"use server";
import {
fail,
type HousekeepingResult,
mapUnknownError,
} from "@/features/housekeeping/foundation/contracts";
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
import type { HousekeepingRecentItem } from "@/features/housekeeping/foundation/recent/recent-work";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import {
loadHousekeepingRecentWork,
recordHousekeepingRouteVisit,
} from "@/lib/housekeeping-recent-work";
export async function executeHousekeepingRecent(): Promise<
HousekeepingResult<readonly HousekeepingRecentItem[]>
> {
try {
const context = await getHousekeepingCapabilityContext();
return await loadHousekeepingRecentWork(context);
} catch (error) {
return mapUnknownError(error);
}
}
export async function recordHousekeepingRouteVisitAction(
routeId: unknown,
): Promise<HousekeepingResult<HousekeepingRecentItem>> {
if (
typeof routeId !== "string" ||
!routeId.trim() ||
routeId !== routeId.trim() ||
routeId.length > 128
) {
return fail(
"VALIDATION",
"errors.housekeeping.recent.invalidRoute",
createCorrelationId(),
);
}
try {
const context = await getHousekeepingCapabilityContext();
return await recordHousekeepingRouteVisit(routeId, context);
} catch (error) {
return mapUnknownError(error);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,9 +1,20 @@
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import { HOUSEKEEPING_DOMAIN_IDS } from "../migration/types"; import { HOUSEKEEPING_DOMAIN_IDS } from "../migration/types";
import { satisfiesCapability } from "./capability-context";
import type { import type {
CapabilityRequirement, CapabilityRequirement,
HousekeepingCapabilityContext,
HousekeepingDomainManifest, HousekeepingDomainManifest,
HousekeepingErrorCode,
HousekeepingWidgetDefinition,
} from "./contracts"; } from "./contracts";
import { ok } from "./contracts";
import { reconcilePreferences } from "./preferences/reconcile";
import type { HousekeepingPreferences } from "./preferences/schema";
import {
type HousekeepingProvider,
runProvider,
} from "./providers/run-provider";
const approvedDomainIds = new Set<string>(HOUSEKEEPING_DOMAIN_IDS); const approvedDomainIds = new Set<string>(HOUSEKEEPING_DOMAIN_IDS);
const knownCapabilitySlugs = new Set<string>(Object.values(PERMS)); const knownCapabilitySlugs = new Set<string>(Object.values(PERMS));
@@ -17,6 +28,76 @@ export interface HousekeepingRegistry {
domains: readonly HousekeepingDomainManifest[]; domains: readonly HousekeepingDomainManifest[];
} }
export const WIDGET_POLICY = Object.freeze({ timeoutMs: 2_000 } as const);
export interface HousekeepingWidgetLoadResult {
readonly widgets: readonly { readonly id: string; readonly data: unknown }[];
readonly errors: readonly {
readonly widgetId: string;
readonly code: HousekeepingErrorCode;
}[];
}
function widgetProvider(
widget: HousekeepingWidgetDefinition,
): HousekeepingProvider<unknown> {
return {
id: widget.id,
capability: widget.capability,
async run(context, signal) {
const result = await widget.load(context, signal);
return result.ok ? ok([result.data], result.correlationId) : result;
},
};
}
export async function loadHousekeepingWidgets(
registry: HousekeepingRegistry,
preferences: HousekeepingPreferences,
context: HousekeepingCapabilityContext,
timeoutMs: number = WIDGET_POLICY.timeoutMs,
): Promise<HousekeepingWidgetLoadResult> {
const reconciled = reconcilePreferences(preferences, registry, context);
const enabledOptionalIds = new Set(reconciled.enabledOptionalWidgetIds);
const selected = registry.domains
.filter((domain) => satisfiesCapability(context, domain.capability))
.flatMap((domain) => domain.widgets)
.filter(
(widget) =>
satisfiesCapability(context, widget.capability) &&
(widget.kind === "mandatory" || enabledOptionalIds.has(widget.id)),
);
const selectedById = new Map(selected.map((widget) => [widget.id, widget]));
const orderedIds = reconciled.widgetOrder.filter((id) =>
selectedById.has(id),
);
for (const widget of selected) {
if (!orderedIds.includes(widget.id)) orderedIds.push(widget.id);
}
const ordered = orderedIds.flatMap((id) => {
const widget = selectedById.get(id);
return widget ? [widget] : [];
});
const outcomes = await Promise.all(
ordered.map((widget) =>
runProvider(widgetProvider(widget), context, timeoutMs),
),
);
return {
widgets: outcomes.flatMap((outcome) =>
outcome.error === undefined
? [{ id: outcome.providerId, data: outcome.items[0] }]
: [],
),
errors: outcomes.flatMap((outcome) =>
outcome.error === undefined
? []
: [{ widgetId: outcome.providerId, code: outcome.error }],
),
};
}
export function createHousekeepingRegistry( export function createHousekeepingRegistry(
manifests: readonly HousekeepingDomainManifest[], manifests: readonly HousekeepingDomainManifest[],
): HousekeepingRegistry { ): HousekeepingRegistry {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,47 @@
import Link from "@/components/link";
import type { HousekeepingSurface } from "../contracts";
import type { HousekeepingRecentItem } from "../recent/recent-work";
import { toHousekeepingHref } from "../routing/href";
export interface RecentWorkLabels {
readonly title: string;
readonly empty: string;
}
export function RecentWork({
items,
labels,
surface,
titles = {},
}: {
readonly items: readonly HousekeepingRecentItem[];
readonly labels: RecentWorkLabels;
readonly surface: HousekeepingSurface;
readonly titles?: Readonly<Record<string, string>>;
}) {
return (
<section aria-labelledby="housekeeping-recent-title" className="space-y-3">
<h2 id="housekeeping-recent-title" className="text-lg font-semibold">
{labels.title}
</h2>
{items.length === 0 ? <p>{labels.empty}</p> : null}
<ul className="space-y-2">
{items.map((item) => (
<li
key={item.routeId}
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3"
>
<Link href={toHousekeepingHref(item.canonicalHref, surface)}>
{titles[item.labelKey] ?? item.labelKey}
</Link>
<div className="text-xs text-[var(--admin-text-muted)]">
<span>{item.source}</span>
{" · "}
<time dateTime={item.occurredAt}>{item.occurredAt}</time>
</div>
</li>
))}
</ul>
</section>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+45
View File
@@ -0,0 +1,45 @@
import "server-only";
import { desc, eq } from "drizzle-orm";
import type {
HousekeepingCapabilityContext,
HousekeepingResult,
} from "@/features/housekeeping/foundation/contracts";
import type { HousekeepingRecentItem } from "@/features/housekeeping/foundation/recent/recent-work";
import { createHousekeepingRecentWorkService } from "@/features/housekeeping/foundation/recent/recent-work";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
import { AdminAuditLog, db } from "@/lib/db";
import { logAudit } from "@/lib/services/audit";
const recentWorkService = createHousekeepingRecentWorkService({
registry: createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS),
async loadRows(userId, limit) {
return db
.select({
action: AdminAuditLog.action,
target: AdminAuditLog.target,
domain: AdminAuditLog.domain,
outcome: AdminAuditLog.outcome,
createdAt: AdminAuditLog.createdAt,
})
.from(AdminAuditLog)
.where(eq(AdminAuditLog.userId, userId))
.orderBy(desc(AdminAuditLog.id))
.limit(limit);
},
writeAudit: logAudit,
});
export async function loadHousekeepingRecentWork(
context: HousekeepingCapabilityContext,
): Promise<HousekeepingResult<readonly HousekeepingRecentItem[]>> {
return recentWorkService.load(context);
}
export async function recordHousekeepingRouteVisit(
routeId: string,
context: HousekeepingCapabilityContext,
): Promise<HousekeepingResult<HousekeepingRecentItem>> {
return recentWorkService.recordVisit(routeId, context);
}