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>
This commit is contained in:
174
src/server/services/webhook-dispatcher.ts
Normal file
174
src/server/services/webhook-dispatcher.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import crypto from 'crypto'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
/**
|
||||
* Dispatch a webhook event to all active webhooks subscribed to this event.
|
||||
*/
|
||||
export async function dispatchWebhookEvent(
|
||||
event: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<number> {
|
||||
const webhooks = await prisma.webhook.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
events: { has: event },
|
||||
},
|
||||
})
|
||||
|
||||
if (webhooks.length === 0) return 0
|
||||
|
||||
let deliveryCount = 0
|
||||
|
||||
for (const webhook of webhooks) {
|
||||
try {
|
||||
const delivery = await prisma.webhookDelivery.create({
|
||||
data: {
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
payload: payload as Prisma.InputJsonValue,
|
||||
status: 'PENDING',
|
||||
attempts: 0,
|
||||
},
|
||||
})
|
||||
|
||||
// Attempt delivery asynchronously (don't block the caller)
|
||||
deliverWebhook(delivery.id).catch((err) => {
|
||||
console.error(`[Webhook] Background delivery failed for ${delivery.id}:`, err)
|
||||
})
|
||||
|
||||
deliveryCount++
|
||||
} catch (error) {
|
||||
console.error(`[Webhook] Failed to create delivery for webhook ${webhook.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return deliveryCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to deliver a single webhook.
|
||||
*/
|
||||
export async function deliverWebhook(deliveryId: string): Promise<void> {
|
||||
const delivery = await prisma.webhookDelivery.findUnique({
|
||||
where: { id: deliveryId },
|
||||
include: { webhook: true },
|
||||
})
|
||||
|
||||
if (!delivery || !delivery.webhook) {
|
||||
console.error(`[Webhook] Delivery ${deliveryId} not found`)
|
||||
return
|
||||
}
|
||||
|
||||
const { webhook } = delivery
|
||||
const payloadStr = JSON.stringify(delivery.payload)
|
||||
|
||||
// Sign payload with HMAC-SHA256
|
||||
const signature = crypto
|
||||
.createHmac('sha256', webhook.secret)
|
||||
.update(payloadStr)
|
||||
.digest('hex')
|
||||
|
||||
// Build headers
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webhook-Signature': `sha256=${signature}`,
|
||||
'X-Webhook-Event': delivery.event,
|
||||
'X-Webhook-Delivery': delivery.id,
|
||||
}
|
||||
|
||||
// Merge custom headers from webhook config
|
||||
if (webhook.headers && typeof webhook.headers === 'object') {
|
||||
const customHeaders = webhook.headers as Record<string, string>
|
||||
for (const [key, value] of Object.entries(customHeaders)) {
|
||||
if (typeof value === 'string') {
|
||||
headers[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30000) // 30s timeout
|
||||
|
||||
const response = await fetch(webhook.url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: payloadStr,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
|
||||
const responseBody = await response.text().catch(() => '')
|
||||
|
||||
await prisma.webhookDelivery.update({
|
||||
where: { id: deliveryId },
|
||||
data: {
|
||||
status: response.ok ? 'DELIVERED' : 'FAILED',
|
||||
responseStatus: response.status,
|
||||
responseBody: responseBody.slice(0, 4000), // Truncate long responses
|
||||
attempts: delivery.attempts + 1,
|
||||
lastAttemptAt: new Date(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
await prisma.webhookDelivery.update({
|
||||
where: { id: deliveryId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
responseBody: errorMessage.slice(0, 4000),
|
||||
attempts: delivery.attempts + 1,
|
||||
lastAttemptAt: new Date(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry all failed webhook deliveries that haven't exceeded max retries.
|
||||
* Called by cron.
|
||||
*/
|
||||
export async function retryFailedDeliveries(): Promise<{
|
||||
retried: number
|
||||
errors: number
|
||||
}> {
|
||||
let retried = 0
|
||||
let errors = 0
|
||||
|
||||
const failedDeliveries = await prisma.webhookDelivery.findMany({
|
||||
where: {
|
||||
status: 'FAILED',
|
||||
},
|
||||
include: {
|
||||
webhook: {
|
||||
select: { maxRetries: true, isActive: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for (const delivery of failedDeliveries) {
|
||||
// Skip if webhook is inactive or max retries exceeded
|
||||
if (!delivery.webhook.isActive) continue
|
||||
if (delivery.attempts >= delivery.webhook.maxRetries) continue
|
||||
|
||||
try {
|
||||
await deliverWebhook(delivery.id)
|
||||
retried++
|
||||
} catch (error) {
|
||||
console.error(`[Webhook] Retry failed for delivery ${delivery.id}:`, error)
|
||||
errors++
|
||||
}
|
||||
}
|
||||
|
||||
return { retried, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random HMAC secret for webhook signing.
|
||||
*/
|
||||
export function generateWebhookSecret(): string {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
Reference in New Issue
Block a user