Files
MOPC-Portal/src/server/routers/webhook.ts

307 lines
7.8 KiB
TypeScript
Raw Normal View History

Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
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',
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
'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
}),
})