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:
2026-02-05 23:31:41 +01:00
parent f038c95777
commit 59436ed67a
68 changed files with 14541 additions and 546 deletions

View File

@@ -110,6 +110,12 @@ enum SettingCategory {
SECURITY
DEFAULTS
WHATSAPP
DIGEST
ANALYTICS
AUDIT_CONFIG
INTEGRATIONS
LOCALIZATION
COMMUNICATION
}
enum NotificationChannel {
@@ -210,6 +216,13 @@ model User {
notificationPreference NotificationChannel @default(EMAIL)
whatsappOptIn Boolean @default(false)
// Digest preferences (F1)
digestFrequency String @default("none") // none, daily, weekly
// Availability & workload (F2)
availabilityJson Json? @db.JsonB // Array of { start, end } date ranges
preferredWorkload Int? // Preferred number of assignments
// Onboarding (Phase 2B)
onboardingCompletedAt DateTime?
@@ -269,6 +282,24 @@ model User {
// Mentor messages
mentorMessages MentorMessage[] @relation("MentorMessageSender")
// Digest logs (F1)
digestLogs DigestLog[]
// Mentor notes & milestones (F8)
mentorNotesMade MentorNote[] @relation("MentorNoteAuthor")
milestoneCompletions MentorMilestoneCompletion[] @relation("MilestoneCompletedByUser")
// Messages (F9)
sentMessages Message[] @relation("MessageSender")
messageRecipients MessageRecipient[]
// Webhooks (F12)
createdWebhooks Webhook[] @relation("WebhookCreatedBy")
// Discussion comments (F13)
discussionComments DiscussionComment[]
closedDiscussions EvaluationDiscussion[] @relation("DiscussionClosedBy")
// NextAuth relations
accounts Account[]
sessions Session[]
@@ -338,6 +369,7 @@ model Program {
partners Partner[]
specialAwards SpecialAward[]
taggingJobs TaggingJob[]
mentorMilestones MentorMilestone[]
@@unique([name, year])
@@index([status])
@@ -475,6 +507,11 @@ model Project {
logoKey String? // Storage key (e.g., "logos/project456/1234567890.png")
logoProvider String? // Storage provider used: 's3' or 'local'
// Draft saving (F11)
isDraft Boolean @default(false)
draftDataJson Json? @db.JsonB
draftExpiresAt DateTime?
// Flexible fields
tags String[] @default([]) // "Ocean Conservation", "Tech", etc.
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
@@ -498,6 +535,7 @@ model Project {
statusHistory ProjectStatusHistory[]
mentorMessages MentorMessage[]
evaluationSummaries EvaluationSummary[]
discussions EvaluationDiscussion[]
@@index([roundId])
@@index([status])
@@ -526,6 +564,10 @@ model ProjectFile {
isLate Boolean @default(false) // Uploaded after round deadline
// File versioning (F7)
version Int @default(1)
replacedById String? // Points to newer version of this file
createdAt DateTime @default(now())
// Relations
@@ -675,6 +717,10 @@ model AuditLog {
// Details
detailsJson Json? @db.JsonB // Before/after values, additional context
// Audit enhancements (F14)
sessionId String? // Groups actions in same user session
previousDataJson Json? @db.JsonB // Snapshot of data before change
// Request info
ipAddress String?
userAgent String?
@@ -951,6 +997,12 @@ model LiveVotingSession {
votingEndsAt DateTime?
projectOrderJson Json? @db.JsonB // Array of project IDs in presentation order
// Live voting UX enhancements (F5/F6)
presentationSettingsJson Json? @db.JsonB // theme, auto-advance, branding
allowAudienceVotes Boolean @default(false)
audienceVoteWeight Float @default(0) // 0-1 weight relative to jury
tieBreakerMethod String @default("admin_decides") // admin_decides, highest_individual, revote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -962,12 +1014,13 @@ model LiveVotingSession {
}
model LiveVote {
id String @id @default(cuid())
sessionId String
projectId String
userId String
score Int // 1-10
votedAt DateTime @default(now())
id String @id @default(cuid())
sessionId String
projectId String
userId String
score Int // 1-10
isAudienceVote Boolean @default(false) // F6: audience voting
votedAt DateTime @default(now())
// Relations
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@ -977,6 +1030,7 @@ model LiveVote {
@@index([sessionId])
@@index([projectId])
@@index([userId])
@@index([isAudienceVote])
}
// =============================================================================
@@ -1020,9 +1074,15 @@ model MentorAssignment {
expertiseMatchScore Float?
aiReasoning String? @db.Text
// Mentor dashboard enhancements (F8)
lastViewedAt DateTime?
completionStatus String @default("in_progress") // in_progress, completed, paused
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
mentor User @relation("MentorAssignments", fields: [mentorId], references: [id])
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
mentor User @relation("MentorAssignments", fields: [mentorId], references: [id])
notes MentorNote[]
milestoneCompletions MentorMilestoneCompletion[]
@@index([mentorId])
@@index([method])
@@ -1427,3 +1487,242 @@ model MentorMessage {
@@index([projectId, createdAt])
}
// =============================================================================
// DIGEST LOGS (F1: Email Digest)
// =============================================================================
model DigestLog {
id String @id @default(cuid())
userId String
digestType String // "daily", "weekly"
contentJson Json @db.JsonB
sentAt DateTime @default(now())
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([sentAt])
}
// =============================================================================
// ROUND TEMPLATES (F3)
// =============================================================================
model RoundTemplate {
id String @id @default(cuid())
name String
description String? @db.Text
programId String? // null = global template
roundType RoundType @default(EVALUATION)
criteriaJson Json @db.JsonB
settingsJson Json? @db.JsonB
assignmentConfig Json? @db.JsonB
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([programId])
}
// =============================================================================
// MENTOR NOTES & MILESTONES (F8)
// =============================================================================
model MentorNote {
id String @id @default(cuid())
mentorAssignmentId String
authorId String
content String @db.Text
isVisibleToAdmin Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade)
author User @relation("MentorNoteAuthor", fields: [authorId], references: [id])
@@index([mentorAssignmentId])
}
model MentorMilestone {
id String @id @default(cuid())
programId String
name String
description String? @db.Text
isRequired Boolean @default(false)
deadlineOffsetDays Int?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
completions MentorMilestoneCompletion[]
@@index([programId])
@@index([sortOrder])
}
model MentorMilestoneCompletion {
id String @id @default(cuid())
milestoneId String
mentorAssignmentId String
completedAt DateTime @default(now())
completedById String
// Relations
milestone MentorMilestone @relation(fields: [milestoneId], references: [id], onDelete: Cascade)
mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade)
completedBy User @relation("MilestoneCompletedByUser", fields: [completedById], references: [id])
@@unique([milestoneId, mentorAssignmentId])
@@index([mentorAssignmentId])
}
// =============================================================================
// COMMUNICATION HUB (F9)
// =============================================================================
model Message {
id String @id @default(cuid())
senderId String
recipientType String // USER, ROLE, ROUND_JURY, PROGRAM_TEAM, ALL
recipientFilter Json? @db.JsonB
roundId String?
templateId String?
subject String
body String @db.Text
deliveryChannels String[]
scheduledAt DateTime?
sentAt DateTime?
metadata Json? @db.JsonB
createdAt DateTime @default(now())
// Relations
sender User @relation("MessageSender", fields: [senderId], references: [id])
template MessageTemplate? @relation(fields: [templateId], references: [id])
recipients MessageRecipient[]
@@index([senderId])
@@index([sentAt])
@@index([scheduledAt])
}
model MessageTemplate {
id String @id @default(cuid())
name String
category String
subject String
body String @db.Text
variables Json? @db.JsonB
isActive Boolean @default(true)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
messages Message[]
@@index([category])
@@index([isActive])
}
model MessageRecipient {
id String @id @default(cuid())
messageId String
userId String
channel String // EMAIL, IN_APP, WHATSAPP
isRead Boolean @default(false)
readAt DateTime?
deliveredAt DateTime?
// Relations
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([messageId])
@@index([userId, isRead])
}
// =============================================================================
// WEBHOOKS (F12)
// =============================================================================
model Webhook {
id String @id @default(cuid())
name String
url String
secret String // HMAC signing key
events String[]
headers Json? @db.JsonB
isActive Boolean @default(true)
maxRetries Int @default(3)
createdById String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
createdBy User @relation("WebhookCreatedBy", fields: [createdById], references: [id])
deliveries WebhookDelivery[]
@@index([isActive])
}
model WebhookDelivery {
id String @id @default(cuid())
webhookId String
event String
payload Json @db.JsonB
responseStatus Int?
responseBody String? @db.Text
attempts Int @default(0)
lastAttemptAt DateTime?
status String @default("PENDING") // PENDING, DELIVERED, FAILED
createdAt DateTime @default(now())
// Relations
webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
@@index([webhookId])
@@index([status])
@@index([createdAt])
}
// =============================================================================
// PEER REVIEW / EVALUATION DISCUSSIONS (F13)
// =============================================================================
model EvaluationDiscussion {
id String @id @default(cuid())
projectId String
roundId String
status String @default("open") // open, closed
createdAt DateTime @default(now())
closedAt DateTime?
closedById String?
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
closedBy User? @relation("DiscussionClosedBy", fields: [closedById], references: [id])
comments DiscussionComment[]
@@unique([projectId, roundId])
@@index([roundId])
@@index([status])
}
model DiscussionComment {
id String @id @default(cuid())
discussionId String
userId String
content String @db.Text
createdAt DateTime @default(now())
// Relations
discussion EvaluationDiscussion @relation(fields: [discussionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@index([discussionId, createdAt])
}