"use server" import { revalidatePath } from "next/cache" import { and, eq } from "drizzle-orm" import { db } from "@/lib/db" import { webhook_endpoints } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" import { getAccountContext } from "@/lib/account" import { webhookEndpointSchema } from "@/lib/validations" import { isWebhookEvent, type WebhookEvent } from "@/lib/webhooks/events" import { assertSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf" import { generateWebhookSecret } from "@/lib/webhooks/deliver" import { deliverTestPing } from "@/lib/webhooks/emit" // ============================================================================ // Webhook endpoint management (dashboard, session-authed). // // Endpoints belong to the ACCOUNT OWNER (team-aware) so every event across the // portfolio is delivered. Writes require canWrite (viewers are read-only). The // signing secret is stored so it can be shown in the dashboard and used to sign // deliveries; it is not a bearer credential. // ============================================================================ const SETTINGS_PATH = "/settings/webhooks" export type WebhookEndpointDTO = { id: string url: string description: string | null events: string[] secret: string status: "active" | "disabled" source: "dashboard" | "api" | "zapier" last_success_at: string | null last_error_at: string | null last_error: string | null failure_count: number created_at: string } function toDTO(row: typeof webhook_endpoints.$inferSelect): WebhookEndpointDTO { return { id: row.id, url: row.url, description: row.description, events: row.events, secret: row.secret, status: row.status, source: row.source, last_success_at: row.last_success_at, last_error_at: row.last_error_at, last_error: row.last_error, failure_count: row.failure_count, created_at: row.created_at, } } /** Resolve the writing account owner or throw a user-facing error. */ async function requireWriter(): Promise { const user = await getSessionUser() if (!user) throw new Error("Unauthorized") const ctx = await getAccountContext(user.id) if (!ctx.canWrite) throw new Error("You do not have permission to manage webhooks.") return ctx.ownerId } function sanitizeEvents(events: unknown): WebhookEvent[] { if (!Array.isArray(events)) return [] return Array.from(new Set(events.filter(isWebhookEvent))) } export async function createWebhookEndpoint(input: { url: string events: string[] description?: string }): Promise { const ownerId = await requireWriter() const parsed = webhookEndpointSchema.safeParse(input) if (!parsed.success) { throw new Error(parsed.error.issues[0]?.message ?? "Invalid webhook configuration") } try { await assertSafeWebhookUrl(parsed.data.url) } catch (e) { throw new Error(e instanceof WebhookUrlError ? e.message : "Invalid webhook URL") } const [row] = await db .insert(webhook_endpoints) .values({ user_id: ownerId, url: parsed.data.url, description: parsed.data.description || null, events: sanitizeEvents(parsed.data.events), secret: generateWebhookSecret(), source: "dashboard", }) .returning() revalidatePath(SETTINGS_PATH) return toDTO(row) } export async function updateWebhookEndpoint( id: string, input: { url?: string; events?: string[]; description?: string; status?: "active" | "disabled" } ): Promise { const ownerId = await requireWriter() if (typeof id !== "string" || !id) throw new Error("Invalid endpoint id") const existing = await db.query.webhook_endpoints.findFirst({ where: and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ownerId)), }) if (!existing) throw new Error("Webhook not found") const patch: Partial = {} if (input.url !== undefined) { const parsed = webhookEndpointSchema.shape.url.safeParse(input.url) if (!parsed.success) throw new Error(parsed.error.issues[0]?.message ?? "Invalid URL") try { await assertSafeWebhookUrl(parsed.data) } catch (e) { throw new Error(e instanceof WebhookUrlError ? e.message : "Invalid webhook URL") } patch.url = parsed.data } if (input.events !== undefined) patch.events = sanitizeEvents(input.events) if (input.description !== undefined) patch.description = input.description.slice(0, 200) || null if (input.status !== undefined) { if (input.status !== "active" && input.status !== "disabled") throw new Error("Invalid status") patch.status = input.status } const [row] = await db .update(webhook_endpoints) .set(patch) .where(and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ownerId))) .returning() revalidatePath(SETTINGS_PATH) return toDTO(row) } export async function deleteWebhookEndpoint(id: string): Promise { const ownerId = await requireWriter() if (typeof id !== "string" || !id) throw new Error("Invalid endpoint id") await db .delete(webhook_endpoints) .where(and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ownerId))) revalidatePath(SETTINGS_PATH) } export async function rotateWebhookSecret(id: string): Promise<{ secret: string }> { const ownerId = await requireWriter() if (typeof id !== "string" || !id) throw new Error("Invalid endpoint id") const secret = generateWebhookSecret() const [row] = await db .update(webhook_endpoints) .set({ secret }) .where(and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ownerId))) .returning({ id: webhook_endpoints.id }) if (!row) throw new Error("Webhook not found") revalidatePath(SETTINGS_PATH) return { secret } } export async function sendTestWebhook( id: string ): Promise<{ ok: boolean; responseStatus: number | null; error: string | null }> { const ownerId = await requireWriter() if (typeof id !== "string" || !id) throw new Error("Invalid endpoint id") const endpoint = await db.query.webhook_endpoints.findFirst({ where: and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ownerId)), }) if (!endpoint) throw new Error("Webhook not found") const result = await deliverTestPing(endpoint) revalidatePath(SETTINGS_PATH) return result }