import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { createTRPCRouter, adminProcedure } from "../trpc.js"; import { WEBHOOK_EVENTS } from "../lib/webhook-dispatcher.js"; import { createAuditEntry } from "../lib/audit.js"; const webhookEventEnum = z.enum(WEBHOOK_EVENTS as unknown as [string, ...string[]]); export const webhookRouter = createTRPCRouter({ /** List all webhooks. */ list: adminProcedure.query(async ({ ctx }) => { return ctx.db.webhook.findMany({ orderBy: { createdAt: "desc" }, }); }), /** Get a single webhook by ID. */ getById: adminProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const wh = await ctx.db.webhook.findUnique({ where: { id: input.id } }); if (!wh) { throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" }); } return wh; }), /** Create a new webhook. */ create: adminProcedure .input( z.object({ name: z.string().min(1).max(200), url: z.string().url(), secret: z.string().optional(), events: z.array(webhookEventEnum).min(1), isActive: z.boolean().default(true), }), ) .mutation(async ({ ctx, input }) => { const webhook = await ctx.db.webhook.create({ data: { name: input.name, url: input.url, ...(input.secret !== undefined ? { secret: input.secret } : {}), events: input.events, isActive: input.isActive, }, }); 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; }), /** Update an existing webhook. */ update: adminProcedure .input( z.object({ id: z.string(), data: z.object({ name: z.string().min(1).max(200).optional(), url: z.string().url().optional(), secret: z.string().nullish(), events: z.array(webhookEventEnum).min(1).optional(), isActive: z.boolean().optional(), }), }), ) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.webhook.findUnique({ where: { id: input.id } }); if (!existing) { throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" }); } const updated = await ctx.db.webhook.update({ where: { id: input.id }, data: { ...(input.data.name !== undefined ? { name: input.data.name } : {}), ...(input.data.url !== undefined ? { url: input.data.url } : {}), ...(input.data.secret !== undefined ? { secret: input.data.secret } : {}), ...(input.data.events !== undefined ? { events: input.data.events } : {}), ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}), }, }); 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; }), /** Delete a webhook. */ delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.webhook.findUnique({ where: { id: input.id } }); if (!existing) { throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" }); } 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", }); }), /** Send a test payload to a webhook URL. */ test: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const wh = await ctx.db.webhook.findUnique({ where: { id: input.id } }); if (!wh) { throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" }); } const testPayload = { event: "webhook.test", timestamp: new Date().toISOString(), payload: { webhookId: wh.id, webhookName: wh.name, message: "This is a test payload from CapaKraken.", }, }; const body = JSON.stringify(testPayload); const headers: Record = { "Content-Type": "application/json", "X-Webhook-Event": "webhook.test", }; if (wh.secret) { const { createHmac } = await import("node:crypto"); const signature = createHmac("sha256", wh.secret) .update(body) .digest("hex"); headers["X-Webhook-Signature"] = signature; } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5_000); let result: { success: boolean; statusCode: number; statusText: string }; try { const response = await fetch(wh.url, { method: "POST", headers, body, signal: controller.signal, }); result = { success: response.ok, statusCode: response.status, statusText: response.statusText, }; } catch (err) { result = { success: false, statusCode: 0, statusText: err instanceof Error ? err.message : "Unknown error", }; } finally { clearTimeout(timeout); } 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; }), });