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,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,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user