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 { PERMS } from "@/lib/permission-slugs";
|
||||||
import { HOUSEKEEPING_DOMAIN_IDS } from "../migration/types";
|
import { HOUSEKEEPING_DOMAIN_IDS } from "../migration/types";
|
||||||
|
import { satisfiesCapability } from "./capability-context";
|
||||||
import type {
|
import type {
|
||||||
CapabilityRequirement,
|
CapabilityRequirement,
|
||||||
|
HousekeepingCapabilityContext,
|
||||||
HousekeepingDomainManifest,
|
HousekeepingDomainManifest,
|
||||||
|
HousekeepingErrorCode,
|
||||||
|
HousekeepingWidgetDefinition,
|
||||||
} from "./contracts";
|
} 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 approvedDomainIds = new Set<string>(HOUSEKEEPING_DOMAIN_IDS);
|
||||||
const knownCapabilitySlugs = new Set<string>(Object.values(PERMS));
|
const knownCapabilitySlugs = new Set<string>(Object.values(PERMS));
|
||||||
@@ -17,6 +28,76 @@ export interface HousekeepingRegistry {
|
|||||||
domains: readonly HousekeepingDomainManifest[];
|
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(
|
export function createHousekeepingRegistry(
|
||||||
manifests: readonly HousekeepingDomainManifest[],
|
manifests: readonly HousekeepingDomainManifest[],
|
||||||
): HousekeepingRegistry {
|
): 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