From cba4d44f16f9c087dbd4b01690bab1a576d6a379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 21:18:29 +0200 Subject: [PATCH] refactor(api): extract webhook procedures --- docs/api-router-procedure-support-backlog.md | 1 + .../webhook-procedure-support.test.ts | 206 ++++++++++++++++++ .../src/router/webhook-procedure-support.ts | 130 +++++++++++ packages/api/src/router/webhook.ts | 119 ++-------- 4 files changed, 358 insertions(+), 98 deletions(-) create mode 100644 packages/api/src/__tests__/webhook-procedure-support.test.ts create mode 100644 packages/api/src/router/webhook-procedure-support.ts diff --git a/docs/api-router-procedure-support-backlog.md b/docs/api-router-procedure-support-backlog.md index 0fc4cdb..4b51423 100644 --- a/docs/api-router-procedure-support-backlog.md +++ b/docs/api-router-procedure-support-backlog.md @@ -25,6 +25,7 @@ Done - `system-role-config` - `audit-log` - `calculation-rules` +- `webhook` Ready next - none in the conflict-safe backlog diff --git a/packages/api/src/__tests__/webhook-procedure-support.test.ts b/packages/api/src/__tests__/webhook-procedure-support.test.ts new file mode 100644 index 0000000..4b9ac9e --- /dev/null +++ b/packages/api/src/__tests__/webhook-procedure-support.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createAuditEntry } from "../lib/audit.js"; +import { + createWebhook, + deleteWebhook, + getWebhookById, + listWebhooks, + testWebhook, + updateWebhook, +} from "../router/webhook-procedure-support.js"; +import * as webhookSupport from "../router/webhook-support.js"; + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn(), +})); + +function createContext(db: Record) { + return { + db: db as never, + dbUser: { id: "user_admin" }, + }; +} + +describe("webhook-procedure-support", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lists webhooks ordered by creation date descending", async () => { + const findMany = vi.fn().mockResolvedValue([{ id: "wh_1" }]); + const ctx = createContext({ + webhook: { findMany }, + }); + + const result = await listWebhooks(ctx); + + expect(result).toEqual([{ id: "wh_1" }]); + expect(findMany).toHaveBeenCalledWith({ + orderBy: { createdAt: "desc" }, + }); + }); + + it("loads a webhook by id", async () => { + const findUnique = vi.fn().mockResolvedValue({ id: "wh_1", name: "Primary" }); + const ctx = createContext({ + webhook: { findUnique }, + }); + + const result = await getWebhookById(ctx, { id: "wh_1" }); + + expect(result).toEqual({ id: "wh_1", name: "Primary" }); + expect(findUnique).toHaveBeenCalledWith({ where: { id: "wh_1" } }); + }); + + it("creates a webhook and records an audit entry", async () => { + const created = { + id: "wh_2", + name: "Primary", + url: "https://example.com/webhook", + events: ["project.created"], + isActive: true, + }; + const create = vi.fn().mockResolvedValue(created); + const ctx = createContext({ + webhook: { create }, + }); + + const result = await createWebhook(ctx, { + name: "Primary", + url: "https://example.com/webhook", + events: ["project.created"], + isActive: true, + }); + + expect(result).toBe(created); + expect(create).toHaveBeenCalledWith({ + data: { + name: "Primary", + url: "https://example.com/webhook", + events: ["project.created"], + isActive: true, + }, + }); + expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ + db: ctx.db, + entityType: "Webhook", + entityId: "wh_2", + entityName: "Primary", + action: "CREATE", + userId: "user_admin", + after: created, + source: "ui", + })); + }); + + it("updates a webhook and records before/after audit snapshots", async () => { + const before = { + id: "wh_3", + name: "Primary", + url: "https://old.example.com/webhook", + }; + const after = { + id: "wh_3", + name: "Primary", + url: "https://new.example.com/webhook", + }; + const findUnique = vi.fn().mockResolvedValue(before); + const update = vi.fn().mockResolvedValue(after); + const ctx = createContext({ + webhook: { findUnique, update }, + }); + + const result = await updateWebhook(ctx, { + id: "wh_3", + data: { url: "https://new.example.com/webhook" }, + }); + + expect(result).toBe(after); + expect(findUnique).toHaveBeenCalledWith({ where: { id: "wh_3" } }); + expect(update).toHaveBeenCalledWith({ + where: { id: "wh_3" }, + data: { + url: "https://new.example.com/webhook", + }, + }); + expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ + db: ctx.db, + entityType: "Webhook", + entityId: "wh_3", + entityName: "Primary", + action: "UPDATE", + userId: "user_admin", + before, + after, + source: "ui", + })); + }); + + it("deletes a webhook and records a delete audit entry", async () => { + const existing = { id: "wh_4", name: "Legacy Webhook" }; + const findUnique = vi.fn().mockResolvedValue(existing); + const deleteFn = vi.fn().mockResolvedValue(existing); + const ctx = createContext({ + webhook: { findUnique, delete: deleteFn }, + }); + + await expect(deleteWebhook(ctx, { id: "wh_4" })).resolves.toBeUndefined(); + + expect(findUnique).toHaveBeenCalledWith({ where: { id: "wh_4" } }); + expect(deleteFn).toHaveBeenCalledWith({ where: { id: "wh_4" } }); + expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ + db: ctx.db, + entityType: "Webhook", + entityId: "wh_4", + entityName: "Legacy Webhook", + action: "DELETE", + userId: "user_admin", + before: existing, + source: "ui", + })); + }); + + it("tests a webhook delivery and records the result", async () => { + const existing = { + id: "wh_5", + name: "Primary", + url: "https://example.com/webhook", + secret: null, + }; + const findUnique = vi.fn().mockResolvedValue(existing); + const sendWebhookTestRequestSpy = vi + .spyOn(webhookSupport, "sendWebhookTestRequest") + .mockResolvedValue({ + success: true, + statusCode: 202, + statusText: "Accepted", + }); + const ctx = createContext({ + webhook: { findUnique }, + }); + + const result = await testWebhook(ctx, { id: "wh_5" }); + + expect(result).toEqual({ + success: true, + statusCode: 202, + statusText: "Accepted", + }); + expect(sendWebhookTestRequestSpy).toHaveBeenCalledWith(existing); + expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ + db: ctx.db, + entityType: "Webhook", + entityId: "wh_5", + entityName: "Primary", + action: "UPDATE", + userId: "user_admin", + summary: "Tested webhook (result: success)", + metadata: { + success: true, + statusCode: 202, + statusText: "Accepted", + }, + source: "ui", + })); + }); +}); diff --git a/packages/api/src/router/webhook-procedure-support.ts b/packages/api/src/router/webhook-procedure-support.ts new file mode 100644 index 0000000..f23a894 --- /dev/null +++ b/packages/api/src/router/webhook-procedure-support.ts @@ -0,0 +1,130 @@ +import { z } from "zod"; +import { createAuditEntry } from "../lib/audit.js"; +import type { TRPCContext } from "../trpc.js"; +import { + buildWebhookCreateData, + buildWebhookUpdateData, + createWebhookInputSchema, + loadWebhookOrThrow, + sendWebhookTestRequest, + updateWebhookInputSchema, +} from "./webhook-support.js"; + +export const WebhookIdInputSchema = z.object({ + id: z.string(), +}); + +export const CreateWebhookInputSchema = createWebhookInputSchema; + +export const UpdateWebhookProcedureInputSchema = z.object({ + id: z.string(), + data: updateWebhookInputSchema, +}); + +type WebhookProcedureContext = Pick; + +function withAuditUser(userId: string | undefined) { + return userId ? { userId } : {}; +} + +export async function listWebhooks(ctx: WebhookProcedureContext) { + return ctx.db.webhook.findMany({ + orderBy: { createdAt: "desc" }, + }); +} + +export async function getWebhookById( + ctx: WebhookProcedureContext, + input: z.infer, +) { + return loadWebhookOrThrow(ctx.db, input.id); +} + +export async function createWebhook( + ctx: WebhookProcedureContext, + input: z.infer, +) { + const webhook = await ctx.db.webhook.create({ + data: buildWebhookCreateData(input), + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Webhook", + entityId: webhook.id, + entityName: webhook.name, + action: "CREATE", + ...withAuditUser(ctx.dbUser?.id), + after: webhook as unknown as Record, + source: "ui", + }); + + return webhook; +} + +export async function updateWebhook( + ctx: WebhookProcedureContext, + input: z.infer, +) { + const existing = await loadWebhookOrThrow(ctx.db, input.id); + + const updated = await ctx.db.webhook.update({ + where: { id: input.id }, + data: buildWebhookUpdateData(input.data), + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Webhook", + entityId: input.id, + entityName: updated.name, + action: "UPDATE", + ...withAuditUser(ctx.dbUser?.id), + before: existing as unknown as Record, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; +} + +export async function deleteWebhook( + ctx: WebhookProcedureContext, + input: z.infer, +) { + const existing = await loadWebhookOrThrow(ctx.db, input.id); + await ctx.db.webhook.delete({ where: { id: input.id } }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Webhook", + entityId: input.id, + entityName: existing.name, + action: "DELETE", + ...withAuditUser(ctx.dbUser?.id), + before: existing as unknown as Record, + source: "ui", + }); +} + +export async function testWebhook( + ctx: WebhookProcedureContext, + input: z.infer, +) { + const webhook = await loadWebhookOrThrow(ctx.db, input.id); + const result = await sendWebhookTestRequest(webhook); + + void createAuditEntry({ + db: ctx.db, + entityType: "Webhook", + entityId: webhook.id, + entityName: webhook.name, + action: "UPDATE", + ...withAuditUser(ctx.dbUser?.id), + summary: `Tested webhook (result: ${result.success ? "success" : "failed"})`, + metadata: result as unknown as Record, + source: "ui", + }); + + return result; +} diff --git a/packages/api/src/router/webhook.ts b/packages/api/src/router/webhook.ts index 1121f64..1574599 100644 --- a/packages/api/src/router/webhook.ts +++ b/packages/api/src/router/webhook.ts @@ -1,119 +1,42 @@ -import { z } from "zod"; import { createTRPCRouter, adminProcedure } from "../trpc.js"; -import { createAuditEntry } from "../lib/audit.js"; import { - buildWebhookCreateData, - buildWebhookUpdateData, - createWebhookInputSchema, - loadWebhookOrThrow, - sendWebhookTestRequest, - updateWebhookInputSchema, -} from "./webhook-support.js"; + CreateWebhookInputSchema, + createWebhook, + deleteWebhook, + getWebhookById, + listWebhooks, + testWebhook, + UpdateWebhookProcedureInputSchema, + updateWebhook, + WebhookIdInputSchema, +} from "./webhook-procedure-support.js"; export const webhookRouter = createTRPCRouter({ /** List all webhooks. */ - list: adminProcedure.query(async ({ ctx }) => { - return ctx.db.webhook.findMany({ - orderBy: { createdAt: "desc" }, - }); - }), + list: adminProcedure.query(({ ctx }) => listWebhooks(ctx)), /** Get a single webhook by ID. */ getById: adminProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => loadWebhookOrThrow(ctx.db, input.id)), + .input(WebhookIdInputSchema) + .query(({ ctx, input }) => getWebhookById(ctx, input)), /** Create a new webhook. */ create: adminProcedure - .input(createWebhookInputSchema) - .mutation(async ({ ctx, input }) => { - const webhook = await ctx.db.webhook.create({ - data: buildWebhookCreateData(input), - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "Webhook", - entityId: webhook.id, - entityName: webhook.name, - action: "CREATE", - userId: ctx.dbUser?.id, - after: webhook as unknown as Record, - source: "ui", - }); - - return webhook; - }), + .input(CreateWebhookInputSchema) + .mutation(({ ctx, input }) => createWebhook(ctx, input)), /** Update an existing webhook. */ update: adminProcedure - .input( - z.object({ - id: z.string(), - data: updateWebhookInputSchema, - }), - ) - .mutation(async ({ ctx, input }) => { - const existing = await loadWebhookOrThrow(ctx.db, input.id); - - const updated = await ctx.db.webhook.update({ - where: { id: input.id }, - data: buildWebhookUpdateData(input.data), - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "Webhook", - entityId: input.id, - entityName: updated.name, - action: "UPDATE", - userId: ctx.dbUser?.id, - before: existing as unknown as Record, - after: updated as unknown as Record, - source: "ui", - }); - - return updated; - }), + .input(UpdateWebhookProcedureInputSchema) + .mutation(({ ctx, input }) => updateWebhook(ctx, input)), /** Delete a webhook. */ delete: adminProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { - const existing = await loadWebhookOrThrow(ctx.db, input.id); - await ctx.db.webhook.delete({ where: { id: input.id } }); - - void createAuditEntry({ - db: ctx.db, - entityType: "Webhook", - entityId: input.id, - entityName: existing.name, - action: "DELETE", - userId: ctx.dbUser?.id, - before: existing as unknown as Record, - source: "ui", - }); - }), + .input(WebhookIdInputSchema) + .mutation(({ ctx, input }) => deleteWebhook(ctx, input)), /** Send a test payload to a webhook URL. */ test: adminProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { - const wh = await loadWebhookOrThrow(ctx.db, input.id); - const result = await sendWebhookTestRequest(wh); - - void createAuditEntry({ - db: ctx.db, - entityType: "Webhook", - entityId: wh.id, - entityName: wh.name, - action: "UPDATE", - userId: ctx.dbUser?.id, - summary: `Tested webhook (result: ${result.success ? "success" : "failed"})`, - metadata: result as unknown as Record, - source: "ui", - }); - - return result; - }), + .input(WebhookIdInputSchema) + .mutation(({ ctx, input }) => testWebhook(ctx, input)), });