feat(housekeeping): personalize command deck
This commit is contained in:
1 parent
300ac0ef95
commit
5574e601bb
12 files changed
+1549
-1
No files matched your search
@@ -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" } });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user