diff --git a/src/features/housekeeping/foundation/contracts/capability.ts b/src/features/housekeeping/foundation/contracts/capability.ts new file mode 100644 index 0000000000..186cd8f5fb --- /dev/null +++ b/src/features/housekeeping/foundation/contracts/capability.ts @@ -0,0 +1,36 @@ +export type CapabilityRequirement = + | { mode: "all"; slugs: readonly string[] } + | { mode: "any"; slugs: readonly string[] }; + +export interface HousekeepingActor { + id: number; + username: string; + rank: number; +} + +export interface HousekeepingCapabilityContext { + actor: HousekeepingActor; + isSuperAdmin: boolean; + has(slug: string): boolean; + hasAny(...slugs: string[]): boolean; + hasAll(...slugs: string[]): boolean; +} + +function createCapabilityRequirement( + mode: CapabilityRequirement["mode"], + slugs: readonly string[], +): CapabilityRequirement { + if (slugs.length === 0) { + throw new Error("capability requirement is empty"); + } + + return { mode, slugs }; +} + +export function anyCapability(...slugs: string[]): CapabilityRequirement { + return createCapabilityRequirement("any", slugs); +} + +export function allCapabilities(...slugs: string[]): CapabilityRequirement { + return createCapabilityRequirement("all", slugs); +} diff --git a/src/features/housekeeping/foundation/contracts/command.ts b/src/features/housekeeping/foundation/contracts/command.ts new file mode 100644 index 0000000000..4f2240a712 --- /dev/null +++ b/src/features/housekeeping/foundation/contracts/command.ts @@ -0,0 +1,18 @@ +import type { HousekeepingDomainId } from "../../migration/types"; +import type { + CapabilityRequirement, + HousekeepingCapabilityContext, +} from "./capability"; +import type { HousekeepingResult } from "./result"; + +export interface HousekeepingCommand { + id: string; + owner: HousekeepingDomainId; + risk: "safe" | "sensitive"; + capability: CapabilityRequirement; + requiresReason: boolean; + execute( + context: HousekeepingCapabilityContext, + input: I, + ): Promise>; +} diff --git a/src/features/housekeeping/foundation/contracts/contracts.test.ts b/src/features/housekeeping/foundation/contracts/contracts.test.ts new file mode 100644 index 0000000000..bd53822627 --- /dev/null +++ b/src/features/housekeeping/foundation/contracts/contracts.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { + allCapabilities, + anyCapability, + type CapabilityRequirement, + fail, + type HousekeepingCommand, + type HousekeepingDomainManifest, + type HousekeepingInboxSource, + type HousekeepingInboxSourceResult, + type HousekeepingQuery, + type HousekeepingSearchProvider, + type HousekeepingSearchResult, + type HousekeepingWidgetDefinition, + type HousekeepingWorkItem, + ok, +} from "."; + +const capability = anyCapability("admin.users.view"); + +const workItem = { + sourceId: "tickets", + itemId: "ticket-42", + deduplicationKey: "ticket:42", + domain: "people", + capability, + severity: "warning", + occurredAt: "2026-08-24T12:00:00.000Z", + titleKey: "pages.housekeeping.items.ticket", + context: { ticketId: "42" }, + href: "/admin-next/people/tickets/42", + freshness: "fresh", +} satisfies HousekeepingWorkItem; + +const searchResult = { + id: "user-42", + domain: "people", + title: "operator", + href: "/admin-next/people/users/42", +} satisfies HousekeepingSearchResult; + +const searchProvider: HousekeepingSearchProvider = { + search: async () => ok([searchResult], "search-1"), +}; + +const inboxSourceResult = { + items: [workItem], + availability: "available", +} satisfies HousekeepingInboxSourceResult; + +const inboxSource: HousekeepingInboxSource = { + getItems: async () => ok(inboxSourceResult, "inbox-1"), +}; + +const widget: HousekeepingWidgetDefinition = { + load: async () => ok({ count: 2 }, "widget-1"), +}; + +const query: HousekeepingQuery<{ id: number }, { id: number }> = { + id: "people.user", + owner: "people", + capability, + run: async (_context, input) => ok(input, "query-1"), +}; + +const command: HousekeepingCommand<{ id: number }, { id: number }> = { + id: "people.user.disable", + owner: "people", + risk: "sensitive", + capability, + requiresReason: true, + execute: async (_context, input) => ok(input, "command-1"), +}; + +const manifest: HousekeepingDomainManifest = { + id: "people", + labelKey: "pages.housekeeping.domains.people.title", + descriptionKey: "pages.housekeeping.domains.people.description", + iconId: "users", + previewHref: "/admin-next/people", + capability, + routes: [], +}; + +describe("housekeeping foundation contracts", () => { + it("creates typed success and error results", () => { + expect(ok({ count: 2 }, "corr-1")).toEqual({ + ok: true, + data: { count: 2 }, + correlationId: "corr-1", + }); + expect(fail("CAPABILITY_DENIED", "corr-2")).toEqual({ + ok: false, + error: { code: "CAPABILITY_DENIED" }, + correlationId: "corr-2", + }); + }); + + it("rejects empty capability requirements", () => { + expect(() => anyCapability()).toThrow("capability requirement is empty"); + expect(() => allCapabilities()).toThrow("capability requirement is empty"); + }); + + it("exports assignable type-only contracts", () => { + const requirements: readonly CapabilityRequirement[] = [ + capability, + allCapabilities("admin.users.view"), + ]; + + expect([ + workItem, + searchProvider, + inboxSource, + widget, + query, + command, + manifest, + requirements, + ]).toHaveLength(8); + }); +}); diff --git a/src/features/housekeeping/foundation/contracts/domain.ts b/src/features/housekeeping/foundation/contracts/domain.ts new file mode 100644 index 0000000000..8c548b5673 --- /dev/null +++ b/src/features/housekeeping/foundation/contracts/domain.ts @@ -0,0 +1,20 @@ +import type { HousekeepingDomainId } from "../../migration/types"; +import type { CapabilityRequirement } from "./capability"; + +export interface HousekeepingRouteDefinition { + id: string; + labelKey: string; + href: string; + capability: CapabilityRequirement; + matchPrefixes?: readonly string[]; +} + +export interface HousekeepingDomainManifest { + id: HousekeepingDomainId; + labelKey: string; + descriptionKey: string; + iconId: "inbox" | "users" | "file-text" | "gem" | "hotel" | "settings"; + previewHref: `/admin-next/${HousekeepingDomainId}`; + capability: CapabilityRequirement; + routes: readonly HousekeepingRouteDefinition[]; +} diff --git a/src/features/housekeeping/foundation/contracts/inbox.ts b/src/features/housekeeping/foundation/contracts/inbox.ts new file mode 100644 index 0000000000..c402c0f86a --- /dev/null +++ b/src/features/housekeeping/foundation/contracts/inbox.ts @@ -0,0 +1,36 @@ +import type { HousekeepingDomainId } from "../../migration/types"; +import type { + CapabilityRequirement, + HousekeepingCapabilityContext, +} from "./capability"; +import type { HousekeepingResult } from "./result"; + +export type HousekeepingWorkItemSeverity = "info" | "warning" | "critical"; +export type HousekeepingWorkItemFreshness = "fresh" | "stale"; +export type HousekeepingInboxSourceAvailability = "available" | "unavailable"; + +export interface HousekeepingWorkItem { + sourceId: string; + itemId: string; + deduplicationKey: string; + domain: HousekeepingDomainId; + capability: CapabilityRequirement; + severity: HousekeepingWorkItemSeverity; + occurredAt: string; + titleKey: string; + context?: Readonly>; + href: string; + freshness: HousekeepingWorkItemFreshness; +} + +export interface HousekeepingInboxSourceResult { + items: readonly HousekeepingWorkItem[]; + availability: HousekeepingInboxSourceAvailability; +} + +export interface HousekeepingInboxSource { + getItems( + context: HousekeepingCapabilityContext, + signal: AbortSignal, + ): Promise>; +} diff --git a/src/features/housekeeping/foundation/contracts/index.ts b/src/features/housekeeping/foundation/contracts/index.ts new file mode 100644 index 0000000000..ab48192f58 --- /dev/null +++ b/src/features/housekeeping/foundation/contracts/index.ts @@ -0,0 +1,33 @@ +export { + allCapabilities, + anyCapability, + type CapabilityRequirement, + type HousekeepingActor, + type HousekeepingCapabilityContext, +} from "./capability"; +export type { HousekeepingCommand } from "./command"; +export type { + HousekeepingDomainManifest, + HousekeepingRouteDefinition, +} from "./domain"; +export type { + HousekeepingInboxSource, + HousekeepingInboxSourceAvailability, + HousekeepingInboxSourceResult, + HousekeepingWorkItem, + HousekeepingWorkItemFreshness, + HousekeepingWorkItemSeverity, +} from "./inbox"; +export type { HousekeepingQuery } from "./query"; +export { + fail, + type HousekeepingErrorCode, + type HousekeepingResult, + ok, +} from "./result"; +export type { + HousekeepingSearchInput, + HousekeepingSearchProvider, + HousekeepingSearchResult, +} from "./search"; +export type { HousekeepingWidgetDefinition } from "./widget"; diff --git a/src/features/housekeeping/foundation/contracts/query.ts b/src/features/housekeeping/foundation/contracts/query.ts new file mode 100644 index 0000000000..b889ffde73 --- /dev/null +++ b/src/features/housekeeping/foundation/contracts/query.ts @@ -0,0 +1,16 @@ +import type { HousekeepingDomainId } from "../../migration/types"; +import type { + CapabilityRequirement, + HousekeepingCapabilityContext, +} from "./capability"; +import type { HousekeepingResult } from "./result"; + +export interface HousekeepingQuery { + id: string; + owner: HousekeepingDomainId; + capability: CapabilityRequirement; + run( + context: HousekeepingCapabilityContext, + input: I, + ): Promise>; +} diff --git a/src/features/housekeeping/foundation/contracts/result.ts b/src/features/housekeeping/foundation/contracts/result.ts new file mode 100644 index 0000000000..e838ad5925 --- /dev/null +++ b/src/features/housekeeping/foundation/contracts/result.ts @@ -0,0 +1,28 @@ +export type HousekeepingErrorCode = + | "VALIDATION_FAILED" + | "AUTHENTICATION_REQUIRED" + | "CAPABILITY_DENIED" + | "NOT_FOUND" + | "CONFLICT" + | "DEPENDENCY_UNAVAILABLE" + | "PARTIAL_RESULT" + | "INTERNAL_ERROR"; + +export type HousekeepingResult = + | { ok: true; data: T; correlationId: string } + | { + ok: false; + error: { code: HousekeepingErrorCode }; + correlationId: string; + }; + +export function ok(data: T, correlationId: string): HousekeepingResult { + return { ok: true, data, correlationId }; +} + +export function fail( + code: HousekeepingErrorCode, + correlationId: string, +): HousekeepingResult { + return { ok: false, error: { code }, correlationId }; +} diff --git a/src/features/housekeeping/foundation/contracts/search.ts b/src/features/housekeeping/foundation/contracts/search.ts new file mode 100644 index 0000000000..744aa3d836 --- /dev/null +++ b/src/features/housekeeping/foundation/contracts/search.ts @@ -0,0 +1,22 @@ +import type { HousekeepingDomainId } from "../../migration/types"; +import type { HousekeepingCapabilityContext } from "./capability"; +import type { HousekeepingResult } from "./result"; + +export interface HousekeepingSearchInput { + term: string; + limit: number; +} + +export interface HousekeepingSearchResult { + id: string; + domain: HousekeepingDomainId; + title: string; + href: string; +} + +export interface HousekeepingSearchProvider { + search( + context: HousekeepingCapabilityContext, + input: HousekeepingSearchInput, + ): Promise>; +} diff --git a/src/features/housekeeping/foundation/contracts/widget.ts b/src/features/housekeeping/foundation/contracts/widget.ts new file mode 100644 index 0000000000..af3929e35f --- /dev/null +++ b/src/features/housekeeping/foundation/contracts/widget.ts @@ -0,0 +1,8 @@ +import type { HousekeepingCapabilityContext } from "./capability"; +import type { HousekeepingResult } from "./result"; + +export interface HousekeepingWidgetDefinition { + load( + context: HousekeepingCapabilityContext, + ): Promise>; +}