/** * Webhook event catalog — the SINGLE SOURCE OF TRUTH for every event a * webhook can be subscribed to (TASK-0130). * * The catalog drives, from one place: * 1. dispatcher matching (worker `webhooks` consumer + fan-out), * 2. API validation (a subscription with an unknown event → 400 listing the * catalog names), * 3. the completeness integration test (every catalog event must fire on its * operation AND be delivered), and * 4. the developer-hub event explorer (description + schema + example). * * This is the 13-event catalog named in the TASK-0130 brief (the public * webhook-safe, tenant-scoped content events — see `docs/api/webhooks.md` * §3). It intentionally EXCLUDES the platform-internal events the worker's * older local catalog listed (`user.login`, `user.register`, * `permission.changed`, `workspace.created`) — those are stream-internal and * are not offered to third-party subscribers. * * A NEW entry here with no matching emitter in the operation's service layer * will FAIL the completeness test (the build gate the task requires): * "a new event added to the catalog with no emitter fails the build." * * The `payloadSchema` documents the ACTUAL `data` object the operation's * emitter publishes onto the event bus (the worker forwards `payload` * verbatim as the envelope's `data`). Keep the schema in sync with the * emitter — the examples validate against it (`catalogSelfTestFailures`). */ import { z } from "zod"; /** One catalog row: identity + human docs + a zod schema for `data`. */ export interface WebhookEventCatalogEntry { /** The event name (also the subscription value + `X-Factory-Event`). */ name: string; /** When it fires (event explorer copy). */ description: string; /** Zod schema for the `data` payload (validates examples + real payloads). */ payloadSchema: z.ZodTypeAny; /** A minimal example `data` object (validates against payloadSchema). */ example: Record; /** The operation that should fire it (completeness-test documentation). */ firedBy: string; } /** The full catalog (13 events, in docs §3 order). */ export const WEBHOOK_EVENT_CATALOG: readonly WebhookEventCatalogEntry[] = [ { name: "page.created", description: "A page was created (including duplicated and templated pages).", firedBy: "pages service create/duplicate/template", payloadSchema: z.object({ workspaceId: z.string(), pageId: z.string(), title: z.string(), parentId: z.string().nullable().optional(), databaseId: z.string().optional(), fromTemplate: z.string().optional() }), example: { workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", pageId: "pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8", title: "Q2 Launch Checklist", parentId: null, databaseId: undefined } }, { name: "page.updated", description: "Page metadata/move/archive changed (block edits also arrive as page.updated).", firedBy: "pages service update/move/archive/restore; block ops", payloadSchema: z .object({ workspaceId: z.string(), pageId: z.string(), fields: z.array(z.string()).optional(), blockIds: z.array(z.string()).optional(), changed: z.string().optional(), restoredVersion: z.string().optional() }) .passthrough(), example: { workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", pageId: "pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8", fields: ["title"] } }, { name: "page.deleted", description: "A page was moved to the trash (or permanently purged).", firedBy: "pages service delete/trash", payloadSchema: z.object({ workspaceId: z.string(), pageId: z.string(), permanent: z.boolean(), pageIds: z.array(z.string()) }), example: { workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", pageId: "pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8", permanent: false, pageIds: ["pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8"] } }, { name: "page.shared", description: "The effective visibility of a page changed (share link created/changed, team move).", firedBy: "share service create/update share link", payloadSchema: z.object({ workspaceId: z.string(), shareId: z.string(), targetType: z.string(), targetId: z.string(), scope: z.string() }), example: { workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", shareId: "shr_01J9ZKQA2C4D6E8F0A2B4C6D8E", targetType: "page", targetId: "pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8", scope: "PUBLIC_LINK_VIEW" } }, { name: "database.created", description: "A database was created.", firedBy: "pages service create (type: database)", payloadSchema: z.object({ workspaceId: z.string(), databaseId: z.string(), pageId: z.string(), propertyCount: z.number() }), example: { workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", databaseId: "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0", pageId: "pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8", propertyCount: 2 } }, { name: "database.updated", description: "Database metadata or schema (property/view) changed.", firedBy: "databases service update/property/view ops", payloadSchema: z .object({ workspaceId: z.string(), databaseId: z.string(), fields: z.array(z.string()).optional(), deleted: z.boolean().optional(), propertyId: z.string().optional(), viewId: z.string().optional(), reordered: z.boolean().optional() }) .passthrough(), example: { workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", databaseId: "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0", propertyId: "prop_01J9ZKQ8F5G7H9J1K3L5M7N9P1" } }, { name: "record.created", description: "A record was created (including via a form submission).", firedBy: "databases query service create / form submission", payloadSchema: z.object({ workspaceId: z.string(), databaseId: z.string(), recordId: z.string(), changedProperties: z.array(z.string()) }), example: { workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", databaseId: "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0", recordId: "rpt_01J9ZKR3F4A6B8C0D2E4F6A8B0", changedProperties: ["*"] } }, { name: "record.updated", description: "A record's properties changed.", firedBy: "databases query service update / bulk ops", payloadSchema: z.object({ workspaceId: z.string(), databaseId: z.string(), recordId: z.string(), changedProperties: z.array(z.string()) }), example: { workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", databaseId: "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0", recordId: "rpt_01J9ZKR3F4A6B8C0D2E4F6A8B0", changedProperties: ["Title"] } }, { name: "comment.created", description: "A new top-level comment (or reply) was added.", firedBy: "comments service create", payloadSchema: z.object({ workspaceId: z.string(), commentId: z.string(), replyId: z.string().optional(), pageId: z.string().optional(), blockId: z.string().nullable().optional(), recordId: z.string().nullable().optional() }), example: { workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", commentId: "cmt_01J9ZKQA1B3C5D7E9F1A3B5C7D", pageId: "pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8", blockId: null, recordId: null } }, { name: "form.submitted", description: "A public or in-workspace form was submitted.", firedBy: "public-forms service submit", payloadSchema: z.object({ workspaceId: z.string(), formId: z.string(), slug: z.string(), submissionId: z.string(), recordId: z.string().optional(), databaseId: z.string().optional(), questionCount: z.number(), anonymous: z.boolean(), ip: z.string().nullable() }), example: { workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", formId: "frm_01J9ZKQA7C9D1E3F5A7B9C1D3E5", slug: "feedback", submissionId: "sub_01J9ZKQA8D0E2F4G6B8C0D2E4F6", recordId: "rpt_01J9ZKQF5E6F7A8B9C0D1E2F3A", databaseId: "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0", questionCount: 3, anonymous: false, ip: "203.0.113.0/24" } }, { name: "automation.executed", description: "An automation run finished (success or failure).", firedBy: "worker automations processor (run finished)", payloadSchema: z .object({ workspaceId: z.string(), automationId: z.string(), runId: z.string(), status: z.string() }) .passthrough(), example: { workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", automationId: "auto_01J9ZKPE4G6H8J0L2M4N6P8Q0", runId: "run_01J9ZKPF5H7I9L1M3N5P7Q9R1", status: "SUCCEEDED" } }, { name: "file.uploaded", description: "A file reached the READY state in the upload pipeline.", firedBy: "files pipeline (READY finalization)", payloadSchema: z .object({ workspaceId: z.string(), fileId: z.string(), name: z.string().optional(), size: z.number().optional(), mimeType: z.string().optional() }) .passthrough(), example: { workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", fileId: "fil_01J9ZKP3D8F0A2B4C6D8E0F2A4", name: "spec.pdf", size: 18244, mimeType: "application/pdf" } }, { name: "api_key.created", description: "An API key was created (never includes the raw key).", firedBy: "api-keys service create", payloadSchema: z.object({ workspaceId: z.string(), apiKeyId: z.string(), name: z.string(), scopes: z.array(z.string()) }), example: { workspaceId: "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", apiKeyId: "apk_01J9ZKPS9J2L4M6N8P0Q2R4S6", name: "CI pipeline", scopes: ["READ", "WRITE"] } } ]; /** The plain list of catalog event NAMES (dispatcher matching / validation). */ export const WEBHOOK_EVENT_NAMES: readonly string[] = WEBHOOK_EVENT_CATALOG.map((e) => e.name); /** Look up a catalog entry by name (null if not a webhook event). */ export function webhookEventCatalogEntry(name: string): WebhookEventCatalogEntry | null { return WEBHOOK_EVENT_CATALOG.find((e) => e.name === name) ?? null; } /** Whether a name is in the catalog (or the `*` wildcard). */ export function isWebhookEvent(name: string): boolean { return name === "*" || WEBHOOK_EVENT_NAMES.includes(name); } /** * Self-test used by the catalog unit test: every example must validate * against its own payloadSchema. Returns the list of failing event names * (empty when the catalog is internally consistent). */ export function catalogSelfTestFailures(): string[] { const bad: string[] = []; for (const e of WEBHOOK_EVENT_CATALOG) { const res = e.payloadSchema.safeParse(e.example); if (!res.success) bad.push(e.name); } return bad; } /** Validate a subscription's `events[]` against the catalog (allow `*`). */ export function validateWebhookEvents(events: string[]): { valid: boolean; unknown: string[] } { const unknown = events.filter((e) => !isWebhookEvent(e)); return { valid: unknown.length === 0, unknown }; }