2026-01-30 13:41:32 +01:00
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
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 { router , protectedProcedure , adminProcedure , juryProcedure } from '../trpc'
2026-02-05 21:09:06 +01:00
import { logAudit } from '@/server/utils/audit'
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
import { notifyAdmins , NotificationTypes } from '../services/in-app-notification'
import { processEvaluationReminders } from '../services/evaluation-reminders'
import { generateSummary } from '@/server/services/ai-evaluation-summary'
2026-01-30 13:41:32 +01:00
export const evaluationRouter = router ( {
/ * *
* Get evaluation for an assignment
* /
get : protectedProcedure
. input ( z . object ( { assignmentId : z.string ( ) } ) )
. query ( async ( { ctx , input } ) = > {
// Verify ownership or admin
const assignment = await ctx . prisma . assignment . findUniqueOrThrow ( {
where : { id : input.assignmentId } ,
include : { round : true } ,
} )
if (
ctx . user . role === 'JURY_MEMBER' &&
assignment . userId !== ctx . user . id
) {
throw new TRPCError ( { code : 'FORBIDDEN' } )
}
return ctx . prisma . evaluation . findUnique ( {
where : { assignmentId : input.assignmentId } ,
include : {
form : true ,
} ,
} )
} ) ,
/ * *
* Start an evaluation ( creates draft )
* /
start : protectedProcedure
. input (
z . object ( {
assignmentId : z.string ( ) ,
} )
)
. mutation ( async ( { ctx , input } ) = > {
// Verify assignment ownership
const assignment = await ctx . prisma . assignment . findUniqueOrThrow ( {
where : { id : input.assignmentId } ,
include : {
round : {
include : {
evaluationForms : { where : { isActive : true } , take : 1 } ,
} ,
} ,
} ,
} )
if ( assignment . userId !== ctx . user . id ) {
throw new TRPCError ( { code : 'FORBIDDEN' } )
}
// Get active form
const form = assignment . round . evaluationForms [ 0 ]
if ( ! form ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : 'No active evaluation form for this round' ,
} )
}
// Check if evaluation exists
const existing = await ctx . prisma . evaluation . findUnique ( {
where : { assignmentId : input.assignmentId } ,
} )
if ( existing ) return existing
return ctx . prisma . evaluation . create ( {
data : {
assignmentId : input.assignmentId ,
formId : form.id ,
status : 'DRAFT' ,
} ,
} )
} ) ,
/ * *
* Autosave evaluation ( debounced on client )
* /
autosave : protectedProcedure
. input (
z . object ( {
id : z.string ( ) ,
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
criterionScoresJson : z.record ( z . union ( [ z . number ( ) , z . string ( ) , z . boolean ( ) ] ) ) . optional ( ) ,
2026-01-30 13:41:32 +01:00
globalScore : z.number ( ) . int ( ) . min ( 1 ) . max ( 10 ) . optional ( ) . nullable ( ) ,
binaryDecision : z.boolean ( ) . optional ( ) . nullable ( ) ,
feedbackText : z.string ( ) . optional ( ) . nullable ( ) ,
} )
)
. mutation ( async ( { ctx , input } ) = > {
const { id , . . . data } = input
// Verify ownership and status
const evaluation = await ctx . prisma . evaluation . findUniqueOrThrow ( {
where : { id } ,
include : { assignment : true } ,
} )
if ( evaluation . assignment . userId !== ctx . user . id ) {
throw new TRPCError ( { code : 'FORBIDDEN' } )
}
if (
evaluation . status === 'SUBMITTED' ||
evaluation . status === 'LOCKED'
) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : 'Cannot edit submitted evaluation' ,
} )
}
return ctx . prisma . evaluation . update ( {
where : { id } ,
data : {
. . . data ,
status : 'DRAFT' ,
} ,
} )
} ) ,
/ * *
* Submit evaluation ( final )
* /
submit : protectedProcedure
. input (
z . object ( {
id : z.string ( ) ,
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
criterionScoresJson : z.record ( z . union ( [ z . number ( ) , z . string ( ) , z . boolean ( ) ] ) ) ,
2026-01-30 13:41:32 +01:00
globalScore : z.number ( ) . int ( ) . min ( 1 ) . max ( 10 ) ,
binaryDecision : z.boolean ( ) ,
feedbackText : z.string ( ) . min ( 10 ) ,
} )
)
. mutation ( async ( { ctx , input } ) = > {
const { id , . . . data } = input
// Verify ownership
const evaluation = await ctx . prisma . evaluation . findUniqueOrThrow ( {
where : { id } ,
include : {
assignment : {
include : { round : true } ,
} ,
} ,
} )
if ( evaluation . assignment . userId !== ctx . user . id ) {
throw new TRPCError ( { code : 'FORBIDDEN' } )
}
// Check voting window
const round = evaluation . assignment . round
const now = new Date ( )
if ( round . status !== 'ACTIVE' ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : 'Round is not active' ,
} )
}
// Check for grace period
const gracePeriod = await ctx . prisma . gracePeriod . findFirst ( {
where : {
roundId : round.id ,
userId : ctx.user.id ,
OR : [
{ projectId : null } ,
{ projectId : evaluation.assignment.projectId } ,
] ,
extendedUntil : { gte : now } ,
} ,
} )
const effectiveEndDate = gracePeriod ? . extendedUntil ? ? round . votingEndAt
if ( round . votingStartAt && now < round . votingStartAt ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : 'Voting has not started yet' ,
} )
}
if ( effectiveEndDate && now > effectiveEndDate ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : 'Voting window has closed' ,
} )
}
2026-02-05 20:31:08 +01:00
// Submit evaluation and mark assignment as completed atomically
const [ updated ] = await ctx . prisma . $transaction ( [
ctx . prisma . evaluation . update ( {
where : { id } ,
data : {
. . . data ,
status : 'SUBMITTED' ,
submittedAt : now ,
} ,
} ) ,
ctx . prisma . assignment . update ( {
where : { id : evaluation.assignmentId } ,
data : { isCompleted : true } ,
} ) ,
] )
2026-01-30 13:41:32 +01:00
// Audit log
2026-02-05 21:09:06 +01:00
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'EVALUATION_SUBMITTED' ,
entityType : 'Evaluation' ,
entityId : id ,
detailsJson : {
projectId : evaluation.assignment.projectId ,
roundId : evaluation.assignment.roundId ,
globalScore : data.globalScore ,
binaryDecision : data.binaryDecision ,
2026-01-30 13:41:32 +01:00
} ,
2026-02-05 21:09:06 +01:00
ipAddress : ctx.ip ,
userAgent : ctx.userAgent ,
2026-01-30 13:41:32 +01:00
} )
return updated
} ) ,
/ * *
* Get aggregated stats for a project ( admin only )
* /
getProjectStats : adminProcedure
. input ( z . object ( { projectId : z.string ( ) } ) )
. query ( async ( { ctx , input } ) = > {
const evaluations = await ctx . prisma . evaluation . findMany ( {
where : {
status : 'SUBMITTED' ,
assignment : { projectId : input.projectId } ,
} ,
} )
if ( evaluations . length === 0 ) {
return null
}
const globalScores = evaluations
. map ( ( e ) = > e . globalScore )
. filter ( ( s ) : s is number = > s !== null )
const yesVotes = evaluations . filter (
( e ) = > e . binaryDecision === true
) . length
return {
totalEvaluations : evaluations.length ,
averageGlobalScore :
globalScores . length > 0
? globalScores . reduce ( ( a , b ) = > a + b , 0 ) / globalScores . length
: null ,
minScore : globalScores.length > 0 ? Math . min ( . . . globalScores ) : null ,
maxScore : globalScores.length > 0 ? Math . max ( . . . globalScores ) : null ,
yesVotes ,
noVotes : evaluations.length - yesVotes ,
yesPercentage : ( yesVotes / evaluations . length ) * 100 ,
}
} ) ,
/ * *
* Get all evaluations for a round ( admin only )
* /
listByRound : adminProcedure
. input (
z . object ( {
roundId : z.string ( ) ,
status : z.enum ( [ 'NOT_STARTED' , 'DRAFT' , 'SUBMITTED' , 'LOCKED' ] ) . optional ( ) ,
} )
)
. query ( async ( { ctx , input } ) = > {
return ctx . prisma . evaluation . findMany ( {
where : {
assignment : { roundId : input.roundId } ,
. . . ( input . status && { status : input.status } ) ,
} ,
include : {
assignment : {
include : {
user : { select : { id : true , name : true , email : true } } ,
project : { select : { id : true , title : true } } ,
} ,
} ,
} ,
orderBy : { updatedAt : 'desc' } ,
} )
} ) ,
/ * *
* Get my past evaluations ( read - only for jury )
* /
myPastEvaluations : protectedProcedure
. input ( z . object ( { roundId : z.string ( ) . optional ( ) } ) )
. query ( async ( { ctx , input } ) = > {
return ctx . prisma . evaluation . findMany ( {
where : {
assignment : {
userId : ctx.user.id ,
. . . ( input . roundId && { roundId : input.roundId } ) ,
} ,
status : 'SUBMITTED' ,
} ,
include : {
assignment : {
include : {
project : { select : { id : true , title : true } } ,
round : { select : { id : true , name : true } } ,
} ,
} ,
} ,
orderBy : { submittedAt : 'desc' } ,
} )
} ) ,
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
// =========================================================================
// Conflict of Interest (COI) Endpoints
// =========================================================================
/ * *
* Declare a conflict of interest for an assignment
* /
declareCOI : protectedProcedure
. input (
z . object ( {
assignmentId : z.string ( ) ,
hasConflict : z.boolean ( ) ,
conflictType : z.string ( ) . optional ( ) ,
description : z.string ( ) . optional ( ) ,
} )
)
. mutation ( async ( { ctx , input } ) = > {
// Look up the assignment to get projectId, roundId, userId
const assignment = await ctx . prisma . assignment . findUniqueOrThrow ( {
where : { id : input.assignmentId } ,
include : {
project : { select : { title : true } } ,
round : { select : { name : true } } ,
} ,
} )
// Verify ownership
if ( assignment . userId !== ctx . user . id ) {
throw new TRPCError ( { code : 'FORBIDDEN' } )
}
// Upsert COI record
const coi = await ctx . prisma . conflictOfInterest . upsert ( {
where : { assignmentId : input.assignmentId } ,
create : {
assignmentId : input.assignmentId ,
userId : ctx.user.id ,
projectId : assignment.projectId ,
roundId : assignment.roundId ,
hasConflict : input.hasConflict ,
conflictType : input.hasConflict ? input.conflictType : null ,
description : input.hasConflict ? input.description : null ,
} ,
update : {
hasConflict : input.hasConflict ,
conflictType : input.hasConflict ? input.conflictType : null ,
description : input.hasConflict ? input.description : null ,
declaredAt : new Date ( ) ,
} ,
} )
// Notify admins if conflict declared
if ( input . hasConflict ) {
await notifyAdmins ( {
type : NotificationTypes . JURY_INACTIVE ,
title : 'Conflict of Interest Declared' ,
message : ` ${ ctx . user . name || ctx . user . email } declared a conflict of interest ( ${ input . conflictType || 'unspecified' } ) for project " ${ assignment . project . title } " in ${ assignment . round . name } . ` ,
linkUrl : ` /admin/rounds/ ${ assignment . roundId } /coi ` ,
linkLabel : 'Review COI' ,
priority : 'high' ,
metadata : {
assignmentId : input.assignmentId ,
userId : ctx.user.id ,
projectId : assignment.projectId ,
roundId : assignment.roundId ,
conflictType : input.conflictType ,
} ,
} )
}
// Audit log
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'COI_DECLARED' ,
entityType : 'ConflictOfInterest' ,
entityId : coi.id ,
detailsJson : {
assignmentId : input.assignmentId ,
projectId : assignment.projectId ,
roundId : assignment.roundId ,
hasConflict : input.hasConflict ,
conflictType : input.conflictType ,
} ,
ipAddress : ctx.ip ,
userAgent : ctx.userAgent ,
} )
return coi
} ) ,
/ * *
* Get COI status for an assignment
* /
getCOIStatus : protectedProcedure
. input ( z . object ( { assignmentId : z.string ( ) } ) )
. query ( async ( { ctx , input } ) = > {
return ctx . prisma . conflictOfInterest . findUnique ( {
where : { assignmentId : input.assignmentId } ,
} )
} ) ,
/ * *
* List COI declarations for a round ( admin only )
* /
listCOIByRound : adminProcedure
. input (
z . object ( {
roundId : z.string ( ) ,
hasConflictOnly : z.boolean ( ) . optional ( ) ,
} )
)
. query ( async ( { ctx , input } ) = > {
return ctx . prisma . conflictOfInterest . findMany ( {
where : {
roundId : input.roundId ,
. . . ( input . hasConflictOnly && { hasConflict : true } ) ,
} ,
include : {
user : { select : { id : true , name : true , email : true } } ,
assignment : {
include : {
project : { select : { id : true , title : true } } ,
} ,
} ,
reviewedBy : { select : { id : true , name : true , email : true } } ,
} ,
orderBy : { declaredAt : 'desc' } ,
} )
} ) ,
/ * *
* Review a COI declaration ( admin only )
* /
reviewCOI : adminProcedure
. input (
z . object ( {
id : z.string ( ) ,
reviewAction : z.enum ( [ 'cleared' , 'reassigned' , 'noted' ] ) ,
} )
)
. mutation ( async ( { ctx , input } ) = > {
const coi = await ctx . prisma . conflictOfInterest . update ( {
where : { id : input.id } ,
data : {
reviewedById : ctx.user.id ,
reviewedAt : new Date ( ) ,
reviewAction : input.reviewAction ,
} ,
} )
// Audit log
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'COI_REVIEWED' ,
entityType : 'ConflictOfInterest' ,
entityId : input.id ,
detailsJson : {
reviewAction : input.reviewAction ,
assignmentId : coi.assignmentId ,
userId : coi.userId ,
projectId : coi.projectId ,
} ,
ipAddress : ctx.ip ,
userAgent : ctx.userAgent ,
} )
return coi
} ) ,
// =========================================================================
// Reminder Triggers
// =========================================================================
/ * *
* Manually trigger reminder check for a specific round ( admin only )
* /
triggerReminders : adminProcedure
. input ( z . object ( { roundId : z.string ( ) } ) )
. mutation ( async ( { ctx , input } ) = > {
const result = await processEvaluationReminders ( input . roundId )
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'REMINDERS_TRIGGERED' ,
entityType : 'Round' ,
entityId : input.roundId ,
detailsJson : {
sent : result.sent ,
errors : result.errors ,
} ,
ipAddress : ctx.ip ,
userAgent : ctx.userAgent ,
} )
return result
} ) ,
// =========================================================================
// AI Evaluation Summary Endpoints
// =========================================================================
/ * *
* Generate an AI - powered evaluation summary for a project ( admin only )
* /
generateSummary : adminProcedure
. input (
z . object ( {
projectId : z.string ( ) ,
roundId : z.string ( ) ,
} )
)
. mutation ( async ( { ctx , input } ) = > {
return generateSummary ( {
projectId : input.projectId ,
roundId : input.roundId ,
userId : ctx.user.id ,
prisma : ctx.prisma ,
} )
} ) ,
/ * *
* Get an existing evaluation summary for a project ( admin only )
* /
getSummary : adminProcedure
. input (
z . object ( {
projectId : z.string ( ) ,
roundId : z.string ( ) ,
} )
)
. query ( async ( { ctx , input } ) = > {
return ctx . prisma . evaluationSummary . findUnique ( {
where : {
projectId_roundId : {
projectId : input.projectId ,
roundId : input.roundId ,
} ,
} ,
} )
} ) ,
/ * *
* Generate summaries for all projects in a round with submitted evaluations ( admin only )
* /
generateBulkSummaries : adminProcedure
. input ( z . object ( { roundId : z.string ( ) } ) )
. mutation ( async ( { ctx , input } ) = > {
// Find all projects in the round with at least 1 submitted evaluation
const projects = await ctx . prisma . project . findMany ( {
where : {
roundId : input.roundId ,
assignments : {
some : {
evaluation : {
status : 'SUBMITTED' ,
} ,
} ,
} ,
} ,
select : { id : true } ,
} )
let generated = 0
const errors : Array < { projectId : string ; error : string } > = [ ]
// Generate summaries sequentially to avoid rate limits
for ( const project of projects ) {
try {
await generateSummary ( {
projectId : project.id ,
roundId : input.roundId ,
userId : ctx.user.id ,
prisma : ctx.prisma ,
} )
generated ++
} catch ( error ) {
errors . push ( {
projectId : project.id ,
error : error instanceof Error ? error . message : 'Unknown error' ,
} )
}
}
return {
total : projects.length ,
generated ,
errors ,
}
} ) ,
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
// =========================================================================
// 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' ,
} )
}
2026-02-08 14:37:32 +01:00
// Fetch the active evaluation form for this round to get criteria labels
const evaluationForm = await ctx . prisma . evaluationForm . findFirst ( {
where : { roundId : input.roundId , isActive : true } ,
select : { criteriaJson : true , scalesJson : true } ,
} )
return {
items : assignments.map ( ( a ) = > ( {
project : a.project ,
evaluation : a.evaluation ,
assignmentId : a.id ,
} ) ) ,
criteria : evaluationForm?.criteriaJson as Array < {
id : string ; label : string ; description? : string ; scale? : string ; weight? : number ; type ? : string
} > | null ,
scales : evaluationForm?.scalesJson as Record < string , { min : number ; max : number } > | null ,
}
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
} ) ,
// =========================================================================
// 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'
Fix build errors: add missing Prisma models/fields and resolve TypeScript type errors
Schema: Add 11 new models (RoundTemplate, MentorNote, MentorMilestone,
MentorMilestoneCompletion, EvaluationDiscussion, DiscussionComment,
Message, MessageRecipient, MessageTemplate, Webhook, WebhookDelivery,
DigestLog) and missing fields on existing models (Project.isDraft,
ProjectFile.version, LiveVotingSession.allowAudienceVotes, User.digestFrequency,
AuditLog.sessionId, MentorAssignment.completionStatus, etc).
Add AUDIT_CONFIG/LOCALIZATION/DIGEST/ANALYTICS enum values.
Code: Fix implicit any types, route type casts, enum casts, null safety,
composite key handling, and relation field names across 11 source files.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:04:02 +01:00
const anonymizedComments = discussion . comments . map ( ( c : { id : string ; userId : string ; user : { name : string | null } ; content : string ; createdAt : Date } , idx : number ) = > {
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
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 ( ' ' )
Fix build errors: add missing Prisma models/fields and resolve TypeScript type errors
Schema: Add 11 new models (RoundTemplate, MentorNote, MentorMilestone,
MentorMilestoneCompletion, EvaluationDiscussion, DiscussionComment,
Message, MessageRecipient, MessageTemplate, Webhook, WebhookDelivery,
DigestLog) and missing fields on existing models (Project.isDraft,
ProjectFile.version, LiveVotingSession.allowAudienceVotes, User.digestFrequency,
AuditLog.sessionId, MentorAssignment.completionStatus, etc).
Add AUDIT_CONFIG/LOCALIZATION/DIGEST/ANALYTICS enum values.
Code: Fix implicit any types, route type casts, enum casts, null safety,
composite key handling, and relation field names across 11 source files.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:04:02 +01:00
. map ( ( n : string ) = > n [ 0 ] )
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
. 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
} ) ,
2026-01-30 13:41:32 +01:00
} )