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);
}
}
@@ -0,0 +1,167 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingCapabilityContext,
} from "../contracts";
import type { HousekeepingRegistry } from "../registry";
import {
createHousekeepingRecentWorkService,
RECENT_WORK_POLICY,
} from "./recent-work";
const usersView = anyCapability(PERMS.USERS_VIEW);
const bansView = anyCapability(PERMS.BANS_VIEW);
function registry(routeCount = 2): HousekeepingRegistry {
return {
domains: [
{
id: "people",
labelKey: "people",
descriptionKey: "people.description",
iconId: "users",
canonicalHref: "/ase/people",
capability: usersView,
routes: Array.from({ length: routeCount }, (_, index) => ({
id: index === 0 ? "people.users" : `people.route-${index}`,
labelKey:
index === 0 ? "routes.people.users" : `routes.people.${index}`,
href:
index === 0
? "/ase/people/users"
: (`/ase/people/route-${index}` as const),
capability: index === 1 ? bansView : usersView,
})),
searchProviders: [],
inboxSources: [],
widgets: [],
},
],
};
}
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 recent work", () => {
it("derives newest authorized route visits and mutation audits, then deduplicates by route", async () => {
const loadRows = vi.fn().mockResolvedValue([
{
action: "housekeeping.route.visit",
target: "people.users",
domain: "people",
outcome: "success",
createdAt: "2026-08-30T10:01:00.000Z",
},
{
action: "people.user.update",
target: "People",
domain: "people",
outcome: "success",
createdAt: "2026-08-30T10:03:00.000Z",
},
{
action: "housekeeping.route.visit",
target: "people.route-1",
domain: "people",
outcome: "success",
createdAt: "2026-08-30T10:04:00.000Z",
},
{
action: "people.user.delete",
target: "People",
domain: "people",
outcome: "failure",
createdAt: "2026-08-30T10:05:00.000Z",
},
{
action: "people.unknown.update",
target: "People",
domain: "people",
outcome: "success",
createdAt: "2026-08-30T10:06:00.000Z",
},
]);
const service = createHousekeepingRecentWorkService({
registry: registry(),
loadRows,
writeAudit: vi.fn(),
createCorrelationId: () => "recent-1",
});
const result = await service.load(context);
expect(loadRows).toHaveBeenCalledWith(42, RECENT_WORK_POLICY.scanLimit);
expect(result).toEqual({
ok: true,
data: [
{
routeId: "people.users",
canonicalHref: "/ase/people/users",
labelKey: "routes.people.users",
occurredAt: "2026-08-30T10:03:00.000Z",
source: "audit",
},
],
correlationId: "recent-1",
});
});
it("caps authorized unique recent routes at twelve", async () => {
const rows = Array.from({ length: 14 }, (_, index) => ({
action: "housekeeping.route.visit",
target: index === 0 ? "people.users" : `people.route-${index + 1}`,
domain: "people",
outcome: "success",
createdAt: new Date(Date.UTC(2026, 7, 30, 12, index)).toISOString(),
}));
const service = createHousekeepingRecentWorkService({
registry: registry(16),
loadRows: vi.fn().mockResolvedValue(rows),
writeAudit: vi.fn(),
});
const result = await service.load(context);
expect(result.ok && result.data).toHaveLength(RECENT_WORK_POLICY.limit);
expect(result.ok && result.data[0]?.occurredAt).toBe(
"2026-08-30T12:13:00.000Z",
);
});
it("records only an authorized canonical route visit for the server actor", async () => {
const writeAudit = vi.fn().mockResolvedValue(undefined);
const service = createHousekeepingRecentWorkService({
registry: registry(),
loadRows: vi.fn(),
writeAudit,
createCorrelationId: () => "visit-1",
});
const allowed = await service.recordVisit("people.users", context);
const denied = await service.recordVisit("people.route-1", context);
expect(allowed).toMatchObject({
ok: true,
data: { routeId: "people.users", canonicalHref: "/ase/people/users" },
});
expect(writeAudit).toHaveBeenCalledOnce();
expect(writeAudit).toHaveBeenCalledWith(
expect.objectContaining({
userId: 42,
action: "housekeeping.route.visit",
target: "people.users",
domain: "people",
outcome: "success",
correlationId: "visit-1",
}),
);
expect(denied).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
});
});
@@ -0,0 +1,256 @@
import "server-only";
import type { AuditEntry } from "@/lib/services/audit";
import { satisfiesCapability } from "../capability-context";
import {
type CanonicalHousekeepingHref,
fail,
type HousekeepingCapabilityContext,
type HousekeepingDomainManifest,
type HousekeepingResult,
ok,
} from "../contracts";
import { createCorrelationId } from "../correlation";
import type { HousekeepingRegistry } from "../registry";
export const RECENT_WORK_POLICY = Object.freeze({
limit: 12,
scanLimit: 200,
dedupe: "routeId",
} as const);
export interface HousekeepingRecentItem {
readonly routeId: string;
readonly canonicalHref: CanonicalHousekeepingHref;
readonly labelKey: string;
readonly occurredAt: string;
readonly source: "route-visit" | "audit";
}
export interface HousekeepingRecentAuditRow {
readonly action: string;
readonly target: string;
readonly domain: string | null;
readonly outcome: string | null;
readonly createdAt: string;
}
interface RegisteredRoute {
readonly domain: HousekeepingDomainManifest;
readonly route: HousekeepingDomainManifest["routes"][number];
}
export interface HousekeepingRecentWorkDependencies {
readonly registry: HousekeepingRegistry;
readonly loadRows: (
userId: number,
limit: number,
) => Promise<readonly HousekeepingRecentAuditRow[]>;
readonly writeAudit: (entry: AuditEntry) => Promise<void>;
readonly createCorrelationId?: () => string;
}
const completedOutcomes = new Set(["success", "partial"]);
const genericActionTokens = new Set([
"add",
"apply",
"change",
"create",
"delete",
"execute",
"remove",
"runtime",
"save",
"set",
"toggle",
"update",
]);
function registeredRoutes(
registry: HousekeepingRegistry,
): readonly RegisteredRoute[] {
return registry.domains.flatMap((domain) =>
domain.routes.map((route) => ({ domain, route })),
);
}
function normalizedTokens(value: string): readonly string[] {
return value
.toLocaleLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean)
.map((token) => {
if (token.endsWith("ies") && token.length > 3) {
return `${token.slice(0, -3)}y`;
}
if (token.endsWith("s") && token.length > 3) return token.slice(0, -1);
return token;
})
.filter((token) => !genericActionTokens.has(token));
}
function authorized(
entry: RegisteredRoute,
context: HousekeepingCapabilityContext,
): boolean {
return (
satisfiesCapability(context, entry.domain.capability) &&
satisfiesCapability(context, entry.route.capability)
);
}
function routeForAudit(
row: HousekeepingRecentAuditRow,
routes: readonly RegisteredRoute[],
context: HousekeepingCapabilityContext,
): RegisteredRoute | undefined {
const domainRoutes = routes.filter(
(entry) => entry.domain.id === row.domain && authorized(entry, context),
);
const exact = domainRoutes.find((entry) => entry.route.id === row.target);
if (exact) return exact;
const actionTokens = new Set(
normalizedTokens(row.action).filter((token) => token !== row.domain),
);
let best: { entry: RegisteredRoute; score: number } | undefined;
for (const entry of domainRoutes) {
const score = normalizedTokens(entry.route.id)
.filter((token) => token !== row.domain)
.reduce((total, token) => total + (actionTokens.has(token) ? 1 : 0), 0);
if (score > 0 && (!best || score > best.score)) best = { entry, score };
}
return best?.entry;
}
function recentItem(
row: HousekeepingRecentAuditRow,
route: RegisteredRoute,
): HousekeepingRecentItem {
return {
routeId: route.route.id,
canonicalHref: route.route.href,
labelKey: route.route.labelKey,
occurredAt: row.createdAt,
source: row.action === "housekeeping.route.visit" ? "route-visit" : "audit",
};
}
export function deriveHousekeepingRecentWork(
rows: readonly HousekeepingRecentAuditRow[],
registry: HousekeepingRegistry,
context: HousekeepingCapabilityContext,
): readonly HousekeepingRecentItem[] {
const routes = registeredRoutes(registry);
const sorted = [...rows].sort(
(left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt),
);
const seen = new Set<string>();
const items: HousekeepingRecentItem[] = [];
for (const row of sorted) {
if (!completedOutcomes.has(row.outcome ?? "")) continue;
if (!Number.isFinite(Date.parse(row.createdAt))) continue;
const route =
row.action === "housekeeping.route.visit"
? routes.find(
(entry) =>
entry.route.id === row.target && authorized(entry, context),
)
: routeForAudit(row, routes, context);
if (!route || seen.has(route.route.id)) continue;
seen.add(route.route.id);
items.push(recentItem(row, route));
if (items.length === RECENT_WORK_POLICY.limit) break;
}
return items;
}
export function createHousekeepingRecentWorkService({
registry,
loadRows,
writeAudit,
createCorrelationId: correlationIdFactory = createCorrelationId,
}: HousekeepingRecentWorkDependencies) {
return {
async load(
context: HousekeepingCapabilityContext,
): Promise<HousekeepingResult<readonly HousekeepingRecentItem[]>> {
const correlationId = correlationIdFactory();
try {
const rows = await loadRows(
context.actor.id,
RECENT_WORK_POLICY.scanLimit,
);
return ok(
deriveHousekeepingRecentWork(rows, registry, context),
correlationId,
);
} catch {
return fail(
"INTERNAL",
"errors.housekeeping.recent.read",
correlationId,
);
}
},
async recordVisit(
routeId: string,
context: HousekeepingCapabilityContext,
): Promise<HousekeepingResult<HousekeepingRecentItem>> {
const correlationId = correlationIdFactory();
const entry = registeredRoutes(registry).find(
(candidate) => candidate.route.id === routeId,
);
if (!entry) {
return fail(
"NOT_FOUND",
"errors.housekeeping.recent.routeNotFound",
correlationId,
);
}
if (!authorized(entry, context)) {
return fail(
"FORBIDDEN",
"errors.housekeeping.forbidden",
correlationId,
);
}
const occurredAt = new Date().toISOString();
try {
await writeAudit({
userId: context.actor.id,
action: "housekeeping.route.visit",
target: entry.route.id,
after: {
canonicalHref: entry.route.href,
labelKey: entry.route.labelKey,
},
correlationId,
outcome: "success",
domain: entry.domain.id,
});
return ok(
{
routeId: entry.route.id,
canonicalHref: entry.route.href,
labelKey: entry.route.labelKey,
occurredAt,
source: "route-visit",
},
correlationId,
);
} catch {
return fail(
"INTERNAL",
"errors.housekeeping.recent.record",
correlationId,
);
}
},
};
}
@@ -30,13 +30,19 @@ import {
anyCapability,
type CanonicalHousekeepingHref,
type CapabilityRequirement,
type HousekeepingCapabilityContext,
type HousekeepingDomainManifest,
type HousekeepingInboxSource,
type HousekeepingSearchProvider,
type HousekeepingWidgetDefinition,
ok,
} from "./contracts";
import { createHousekeepingRegistry } from "./registry";
import { defaultHousekeepingPreferences } from "./preferences/schema";
import {
createHousekeepingRegistry,
loadHousekeepingWidgets,
WIDGET_POLICY,
} from "./registry";
const manifest = (
id: HousekeepingDomainId,
@@ -565,6 +571,88 @@ describe("housekeeping registry", () => {
).toThrow("invalid widget kind: people.queue");
});
it("loads only authorized mandatory and enabled optional widgets with isolated timeouts", async () => {
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),
};
let deniedCalls = 0;
const base = {
...manifest("people"),
capability: anyCapability(PERMS.USERS_VIEW),
};
const registry = createHousekeepingRegistry([
{
...base,
widgets: [
{
...widget("people.required"),
kind: "mandatory",
load: async () => ok("required", "required"),
},
{
...widget("people.optional"),
load: async () => ok("optional", "optional"),
},
{
...widget(
"people.denied",
"people",
anyCapability(PERMS.BANS_VIEW),
),
load: async () => {
deniedCalls += 1;
return ok("denied", "denied");
},
},
{
...widget("people.slow"),
load: async (_context, signal) =>
new Promise((resolve) => {
signal.addEventListener("abort", () =>
resolve(ok("late", "late")),
);
}),
},
],
},
]);
const preferences = {
...defaultHousekeepingPreferences(),
widgetOrder: [
"stale.widget",
"people.optional",
"people.required",
"people.slow",
],
enabledOptionalWidgetIds: [
"people.optional",
"people.denied",
"people.slow",
],
};
const result = await loadHousekeepingWidgets(
registry,
preferences,
context,
5,
);
expect(WIDGET_POLICY.timeoutMs).toBe(2_000);
expect(result.widgets).toEqual([
{ id: "people.optional", data: "optional" },
{ id: "people.required", data: "required" },
]);
expect(result.errors).toEqual([
{ widgetId: "people.slow", code: "TIMEOUT" },
]);
expect(deniedCalls).toBe(0);
});
it("rejects empty domain and route capability requirements", () => {
expect(() =>
createHousekeepingRegistry([
@@ -1,9 +1,20 @@
import { PERMS } from "@/lib/permission-slugs";
import { HOUSEKEEPING_DOMAIN_IDS } from "../migration/types";
import { satisfiesCapability } from "./capability-context";
import type {
CapabilityRequirement,
HousekeepingCapabilityContext,
HousekeepingDomainManifest,
HousekeepingErrorCode,
HousekeepingWidgetDefinition,
} 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 knownCapabilitySlugs = new Set<string>(Object.values(PERMS));
@@ -17,6 +28,76 @@ export interface HousekeepingRegistry {
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(
manifests: readonly HousekeepingDomainManifest[],
): HousekeepingRegistry {
@@ -0,0 +1,241 @@
"use client";
import {
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useState } from "react";
import Link from "@/components/link";
import type {
HousekeepingRouteDefinition,
HousekeepingSurface,
} from "../contracts";
import type { HousekeepingPreferences } from "../preferences/schema";
import { toHousekeepingHref } from "../routing/href";
import {
type HousekeepingPreferenceSaver,
preferencesAfterSave,
} from "./widget-settings";
export function movePreferenceId(
ids: readonly string[],
activeId: string,
overId: string,
): string[] {
const from = ids.indexOf(activeId);
const to = ids.indexOf(overId);
if (from < 0 || to < 0 || from === to) return [...ids];
return arrayMove([...ids], from, to);
}
export function togglePinnedRoute(
preferences: HousekeepingPreferences,
routeId: string,
): HousekeepingPreferences {
const pinned = preferences.pinnedRouteIds.includes(routeId);
if (pinned) {
return {
...preferences,
pinnedRouteIds: preferences.pinnedRouteIds.filter((id) => id !== routeId),
shortcutOrder: preferences.shortcutOrder.filter((id) => id !== routeId),
};
}
return {
...preferences,
pinnedRouteIds: [...preferences.pinnedRouteIds, routeId],
shortcutOrder: preferences.shortcutOrder.includes(routeId)
? [...preferences.shortcutOrder]
: [...preferences.shortcutOrder, routeId],
};
}
function orderedPinnedRouteIds(preferences: HousekeepingPreferences): string[] {
const pinned = new Set(preferences.pinnedRouteIds);
const ordered = preferences.shortcutOrder.filter((id) => pinned.has(id));
for (const id of preferences.pinnedRouteIds) {
if (!ordered.includes(id)) ordered.push(id);
}
return ordered;
}
function SortableFavorite({
route,
surface,
unpin,
unpinLabel,
}: {
readonly route: HousekeepingRouteDefinition;
readonly surface: HousekeepingSurface;
readonly unpin: () => void;
readonly unpinLabel: string;
}) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({
id: route.id,
});
return (
<li
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className="flex items-center gap-2 rounded-md border border-[var(--admin-border)] p-3"
>
<button
type="button"
aria-label={route.labelKey}
className="cursor-grab text-[var(--admin-text-muted)]"
{...attributes}
{...listeners}
>
↕
</button>
<Link
className="min-w-0 flex-1"
href={toHousekeepingHref(route.href, surface)}
>
{route.labelKey}
</Link>
<button
type="button"
onClick={unpin}
className="rounded-md px-2 py-1 text-sm"
>
{unpinLabel}
</button>
</li>
);
}
export interface FavoritesLabels {
readonly title: string;
readonly pin: string;
readonly unpin: string;
readonly error: string;
}
export function Favorites({
preferences: initialPreferences,
routes,
surface,
savePreferences,
labels,
}: {
readonly preferences: HousekeepingPreferences;
readonly routes: readonly HousekeepingRouteDefinition[];
readonly surface: HousekeepingSurface;
readonly savePreferences: HousekeepingPreferenceSaver;
readonly labels: FavoritesLabels;
}) {
const [preferences, setPreferences] = useState(initialPreferences);
const [error, setError] = useState(false);
const [saving, setSaving] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const routeById = new Map(routes.map((route) => [route.id, route]));
const pinnedIds = orderedPinnedRouteIds(preferences).filter((id) =>
routeById.has(id),
);
const unpinned = routes.filter(
(route) => !preferences.pinnedRouteIds.includes(route.id),
);
const commit = async (proposed: HousekeepingPreferences) => {
setSaving(true);
setError(false);
try {
const outcome = preferencesAfterSave(
preferences,
proposed,
await savePreferences(proposed),
);
setPreferences(outcome.preferences);
setError(outcome.error !== undefined);
} catch {
setError(true);
} finally {
setSaving(false);
}
};
const onDragEnd = (event: DragEndEvent) => {
if (!event.over) return;
const shortcutOrder = movePreferenceId(
pinnedIds,
String(event.active.id),
String(event.over.id),
);
void commit({ ...preferences, shortcutOrder });
};
return (
<section
aria-labelledby="housekeeping-favorites-title"
className="space-y-3"
>
<h2 id="housekeeping-favorites-title" className="text-lg font-semibold">
{labels.title}
</h2>
{error ? (
<p role="alert" className="text-[var(--admin-danger)]">
{labels.error}
</p>
) : null}
<DndContext sensors={sensors} onDragEnd={onDragEnd}>
<SortableContext
items={pinnedIds}
strategy={verticalListSortingStrategy}
>
<ul className="space-y-2">
{pinnedIds.map((id) => {
const route = routeById.get(id);
return route ? (
<SortableFavorite
key={id}
route={route}
surface={surface}
unpinLabel={labels.unpin}
unpin={() => void commit(togglePinnedRoute(preferences, id))}
/>
) : null;
})}
</ul>
</SortableContext>
</DndContext>
<ul className="space-y-2">
{unpinned.map((route) => (
<li
key={route.id}
className="flex items-center justify-between gap-3 p-2"
>
<span>{route.labelKey}</span>
<button
type="button"
disabled={saving}
onClick={() =>
void commit(togglePinnedRoute(preferences, route.id))
}
className="rounded-md px-2 py-1 text-sm"
>
{labels.pin}
</button>
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,213 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
fail,
type HousekeepingCapabilityContext,
ok,
} from "../contracts";
import { defaultHousekeepingPreferences } from "../preferences/schema";
import {
createHousekeepingRegistry,
loadHousekeepingWidgets,
} from "../registry";
import { Favorites, movePreferenceId, togglePinnedRoute } from "./favorites";
import { RecentWork } from "./recent-work";
import { WidgetGrid } from "./widget-grid";
import {
preferencesAfterSave,
toggleOptionalWidget,
WidgetSettings,
} from "./widget-settings";
const usersView = anyCapability(PERMS.USERS_VIEW);
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),
};
const registry = createHousekeepingRegistry([
{
id: "people",
labelKey: "people",
descriptionKey: "people.description",
iconId: "users",
canonicalHref: "/ase/people",
capability: usersView,
routes: [
{
id: "people.users",
labelKey: "Users",
href: "/ase/people/users",
capability: usersView,
},
{
id: "people.tickets",
labelKey: "Tickets",
href: "/ase/people/tickets",
capability: usersView,
},
],
searchProviders: [],
inboxSources: [],
widgets: [
{
id: "people.mandatory",
owner: "people",
capability: usersView,
kind: "mandatory",
load: async () => ok({ count: 1 }, "mandatory"),
},
{
id: "people.optional",
owner: "people",
capability: usersView,
kind: "optional",
load: async () => ok({ count: 2 }, "optional"),
},
{
id: "people.failing",
owner: "people",
capability: usersView,
kind: "optional",
load: async () => fail("TIMEOUT", "timeout", "failing"),
},
],
},
]);
const initialPreferences = {
...defaultHousekeepingPreferences(),
pinnedRouteIds: ["people.users"],
shortcutOrder: ["people.users"],
widgetOrder: ["stale.widget", "people.optional", "people.mandatory"],
enabledOptionalWidgetIds: ["people.optional", "people.failing"],
};
const save = vi.fn(async (value) => ok(value, "saved"));
describe("housekeeping personalization", () => {
it("loads enabled widgets independently in reconciled order and reports partial failures", async () => {
const result = await loadHousekeepingWidgets(
registry,
initialPreferences,
context,
);
expect(result.widgets).toEqual([
{ id: "people.optional", data: { count: 2 } },
{ id: "people.mandatory", data: { count: 1 } },
]);
expect(result.errors).toEqual([
{ widgetId: "people.failing", code: "TIMEOUT" },
]);
});
it("pins, unpins, and reorders only stable preference identifiers", () => {
const pinned = togglePinnedRoute(initialPreferences, "people.tickets");
expect(pinned.pinnedRouteIds).toEqual(["people.users", "people.tickets"]);
expect(pinned.shortcutOrder).toEqual(["people.users", "people.tickets"]);
expect(
movePreferenceId(pinned.shortcutOrder, "people.tickets", "people.users"),
).toEqual(["people.tickets", "people.users"]);
expect(togglePinnedRoute(pinned, "people.users")).toMatchObject({
pinnedRouteIds: ["people.tickets"],
shortcutOrder: ["people.tickets"],
});
});
it("locks mandatory widgets, toggles optional widgets, and retains UI state after a failed save", () => {
const enabled = toggleOptionalWidget(initialPreferences, "people.optional");
expect(enabled.enabledOptionalWidgetIds).toEqual(["people.failing"]);
const failure = fail(
"INTERNAL",
"errors.housekeeping.preferences.save",
"save-failed",
);
expect(preferencesAfterSave(initialPreferences, enabled, failure)).toEqual({
preferences: initialPreferences,
error: "errors.housekeeping.preferences.save",
});
});
it("renders favorites, recent links, locked settings, keyboard ordering, and widget errors accessibly", () => {
const widgets = {
widgets: [
{ id: "people.optional", data: { count: 2 } },
{ id: "people.mandatory", data: { count: 1 } },
],
errors: [{ widgetId: "people.failing", code: "TIMEOUT" as const }],
};
const routes = registry.domains[0]?.routes ?? [];
const definitions = registry.domains[0]?.widgets ?? [];
const html = renderToStaticMarkup(
<>
<Favorites
preferences={initialPreferences}
routes={routes}
surface="preview"
savePreferences={save}
labels={{
title: "Favorites",
pin: "Pin",
unpin: "Unpin",
error: "Save failed",
}}
/>
<RecentWork
items={[
{
routeId: "people.users",
canonicalHref: "/ase/people/users",
labelKey: "Users",
occurredAt: "2026-08-30T10:00:00.000Z",
source: "route-visit",
},
]}
surface="preview"
labels={{ title: "Recent work", empty: "Nothing recent" }}
/>
<WidgetSettings
preferences={initialPreferences}
widgets={definitions}
savePreferences={save}
labels={{
title: "Widget settings",
mandatory: "Required",
error: "Save failed",
}}
/>
<WidgetGrid
preferences={initialPreferences}
result={widgets}
savePreferences={save}
labels={{
title: "Widgets",
partial: "Some widgets failed",
error: "Save failed",
}}
/>
</>,
);
expect(html).toContain('href="/ase-next/people/users"');
expect(html).toContain("Required");
expect(html).toContain('disabled=""');
expect(html).toContain("Some widgets failed");
expect(html).toContain("people.failing");
expect(html).toContain("TIMEOUT");
const gridSource = readFileSync(
resolve(
process.cwd(),
"src/features/housekeeping/foundation/shell/widget-grid.tsx",
),
"utf8",
);
expect(gridSource).toContain("KeyboardSensor");
expect(gridSource).toContain("sortableKeyboardCoordinates");
});
});
@@ -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>
);
}
@@ -0,0 +1,159 @@
"use client";
import {
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useState } from "react";
import type { HousekeepingPreferences } from "../preferences/schema";
import type { HousekeepingWidgetLoadResult } from "../registry";
import { movePreferenceId } from "./favorites";
import {
type HousekeepingPreferenceSaver,
preferencesAfterSave,
} from "./widget-settings";
function SortableWidget({
id,
data,
}: {
readonly id: string;
readonly data: unknown;
}) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id });
return (
<li
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<div className="flex items-center gap-2">
<button
type="button"
aria-label={id}
className="cursor-grab text-[var(--admin-text-muted)]"
{...attributes}
{...listeners}
>
↕
</button>
<h3 className="font-medium">{id}</h3>
</div>
<pre className="mt-2 overflow-auto text-xs">
{JSON.stringify(data, null, 2)}
</pre>
</li>
);
}
export interface WidgetGridLabels {
readonly title: string;
readonly partial: string;
readonly error: string;
}
export function WidgetGrid({
preferences: initialPreferences,
result,
savePreferences,
labels,
}: {
readonly preferences: HousekeepingPreferences;
readonly result: HousekeepingWidgetLoadResult;
readonly savePreferences: HousekeepingPreferenceSaver;
readonly labels: WidgetGridLabels;
}) {
const [preferences, setPreferences] = useState(initialPreferences);
const [error, setError] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const dataById = new Map(
result.widgets.map((widget) => [widget.id, widget.data]),
);
const ids = preferences.widgetOrder.filter((id) => dataById.has(id));
for (const widget of result.widgets) {
if (!ids.includes(widget.id)) ids.push(widget.id);
}
const onDragEnd = async (event: DragEndEvent) => {
if (!event.over) return;
const moved = movePreferenceId(
ids,
String(event.active.id),
String(event.over.id),
);
const retained = preferences.widgetOrder.filter((id) => !ids.includes(id));
const proposed = { ...preferences, widgetOrder: [...moved, ...retained] };
setError(false);
try {
const outcome = preferencesAfterSave(
preferences,
proposed,
await savePreferences(proposed),
);
setPreferences(outcome.preferences);
setError(outcome.error !== undefined);
} catch {
setError(true);
}
};
return (
<section
aria-labelledby="housekeeping-widget-grid-title"
className="space-y-3"
>
<h2 id="housekeeping-widget-grid-title" className="text-lg font-semibold">
{labels.title}
</h2>
{error ? (
<p role="alert" className="text-[var(--admin-danger)]">
{labels.error}
</p>
) : null}
{result.errors.length > 0 ? (
<div
role="status"
className="rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] p-3"
>
<p className="text-[var(--admin-warning)]">{labels.partial}</p>
<ul className="mt-2 list-disc pl-5 text-sm">
{result.errors.map((item) => (
<li key={item.widgetId}>
{item.widgetId}: {item.code}
</li>
))}
</ul>
</div>
) : null}
<DndContext
sensors={sensors}
onDragEnd={(event) => void onDragEnd(event)}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
<ul className="grid gap-3 md:grid-cols-2">
{ids.map((id) => (
<SortableWidget key={id} id={id} data={dataById.get(id)} />
))}
</ul>
</SortableContext>
</DndContext>
</section>
);
}
@@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import type {
HousekeepingResult,
HousekeepingWidgetDefinition,
} from "../contracts";
import type { HousekeepingPreferences } from "../preferences/schema";
export type HousekeepingPreferenceSaver = (
preferences: HousekeepingPreferences,
) => Promise<HousekeepingResult<HousekeepingPreferences>>;
export function toggleOptionalWidget(
preferences: HousekeepingPreferences,
widgetId: string,
): HousekeepingPreferences {
const enabled = preferences.enabledOptionalWidgetIds.includes(widgetId);
return {
...preferences,
widgetOrder:
!enabled && !preferences.widgetOrder.includes(widgetId)
? [...preferences.widgetOrder, widgetId]
: [...preferences.widgetOrder],
enabledOptionalWidgetIds: enabled
? preferences.enabledOptionalWidgetIds.filter((id) => id !== widgetId)
: [...preferences.enabledOptionalWidgetIds, widgetId],
};
}
export function preferencesAfterSave(
current: HousekeepingPreferences,
_proposed: HousekeepingPreferences,
result: HousekeepingResult<HousekeepingPreferences>,
): { preferences: HousekeepingPreferences; error?: string } {
return result.ok
? { preferences: result.data }
: { preferences: current, error: result.error.messageKey };
}
export interface WidgetSettingsLabels {
readonly title: string;
readonly mandatory: string;
readonly error: string;
}
export function WidgetSettings({
preferences: initialPreferences,
widgets,
savePreferences,
labels,
titles = {},
}: {
readonly preferences: HousekeepingPreferences;
readonly widgets: readonly HousekeepingWidgetDefinition[];
readonly savePreferences: HousekeepingPreferenceSaver;
readonly labels: WidgetSettingsLabels;
readonly titles?: Readonly<Record<string, string>>;
}) {
const [preferences, setPreferences] = useState(initialPreferences);
const [savingId, setSavingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const toggle = async (widgetId: string) => {
const proposed = toggleOptionalWidget(preferences, widgetId);
setSavingId(widgetId);
setError(null);
try {
const outcome = preferencesAfterSave(
preferences,
proposed,
await savePreferences(proposed),
);
setPreferences(outcome.preferences);
setError(outcome.error ?? null);
} catch {
setError(labels.error);
} finally {
setSavingId(null);
}
};
return (
<section
aria-labelledby="housekeeping-widget-settings-title"
className="space-y-3"
>
<h2
id="housekeeping-widget-settings-title"
className="text-lg font-semibold"
>
{labels.title}
</h2>
{error ? (
<p role="alert" className="text-[var(--admin-danger)]">
{error === labels.error ? error : labels.error}
</p>
) : null}
<ul className="space-y-2">
{widgets.map((widget) => {
const mandatory = widget.kind === "mandatory";
const checked =
mandatory ||
preferences.enabledOptionalWidgetIds.includes(widget.id);
return (
<li
key={widget.id}
className="flex items-center justify-between gap-3 rounded-md border border-[var(--admin-border)] p-3"
>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={checked}
disabled={mandatory || savingId !== null}
onChange={() => void toggle(widget.id)}
/>
<span>{titles[widget.id] ?? widget.id}</span>
</label>
{mandatory ? (
<span className="text-xs text-[var(--admin-text-muted)]">
{labels.mandatory}
</span>
) : null}
</li>
);
})}
</ul>
</section>
);
}
+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);
}