Files
Nexus/packages/api/src/router/webhook.ts
T
Hartmut e5d7ca1293 refactor: rename Planarchy to CapaKraken (branding only)
User-facing rename across 20 files:
- Layout title/meta: "CapaKraken — Resource & Capacity Planning"
- Sidebar logo: "CapaKraken" with "RESOURCE & CAPACITY PLANNING"
- Sign-in page: "CapaKraken Control Center", "Sign in to CapaKraken"
- PWA manifest: name + short_name
- PDF reports: footer text
- Install prompt: "Install CapaKraken"
- AI assistant system prompt
- Webhooks test payload
- Email subject lines
- Tooltips, descriptions, empty states

NOT changed (technical identifiers):
- Package names (@planarchy/*)
- Import paths
- Database names
- Docker container names
- localStorage keys
- Domain URLs
- CLAUDE.md

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-25 21:46:15 +01:00

208 lines
6.2 KiB
TypeScript

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<string, unknown>,
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<string, unknown>,
after: updated as unknown as Record<string, unknown>,
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<string, unknown>,
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<string, string> = {
"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<string, unknown>,
source: "ui",
});
return result;
}),
});