257 lines
6.5 KiB
TypeScript
257 lines
6.5 KiB
TypeScript
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,
|
|
);
|
|
}
|
|
},
|
|
};
|
|
}
|