All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
307 lines
8.1 KiB
TypeScript
307 lines
8.1 KiB
TypeScript
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
|
|
}),
|
|
})
|