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:
@@ -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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user