diff --git a/src/actions/housekeeping-preferences.test.ts b/src/actions/housekeeping-preferences.test.ts new file mode 100644 index 00000000..976df44d --- /dev/null +++ b/src/actions/housekeeping-preferences.test.ts @@ -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(); + }); +}); diff --git a/src/actions/housekeeping-preferences.ts b/src/actions/housekeeping-preferences.ts new file mode 100644 index 00000000..adedaef0 --- /dev/null +++ b/src/actions/housekeeping-preferences.ts @@ -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; +} + +export async function loadHousekeepingPreferences( + dependencies: HousekeepingPreferencesActionDependencies, +): Promise> { + 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> { + 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 { + return dependencies.getContext + ? dependencies.getContext() + : getHousekeepingCapabilityContext(); +} diff --git a/src/features/housekeeping/foundation/preferences/reconcile.test.ts b/src/features/housekeeping/foundation/preferences/reconcile.test.ts new file mode 100644 index 00000000..cdfb6bf8 --- /dev/null +++ b/src/features/housekeeping/foundation/preferences/reconcile.test.ts @@ -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"], + }); + }); +}); diff --git a/src/features/housekeeping/foundation/preferences/reconcile.ts b/src/features/housekeeping/foundation/preferences/reconcile.ts new file mode 100644 index 00000000..1780df96 --- /dev/null +++ b/src/features/housekeeping/foundation/preferences/reconcile.ts @@ -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[] { + 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; +} diff --git a/src/features/housekeeping/foundation/preferences/repository.test.ts b/src/features/housekeeping/foundation/preferences/repository.test.ts new file mode 100644 index 00000000..4bfbdbdc --- /dev/null +++ b/src/features/housekeeping/foundation/preferences/repository.test.ts @@ -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), + }); + }); +}); diff --git a/src/features/housekeeping/foundation/preferences/repository.ts b/src/features/housekeeping/foundation/preferences/repository.ts new file mode 100644 index 00000000..592fa2fd --- /dev/null +++ b/src/features/housekeeping/foundation/preferences/repository.ts @@ -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; + upsert(row: { + userId: number; + schemaVersion: 1; + payload: string; + }): Promise; +} + +export interface HousekeepingPreferencesRepository { + read(userId: number): Promise; + upsert(userId: number, value: HousekeepingPreferences): Promise; +} + +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), + }); + }, + }; +} diff --git a/src/features/housekeeping/foundation/preferences/schema.test.ts b/src/features/housekeeping/foundation/preferences/schema.test.ts new file mode 100644 index 00000000..fe8abcf2 --- /dev/null +++ b/src/features/housekeeping/foundation/preferences/schema.test.ts @@ -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); + }); +}); diff --git a/src/features/housekeeping/foundation/preferences/schema.ts b/src/features/housekeeping/foundation/preferences/schema.ts new file mode 100644 index 00000000..b5ee80d3 --- /dev/null +++ b/src/features/housekeeping/foundation/preferences/schema.ts @@ -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(); + 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 = + 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); +}