Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
This commit is contained in:
@@ -1,306 +1,306 @@
|
||||
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
|
||||
}),
|
||||
})
|
||||
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
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user