feat: define housekeeping foundation contracts
This commit is contained in:
1 parent
3b3d7780c9
commit
7d62a04f0e
10 files changed
+338
No files matched your search
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { HousekeepingDomainId } from "../../migration/types";
|
||||||
|
import type {
|
||||||
|
CapabilityRequirement,
|
||||||
|
HousekeepingCapabilityContext,
|
||||||
|
} from "./capability";
|
||||||
|
import type { HousekeepingResult } from "./result";
|
||||||
|
|
||||||
|
export interface HousekeepingCommand<I, O> {
|
||||||
|
id: string;
|
||||||
|
owner: HousekeepingDomainId;
|
||||||
|
risk: "safe" | "sensitive";
|
||||||
|
capability: CapabilityRequirement;
|
||||||
|
requiresReason: boolean;
|
||||||
|
execute(
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
input: I,
|
||||||
|
): Promise<HousekeepingResult<O>>;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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<Record<string, unknown>>;
|
||||||
|
href: string;
|
||||||
|
freshness: HousekeepingWorkItemFreshness;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HousekeepingInboxSourceResult {
|
||||||
|
items: readonly HousekeepingWorkItem[];
|
||||||
|
availability: HousekeepingInboxSourceAvailability;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HousekeepingInboxSource {
|
||||||
|
getItems(
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<HousekeepingResult<HousekeepingInboxSourceResult>>;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { HousekeepingDomainId } from "../../migration/types";
|
||||||
|
import type {
|
||||||
|
CapabilityRequirement,
|
||||||
|
HousekeepingCapabilityContext,
|
||||||
|
} from "./capability";
|
||||||
|
import type { HousekeepingResult } from "./result";
|
||||||
|
|
||||||
|
export interface HousekeepingQuery<I, O> {
|
||||||
|
id: string;
|
||||||
|
owner: HousekeepingDomainId;
|
||||||
|
capability: CapabilityRequirement;
|
||||||
|
run(
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
input: I,
|
||||||
|
): Promise<HousekeepingResult<O>>;
|
||||||
|
}
|
||||||
@@ -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<T> =
|
||||||
|
| { ok: true; data: T; correlationId: string }
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
error: { code: HousekeepingErrorCode };
|
||||||
|
correlationId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ok<T>(data: T, correlationId: string): HousekeepingResult<T> {
|
||||||
|
return { ok: true, data, correlationId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fail(
|
||||||
|
code: HousekeepingErrorCode,
|
||||||
|
correlationId: string,
|
||||||
|
): HousekeepingResult<never> {
|
||||||
|
return { ok: false, error: { code }, correlationId };
|
||||||
|
}
|
||||||
@@ -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<HousekeepingResult<readonly HousekeepingSearchResult[]>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { HousekeepingCapabilityContext } from "./capability";
|
||||||
|
import type { HousekeepingResult } from "./result";
|
||||||
|
|
||||||
|
export interface HousekeepingWidgetDefinition {
|
||||||
|
load(
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
): Promise<HousekeepingResult<unknown>>;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user