import { z } from 'zod' import { router, superAdminProcedure } from '../trpc' import { logAudit } from '@/server/utils/audit' import { generateWebhookSecret, deliverWebhook, } from '@/server/services/webhook-dispatcher' export const WEBHOOK_EVENTS = [ 'evaluation.submitted', 'evaluation.updated', 'project.created', 'project.statusChanged', 'round.activated', 'round.closed', 'stage.activated', 'stage.closed', 'assignment.created', 'assignment.completed', 'user.invited', 'user.activated', ] as const export const webhookRouter = router({ /** * List all webhooks with delivery stats. */ list: superAdminProcedure.query(async ({ ctx }) => { const webhooks = await ctx.prisma.webhook.findMany({ include: { _count: { select: { deliveries: true }, }, createdBy: { select: { id: true, name: true, email: true }, }, }, orderBy: { createdAt: 'desc' }, }) // Compute recent delivery stats for each webhook const now = new Date() const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000) const stats = await Promise.all( webhooks.map(async (wh) => { const [delivered, failed] = await Promise.all([ ctx.prisma.webhookDelivery.count({ where: { webhookId: wh.id, status: 'DELIVERED', createdAt: { gte: twentyFourHoursAgo }, }, }), ctx.prisma.webhookDelivery.count({ where: { webhookId: wh.id, status: 'FAILED', createdAt: { gte: twentyFourHoursAgo }, }, }), ]) return { ...wh, recentDelivered: delivered, recentFailed: failed, } }) ) return stats }), /** * Create a new webhook. */ create: superAdminProcedure .input( z.object({ name: z.string().min(1).max(200), url: z.string().url(), events: z.array(z.string()).min(1), headers: z.any().optional(), maxRetries: z.number().int().min(0).max(10).default(3), }) ) .mutation(async ({ ctx, input }) => { const secret = generateWebhookSecret() const webhook = await ctx.prisma.webhook.create({ data: { name: input.name, url: input.url, secret, events: input.events, headers: input.headers ?? undefined, maxRetries: input.maxRetries, createdById: ctx.user.id, }, }) try { await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'CREATE_WEBHOOK', entityType: 'Webhook', entityId: webhook.id, detailsJson: { name: input.name, url: input.url, events: input.events }, }) } catch {} return webhook }), /** * Update a webhook. */ update: superAdminProcedure .input( z.object({ id: z.string(), name: z.string().min(1).max(200).optional(), url: z.string().url().optional(), events: z.array(z.string()).min(1).optional(), headers: z.any().optional(), isActive: z.boolean().optional(), maxRetries: z.number().int().min(0).max(10).optional(), }) ) .mutation(async ({ ctx, input }) => { const { id, ...data } = input const webhook = await ctx.prisma.webhook.update({ where: { id }, data: { ...(data.name !== undefined ? { name: data.name } : {}), ...(data.url !== undefined ? { url: data.url } : {}), ...(data.events !== undefined ? { events: data.events } : {}), ...(data.headers !== undefined ? { headers: data.headers } : {}), ...(data.isActive !== undefined ? { isActive: data.isActive } : {}), ...(data.maxRetries !== undefined ? { maxRetries: data.maxRetries } : {}), }, }) try { await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'UPDATE_WEBHOOK', entityType: 'Webhook', entityId: id, detailsJson: { updatedFields: Object.keys(data) }, }) } catch {} return webhook }), /** * Delete a webhook and its delivery history. */ delete: superAdminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { // Cascade delete is defined in schema, so just delete the webhook await ctx.prisma.webhook.delete({ where: { id: input.id }, }) try { await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'DELETE_WEBHOOK', entityType: 'Webhook', entityId: input.id, }) } catch {} return { success: true } }), /** * Send a test payload to a webhook. */ test: superAdminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const webhook = await ctx.prisma.webhook.findUnique({ where: { id: input.id }, }) if (!webhook) { throw new Error('Webhook not found') } const testPayload = { event: 'test', timestamp: new Date().toISOString(), data: { message: 'This is a test webhook delivery from MOPC Platform.', webhookId: webhook.id, webhookName: webhook.name, }, } const delivery = await ctx.prisma.webhookDelivery.create({ data: { webhookId: webhook.id, event: 'test', payload: testPayload, status: 'PENDING', attempts: 0, }, }) await deliverWebhook(delivery.id) // Fetch updated delivery to get the result const result = await ctx.prisma.webhookDelivery.findUnique({ where: { id: delivery.id }, }) try { await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'TEST_WEBHOOK', entityType: 'Webhook', entityId: input.id, detailsJson: { deliveryStatus: result?.status }, }) } catch {} return result }), /** * Get paginated delivery log for a webhook. */ getDeliveryLog: superAdminProcedure .input( z.object({ webhookId: z.string(), page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(100).default(20), }) ) .query(async ({ ctx, input }) => { const skip = (input.page - 1) * input.pageSize const [items, total] = await Promise.all([ ctx.prisma.webhookDelivery.findMany({ where: { webhookId: input.webhookId }, orderBy: { createdAt: 'desc' }, skip, take: input.pageSize, }), ctx.prisma.webhookDelivery.count({ where: { webhookId: input.webhookId }, }), ]) return { items, total, page: input.page, pageSize: input.pageSize, totalPages: Math.ceil(total / input.pageSize), } }), /** * Regenerate the HMAC secret for a webhook. */ regenerateSecret: superAdminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const newSecret = generateWebhookSecret() const webhook = await ctx.prisma.webhook.update({ where: { id: input.id }, data: { secret: newSecret }, }) try { await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'REGENERATE_WEBHOOK_SECRET', entityType: 'Webhook', entityId: input.id, }) } catch {} return webhook }), /** * Get available webhook events. */ getAvailableEvents: superAdminProcedure.query(() => { return WEBHOOK_EVENTS }), })