diff --git a/src/actions/housekeeping-recent.test.ts b/src/actions/housekeeping-recent.test.ts new file mode 100644 index 00000000..4b723aa8 --- /dev/null +++ b/src/actions/housekeeping-recent.test.ts @@ -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" } }); + }); +}); diff --git a/src/actions/housekeeping-recent.ts b/src/actions/housekeeping-recent.ts new file mode 100644 index 00000000..b2605474 --- /dev/null +++ b/src/actions/housekeeping-recent.ts @@ -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 +> { + try { + const context = await getHousekeepingCapabilityContext(); + return await loadHousekeepingRecentWork(context); + } catch (error) { + return mapUnknownError(error); + } +} + +export async function recordHousekeepingRouteVisitAction( + routeId: unknown, +): Promise> { + 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); + } +} diff --git a/src/features/housekeeping/foundation/recent/recent-work.test.ts b/src/features/housekeeping/foundation/recent/recent-work.test.ts new file mode 100644 index 00000000..9dbc153a --- /dev/null +++ b/src/features/housekeeping/foundation/recent/recent-work.test.ts @@ -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" } }); + }); +}); diff --git a/src/features/housekeeping/foundation/recent/recent-work.ts b/src/features/housekeeping/foundation/recent/recent-work.ts new file mode 100644 index 00000000..582ca866 --- /dev/null +++ b/src/features/housekeeping/foundation/recent/recent-work.ts @@ -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 writeAudit: (entry: AuditEntry) => Promise; + 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(); + 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> { + 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> { + 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, + ); + } + }, + }; +} diff --git a/src/features/housekeeping/foundation/registry.test.ts b/src/features/housekeeping/foundation/registry.test.ts index bd0d5d29..23169b29 100644 --- a/src/features/housekeeping/foundation/registry.test.ts +++ b/src/features/housekeeping/foundation/registry.test.ts @@ -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([ diff --git a/src/features/housekeeping/foundation/registry.ts b/src/features/housekeeping/foundation/registry.ts index f59e3c3d..7ffe553d 100644 --- a/src/features/housekeeping/foundation/registry.ts +++ b/src/features/housekeeping/foundation/registry.ts @@ -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(HOUSEKEEPING_DOMAIN_IDS); const knownCapabilitySlugs = new Set(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 { + 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 { + 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 { diff --git a/src/features/housekeeping/foundation/shell/favorites.tsx b/src/features/housekeeping/foundation/shell/favorites.tsx new file mode 100644 index 00000000..4b5c0ea7 --- /dev/null +++ b/src/features/housekeeping/foundation/shell/favorites.tsx @@ -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 ( +
  • + + + {route.labelKey} + + +
  • + ); +} + +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 ( +
    +

    + {labels.title} +

    + {error ? ( +

    + {labels.error} +

    + ) : null} + + +
      + {pinnedIds.map((id) => { + const route = routeById.get(id); + return route ? ( + void commit(togglePinnedRoute(preferences, id))} + /> + ) : null; + })} +
    +
    +
    +
      + {unpinned.map((route) => ( +
    • + {route.labelKey} + +
    • + ))} +
    +
    + ); +} diff --git a/src/features/housekeeping/foundation/shell/personalization.test.tsx b/src/features/housekeeping/foundation/shell/personalization.test.tsx new file mode 100644 index 00000000..624b3df9 --- /dev/null +++ b/src/features/housekeeping/foundation/shell/personalization.test.tsx @@ -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( + <> + + + + + , + ); + 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"); + }); +}); diff --git a/src/features/housekeeping/foundation/shell/recent-work.tsx b/src/features/housekeeping/foundation/shell/recent-work.tsx new file mode 100644 index 00000000..6ac7d1cf --- /dev/null +++ b/src/features/housekeeping/foundation/shell/recent-work.tsx @@ -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>; +}) { + return ( +
    +

    + {labels.title} +

    + {items.length === 0 ?

    {labels.empty}

    : null} +
      + {items.map((item) => ( +
    • + + {titles[item.labelKey] ?? item.labelKey} + +
      + {item.source} + {" · "} + +
      +
    • + ))} +
    +
    + ); +} diff --git a/src/features/housekeeping/foundation/shell/widget-grid.tsx b/src/features/housekeeping/foundation/shell/widget-grid.tsx new file mode 100644 index 00000000..40076a92 --- /dev/null +++ b/src/features/housekeeping/foundation/shell/widget-grid.tsx @@ -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 ( +
  • +
    + +

    {id}

    +
    +
    +				{JSON.stringify(data, null, 2)}
    +			
    +
  • + ); +} + +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 ( +
    +

    + {labels.title} +

    + {error ? ( +

    + {labels.error} +

    + ) : null} + {result.errors.length > 0 ? ( +
    +

    {labels.partial}

    +
      + {result.errors.map((item) => ( +
    • + {item.widgetId}: {item.code} +
    • + ))} +
    +
    + ) : null} + void onDragEnd(event)} + > + +
      + {ids.map((id) => ( + + ))} +
    +
    +
    +
    + ); +} diff --git a/src/features/housekeeping/foundation/shell/widget-settings.tsx b/src/features/housekeeping/foundation/shell/widget-settings.tsx new file mode 100644 index 00000000..50ce23cc --- /dev/null +++ b/src/features/housekeeping/foundation/shell/widget-settings.tsx @@ -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>; + +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, +): { 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>; +}) { + const [preferences, setPreferences] = useState(initialPreferences); + const [savingId, setSavingId] = useState(null); + const [error, setError] = useState(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 ( +
    +

    + {labels.title} +

    + {error ? ( +

    + {error === labels.error ? error : labels.error} +

    + ) : null} +
      + {widgets.map((widget) => { + const mandatory = widget.kind === "mandatory"; + const checked = + mandatory || + preferences.enabledOptionalWidgetIds.includes(widget.id); + return ( +
    • + + {mandatory ? ( + + {labels.mandatory} + + ) : null} +
    • + ); + })} +
    +
    + ); +} diff --git a/src/lib/housekeeping-recent-work.ts b/src/lib/housekeeping-recent-work.ts new file mode 100644 index 00000000..9b21b520 --- /dev/null +++ b/src/lib/housekeeping-recent-work.ts @@ -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> { + return recentWorkService.load(context); +} + +export async function recordHousekeepingRouteVisit( + routeId: string, + context: HousekeepingCapabilityContext, +): Promise> { + return recentWorkService.recordVisit(routeId, context); +}