feat(housekeeping): persist operator preferences
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user