feat(housekeeping): persist operator preferences

This commit is contained in:
Simo committed 2026-08-26 22:17:52 +02:00
1 parent 86a2d9d069
commit 2eb0456999
8 files changed
+566

No files matched your search

@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: vi.fn(),
}));
import type { HousekeepingPreferencesRepository } from "@/features/housekeeping/foundation/preferences/repository";
import { defaultHousekeepingPreferences } from "@/features/housekeeping/foundation/preferences/schema";
import type { HousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import {
type HousekeepingPreferencesActionDependencies,
loadHousekeepingPreferences,
saveHousekeepingPreferences,
} from "./housekeeping-preferences";
const registry: HousekeepingRegistry = { domains: [] };
const allowedContext = {
actor: { id: 42, username: "operator", rank: 0 },
isSuperAdmin: false,
has: (slug: string) => slug === "admin.dashboard",
hasAny: (...slugs: string[]) => slugs.includes("admin.dashboard"),
hasAll: (...slugs: string[]) =>
slugs.every((slug) => slug === "admin.dashboard"),
};
function dependencies(
context = allowedContext,
): HousekeepingPreferencesActionDependencies & {
repository: HousekeepingPreferencesRepository;
} {
return {
repository: {
read: vi.fn().mockResolvedValue(defaultHousekeepingPreferences()),
upsert: vi.fn(),
},
registry,
getContext: vi.fn().mockResolvedValue(context),
};
}
describe("housekeeping preference actions", () => {
it("derives the read owner from request capability context", async () => {
const deps = dependencies();
const result = await loadHousekeepingPreferences(deps);
expect(result.ok).toBe(true);
expect(deps.repository.read).toHaveBeenCalledWith(42);
expect(deps.getContext).toHaveBeenCalledOnce();
});
it("rejects a denied operator without reading or writing another user preferences", async () => {
const deps = dependencies({ ...allowedContext, hasAny: () => false });
const result = await saveHousekeepingPreferences(
defaultHousekeepingPreferences(),
deps,
);
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
expect(deps.repository.read).not.toHaveBeenCalled();
expect(deps.repository.upsert).not.toHaveBeenCalled();
});
it("validates writes and persists them for the context actor only", async () => {
const deps = dependencies();
const value = {
...defaultHousekeepingPreferences(),
pinnedRouteIds: ["people.users"],
};
const result = await saveHousekeepingPreferences(value, deps);
expect(result).toMatchObject({ ok: true, data: value });
expect(deps.repository.upsert).toHaveBeenCalledWith(42, value);
});
it("returns a validation result before an invalid payload reaches persistence", async () => {
const deps = dependencies();
const result = await saveHousekeepingPreferences(
{ schemaVersion: 2 },
deps,
);
expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION" } });
expect(deps.repository.upsert).not.toHaveBeenCalled();
});
});
+87
View File
@@ -0,0 +1,87 @@
import { authorizeHousekeeping } from "@/features/housekeeping/foundation/authorization";
import {
anyCapability,
fail,
type HousekeepingCapabilityContext,
type HousekeepingResult,
ok,
} from "@/features/housekeeping/foundation/contracts";
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
import { reconcilePreferences } from "@/features/housekeeping/foundation/preferences/reconcile";
import type { HousekeepingPreferencesRepository } from "@/features/housekeeping/foundation/preferences/repository";
import {
type HousekeepingPreferences,
housekeepingPreferencesSchema,
} from "@/features/housekeeping/foundation/preferences/schema";
import type { HousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { PERMS } from "@/lib/permission-slugs";
const preferencesCapability = anyCapability(PERMS.ADMIN_DASHBOARD);
export interface HousekeepingPreferencesActionDependencies {
repository: HousekeepingPreferencesRepository;
registry: HousekeepingRegistry;
getContext?: () => Promise<HousekeepingCapabilityContext>;
}
export async function loadHousekeepingPreferences(
dependencies: HousekeepingPreferencesActionDependencies,
): Promise<HousekeepingResult<HousekeepingPreferences>> {
const context = await resolveContext(dependencies);
const authorization = authorizeHousekeeping(context, preferencesCapability);
if (!authorization.ok) return authorization;
const correlationId = createCorrelationId();
try {
const stored = await dependencies.repository.read(context.actor.id);
return ok(
reconcilePreferences(stored, dependencies.registry, context),
correlationId,
);
} catch {
return fail(
"INTERNAL",
"errors.housekeeping.preferences.read",
correlationId,
);
}
}
export async function saveHousekeepingPreferences(
input: unknown,
dependencies: HousekeepingPreferencesActionDependencies,
): Promise<HousekeepingResult<HousekeepingPreferences>> {
const context = await resolveContext(dependencies);
const authorization = authorizeHousekeeping(context, preferencesCapability);
if (!authorization.ok) return authorization;
const correlationId = createCorrelationId();
const parsed = housekeepingPreferencesSchema.safeParse(input);
if (!parsed.success) {
return fail(
"VALIDATION",
"errors.housekeeping.preferences.invalid",
correlationId,
);
}
try {
await dependencies.repository.upsert(context.actor.id, parsed.data);
return ok(parsed.data, correlationId);
} catch {
return fail(
"INTERNAL",
"errors.housekeeping.preferences.save",
correlationId,
);
}
}
async function resolveContext(
dependencies: HousekeepingPreferencesActionDependencies,
): Promise<HousekeepingCapabilityContext> {
return dependencies.getContext
? dependencies.getContext()
: getHousekeepingCapabilityContext();
}
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import {
anyCapability,
type HousekeepingCapabilityContext,
} from "../contracts";
import type { HousekeepingRegistry } from "../registry";
import { reconcilePreferences } from "./reconcile";
import { defaultHousekeepingPreferences } from "./schema";
const usersView = anyCapability("admin.users.view");
const ticketsView = anyCapability("admin.tickets.view");
const bansView = anyCapability("admin.bans.view");
const registry: HousekeepingRegistry = {
domains: [
{
id: "people",
labelKey: "people",
descriptionKey: "people.description",
iconId: "users",
canonicalHref: "/ase/people",
capability: usersView,
routes: [
{
id: "people.users",
labelKey: "users",
href: "/ase/people/users",
capability: usersView,
},
{
id: "people.tickets",
labelKey: "tickets",
href: "/ase/people/tickets",
capability: ticketsView,
},
{
id: "people.bans",
labelKey: "bans",
href: "/ase/people/bans",
capability: bansView,
},
],
searchProviders: [],
inboxSources: [],
widgets: [
{
id: "people.summary",
owner: "people",
capability: usersView,
kind: "mandatory",
load: async () => ({ ok: true, data: null, correlationId: "widget" }),
},
{
id: "people.queue",
owner: "people",
capability: ticketsView,
kind: "optional",
load: async () => ({ ok: true, data: null, correlationId: "widget" }),
},
{
id: "people.bans",
owner: "people",
capability: bansView,
kind: "optional",
load: async () => ({ ok: true, data: null, correlationId: "widget" }),
},
],
},
],
};
const context: HousekeepingCapabilityContext = {
actor: { id: 42, username: "operator", rank: 0 },
isSuperAdmin: false,
has: (slug) => slug === "admin.users.view" || slug === "admin.tickets.view",
hasAny: (...slugs) => slugs.some(context.has),
hasAll: (...slugs) => slugs.every(context.has),
};
describe("reconcilePreferences", () => {
it("drops unknown and unauthorized IDs before returning preferences", () => {
const stored = {
...defaultHousekeepingPreferences(),
pinnedRouteIds: ["removed.route", "people.bans", "people.users"],
pinnedCommandIds: ["removed.command"],
shortcutOrder: [
"removed.route",
"people.bans",
"people.tickets",
"people.users",
],
widgetOrder: ["removed.widget", "people.bans", "people.queue"],
enabledOptionalWidgetIds: [
"removed.widget",
"people.bans",
"people.queue",
],
};
expect(reconcilePreferences(stored, registry, context)).toEqual({
...defaultHousekeepingPreferences(),
pinnedRouteIds: ["people.users"],
shortcutOrder: ["people.tickets", "people.users"],
widgetOrder: ["people.queue", "people.summary"],
enabledOptionalWidgetIds: ["people.queue"],
});
});
it("retains mandatory widgets and preserves the relative order of valid entries", () => {
const stored = {
...defaultHousekeepingPreferences(),
pinnedRouteIds: ["people.tickets", "people.users"],
shortcutOrder: ["people.tickets", "people.users"],
widgetOrder: ["people.queue"],
};
expect(reconcilePreferences(stored, registry, context)).toMatchObject({
pinnedRouteIds: ["people.tickets", "people.users"],
shortcutOrder: ["people.tickets", "people.users"],
widgetOrder: ["people.queue", "people.summary"],
});
});
});
@@ -0,0 +1,68 @@
import { satisfiesCapability } from "../capability-context";
import type {
HousekeepingCapabilityContext,
HousekeepingWidgetDefinition,
} from "../contracts";
import type { HousekeepingRegistry } from "../registry";
import type { HousekeepingPreferences } from "./schema";
export function reconcilePreferences(
stored: HousekeepingPreferences,
registry: HousekeepingRegistry,
context: HousekeepingCapabilityContext,
): HousekeepingPreferences {
const routes = registry.domains
.flatMap((domain) => domain.routes)
.filter((route) => satisfiesCapability(context, route.capability));
const widgets = registry.domains
.flatMap((domain) => domain.widgets)
.filter((widget) => satisfiesCapability(context, widget.capability));
const allowedRouteIds = new Set(routes.map((route) => route.id));
const allowedWidgetIds = new Set(widgets.map((widget) => widget.id));
const allowedOptionalWidgetIds = new Set(
widgets
.filter((widget) => widget.kind === "optional")
.map((widget) => widget.id),
);
const mandatoryWidgets = widgets.filter(
(widget): widget is HousekeepingWidgetDefinition & { kind: "mandatory" } =>
widget.kind === "mandatory",
);
const retainedWidgetOrder = filterKnown(stored.widgetOrder, allowedWidgetIds);
return {
schemaVersion: 1,
pinnedRouteIds: filterKnown(stored.pinnedRouteIds, allowedRouteIds),
// Commands are intentionally omitted until the command registry publishes
// a stable registry collection; unregistered IDs are never trusted.
pinnedCommandIds: [],
shortcutOrder: filterKnown(stored.shortcutOrder, allowedRouteIds),
widgetOrder: appendMissing(
retainedWidgetOrder,
mandatoryWidgets.map((widget) => widget.id),
),
enabledOptionalWidgetIds: filterKnown(
stored.enabledOptionalWidgetIds,
allowedOptionalWidgetIds,
),
};
}
function filterKnown(
values: readonly string[],
allowed: ReadonlySet<string>,
): string[] {
return values.filter((value) => allowed.has(value));
}
function appendMissing(
values: readonly string[],
required: readonly string[],
): string[] {
const combined = [...values];
const known = new Set(combined);
for (const value of required) {
if (!known.has(value)) combined.push(value);
}
return combined;
}
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import {
createHousekeepingPreferencesRepository,
type HousekeepingPreferencesStorage,
} from "./repository";
import { defaultHousekeepingPreferences } from "./schema";
describe("housekeeping preferences repository", () => {
it("returns a fresh default when no row exists", async () => {
const storage: HousekeepingPreferencesStorage = {
findByUserId: vi.fn().mockResolvedValue(null),
upsert: vi.fn(),
};
const repository = createHousekeepingPreferencesRepository(storage);
expect(await repository.read(42)).toEqual(defaultHousekeepingPreferences());
expect(storage.findByUserId).toHaveBeenCalledWith(42);
});
it("reads validated JSON and writes the schema-versioned payload through the injected adapter", async () => {
const value = {
...defaultHousekeepingPreferences(),
pinnedRouteIds: ["people.users"],
};
const storage: HousekeepingPreferencesStorage = {
findByUserId: vi.fn().mockResolvedValue({
schemaVersion: 1,
payload: JSON.stringify(value),
}),
upsert: vi.fn(),
};
const repository = createHousekeepingPreferencesRepository(storage);
expect(await repository.read(42)).toEqual(value);
await repository.upsert(42, value);
expect(storage.upsert).toHaveBeenCalledWith({
userId: 42,
schemaVersion: 1,
payload: JSON.stringify(value),
});
});
});
@@ -0,0 +1,49 @@
import type { HousekeepingPreferences } from "./schema";
import {
defaultHousekeepingPreferences,
parseHousekeepingPreferences,
} from "./schema";
export interface HousekeepingPreferencesStorageRow {
schemaVersion: number;
payload: string;
}
export interface HousekeepingPreferencesStorage {
findByUserId(
userId: number,
): Promise<HousekeepingPreferencesStorageRow | null>;
upsert(row: {
userId: number;
schemaVersion: 1;
payload: string;
}): Promise<void>;
}
export interface HousekeepingPreferencesRepository {
read(userId: number): Promise<HousekeepingPreferences>;
upsert(userId: number, value: HousekeepingPreferences): Promise<void>;
}
export function createHousekeepingPreferencesRepository(
storage: HousekeepingPreferencesStorage,
): HousekeepingPreferencesRepository {
return {
async read(userId) {
const row = await storage.findByUserId(userId);
if (!row) return defaultHousekeepingPreferences();
if (row.schemaVersion !== 1) {
throw new Error("unsupported housekeeping preferences schema version");
}
return parseHousekeepingPreferences(row.payload);
},
async upsert(userId, value) {
await storage.upsert({
userId,
schemaVersion: value.schemaVersion,
payload: JSON.stringify(value),
});
},
};
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
defaultHousekeepingPreferences,
housekeepingPreferencesSchema,
parseHousekeepingPreferences,
} from "./schema";
describe("housekeeping preferences schema", () => {
it("rejects malformed serialized JSON", () => {
expect(() => parseHousekeepingPreferences("{not-json")).toThrow(
"invalid housekeeping preferences JSON",
);
});
it("rejects unknown keys, duplicate identifiers, and unsupported versions", () => {
const valid = defaultHousekeepingPreferences();
expect(
housekeepingPreferencesSchema.safeParse({ ...valid, unsupported: true })
.success,
).toBe(false);
expect(
housekeepingPreferencesSchema.safeParse({
...valid,
pinnedRouteIds: ["people.users", "people.users"],
}).success,
).toBe(false);
expect(
housekeepingPreferencesSchema.safeParse({ ...valid, schemaVersion: 2 })
.success,
).toBe(false);
});
it("parses the current validated presentation payload", () => {
const value = {
schemaVersion: 1,
pinnedRouteIds: ["people.users"],
pinnedCommandIds: [],
shortcutOrder: ["people.users"],
widgetOrder: ["people.summary"],
enabledOptionalWidgetIds: [],
};
expect(parseHousekeepingPreferences(JSON.stringify(value))).toEqual(value);
});
});
@@ -0,0 +1,62 @@
import { z } from "zod";
export interface HousekeepingPreferences {
schemaVersion: 1;
pinnedRouteIds: string[];
pinnedCommandIds: string[];
shortcutOrder: string[];
widgetOrder: string[];
enabledOptionalWidgetIds: string[];
}
const uniqueIdentifiers = z
.array(z.string().trim().min(1))
.superRefine((values, context) => {
const seen = new Set<string>();
for (const [index, value] of values.entries()) {
if (seen.has(value)) {
context.addIssue({
code: "custom",
message: "duplicate preference identifier",
path: [index],
});
}
seen.add(value);
}
});
export const housekeepingPreferencesSchema: z.ZodType<HousekeepingPreferences> =
z
.object({
schemaVersion: z.literal(1),
pinnedRouteIds: uniqueIdentifiers,
pinnedCommandIds: uniqueIdentifiers,
shortcutOrder: uniqueIdentifiers,
widgetOrder: uniqueIdentifiers,
enabledOptionalWidgetIds: uniqueIdentifiers,
})
.strict();
export function defaultHousekeepingPreferences(): HousekeepingPreferences {
return {
schemaVersion: 1,
pinnedRouteIds: [],
pinnedCommandIds: [],
shortcutOrder: [],
widgetOrder: [],
enabledOptionalWidgetIds: [],
};
}
export function parseHousekeepingPreferences(
serialized: string,
): HousekeepingPreferences {
let value: unknown;
try {
value = JSON.parse(serialized);
} catch {
throw new Error("invalid housekeeping preferences JSON");
}
return housekeepingPreferencesSchema.parse(value);
}