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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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