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,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