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