e5d7ca1293
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>
208 lines
6.2 KiB
TypeScript
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;
|
|
}),
|
|
});
|