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

@@ -1,6 +1,6 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { router, protectedProcedure, adminProcedure, juryProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
import { processEvaluationReminders } from '../services/evaluation-reminders'
@@ -621,4 +621,397 @@ export const evaluationRouter = router({
errors,
}
}),
// =========================================================================
// Side-by-Side Comparison (F4)
// =========================================================================
/**
* Get multiple projects with evaluations for side-by-side comparison
*/
getMultipleForComparison: juryProcedure
.input(
z.object({
projectIds: z.array(z.string()).min(2).max(3),
roundId: z.string(),
})
)
.query(async ({ ctx, input }) => {
// Verify all projects are assigned to current user in this round
const assignments = await ctx.prisma.assignment.findMany({
where: {
userId: ctx.user.id,
roundId: input.roundId,
projectId: { in: input.projectIds },
},
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
description: true,
country: true,
tags: true,
files: {
select: {
id: true,
fileName: true,
fileType: true,
size: true,
},
},
},
},
evaluation: true,
},
})
if (assignments.length !== input.projectIds.length) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to all requested projects in this round',
})
}
return assignments.map((a) => ({
project: a.project,
evaluation: a.evaluation,
assignmentId: a.id,
}))
}),
// =========================================================================
// Peer Review & Discussion (F13)
// =========================================================================
/**
* Get anonymized peer evaluation summary for a project
*/
getPeerSummary: juryProcedure
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
})
)
.query(async ({ ctx, input }) => {
// Verify user has submitted their own evaluation first
const userAssignment = await ctx.prisma.assignment.findFirst({
where: {
userId: ctx.user.id,
projectId: input.projectId,
roundId: input.roundId,
},
include: { evaluation: true },
})
if (!userAssignment || userAssignment.evaluation?.status !== 'SUBMITTED') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'You must submit your own evaluation before viewing peer summaries',
})
}
// Check round settings for peer review
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const settings = (round.settingsJson as Record<string, unknown>) || {}
if (!settings.peer_review_enabled) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Peer review is not enabled for this round',
})
}
// Get all submitted evaluations for this project
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: {
projectId: input.projectId,
roundId: input.roundId,
},
},
include: {
assignment: {
include: {
user: { select: { id: true, name: true } },
},
},
},
})
if (evaluations.length === 0) {
return { aggregated: null, individualScores: [], totalEvaluations: 0 }
}
// Calculate average and stddev per criterion
const criterionData: Record<string, number[]> = {}
evaluations.forEach((e) => {
const scores = e.criterionScoresJson as Record<string, number> | null
if (scores) {
Object.entries(scores).forEach(([key, val]) => {
if (typeof val === 'number') {
if (!criterionData[key]) criterionData[key] = []
criterionData[key].push(val)
}
})
}
})
const aggregated: Record<string, { average: number; stddev: number; count: number; distribution: Record<number, number> }> = {}
Object.entries(criterionData).forEach(([key, scores]) => {
const avg = scores.reduce((a, b) => a + b, 0) / scores.length
const variance = scores.reduce((sum, s) => sum + Math.pow(s - avg, 2), 0) / scores.length
const stddev = Math.sqrt(variance)
const distribution: Record<number, number> = {}
scores.forEach((s) => {
const bucket = Math.round(s)
distribution[bucket] = (distribution[bucket] || 0) + 1
})
aggregated[key] = { average: avg, stddev, count: scores.length, distribution }
})
// Anonymize individual scores based on round settings
const anonymizationLevel = (settings.anonymization_level as string) || 'fully_anonymous'
const individualScores = evaluations.map((e) => {
let jurorLabel: string
if (anonymizationLevel === 'named') {
jurorLabel = e.assignment.user.name || 'Juror'
} else if (anonymizationLevel === 'show_initials') {
const name = e.assignment.user.name || ''
jurorLabel = name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase() || 'J'
} else {
jurorLabel = `Juror ${evaluations.indexOf(e) + 1}`
}
return {
jurorLabel,
globalScore: e.globalScore,
binaryDecision: e.binaryDecision,
criterionScoresJson: e.criterionScoresJson,
}
})
return {
aggregated,
individualScores,
totalEvaluations: evaluations.length,
}
}),
/**
* Get or create a discussion for a project evaluation
*/
getDiscussion: juryProcedure
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
})
)
.query(async ({ ctx, input }) => {
// Get or create discussion
let discussion = await ctx.prisma.evaluationDiscussion.findUnique({
where: {
projectId_roundId: {
projectId: input.projectId,
roundId: input.roundId,
},
},
include: {
comments: {
include: {
user: { select: { id: true, name: true } },
},
orderBy: { createdAt: 'asc' },
},
},
})
if (!discussion) {
discussion = await ctx.prisma.evaluationDiscussion.create({
data: {
projectId: input.projectId,
roundId: input.roundId,
},
include: {
comments: {
include: {
user: { select: { id: true, name: true } },
},
orderBy: { createdAt: 'asc' },
},
},
})
}
// Anonymize comments based on round settings
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const settings = (round.settingsJson as Record<string, unknown>) || {}
const anonymizationLevel = (settings.anonymization_level as string) || 'fully_anonymous'
const anonymizedComments = discussion.comments.map((c, idx) => {
let authorLabel: string
if (anonymizationLevel === 'named' || c.userId === ctx.user.id) {
authorLabel = c.user.name || 'Juror'
} else if (anonymizationLevel === 'show_initials') {
const name = c.user.name || ''
authorLabel = name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase() || 'J'
} else {
authorLabel = `Juror ${idx + 1}`
}
return {
id: c.id,
authorLabel,
isOwn: c.userId === ctx.user.id,
content: c.content,
createdAt: c.createdAt,
}
})
return {
id: discussion.id,
status: discussion.status,
createdAt: discussion.createdAt,
closedAt: discussion.closedAt,
comments: anonymizedComments,
}
}),
/**
* Add a comment to a project evaluation discussion
*/
addComment: juryProcedure
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
content: z.string().min(1).max(2000),
})
)
.mutation(async ({ ctx, input }) => {
// Check max comment length from round settings
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const settings = (round.settingsJson as Record<string, unknown>) || {}
const maxLength = (settings.max_comment_length as number) || 2000
if (input.content.length > maxLength) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Comment exceeds maximum length of ${maxLength} characters`,
})
}
// Get or create discussion
let discussion = await ctx.prisma.evaluationDiscussion.findUnique({
where: {
projectId_roundId: {
projectId: input.projectId,
roundId: input.roundId,
},
},
})
if (!discussion) {
discussion = await ctx.prisma.evaluationDiscussion.create({
data: {
projectId: input.projectId,
roundId: input.roundId,
},
})
}
if (discussion.status === 'closed') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This discussion has been closed',
})
}
const comment = await ctx.prisma.discussionComment.create({
data: {
discussionId: discussion.id,
userId: ctx.user.id,
content: input.content,
},
})
// Audit log
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DISCUSSION_COMMENT_ADDED',
entityType: 'DiscussionComment',
entityId: comment.id,
detailsJson: {
discussionId: discussion.id,
projectId: input.projectId,
roundId: input.roundId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Never throw on audit failure
}
return comment
}),
/**
* Close a discussion (admin only)
*/
closeDiscussion: adminProcedure
.input(z.object({ discussionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const discussion = await ctx.prisma.evaluationDiscussion.update({
where: { id: input.discussionId },
data: {
status: 'closed',
closedAt: new Date(),
closedById: ctx.user.id,
},
})
// Audit log
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DISCUSSION_CLOSED',
entityType: 'EvaluationDiscussion',
entityId: input.discussionId,
detailsJson: {
projectId: discussion.projectId,
roundId: discussion.roundId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Never throw on audit failure
}
return discussion
}),
})