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, publicProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
export const liveVotingRouter = router({
@@ -351,7 +351,7 @@ export const liveVotingRouter = router({
}),
/**
* Get results for a session
* Get results for a session (with weighted jury + audience scoring)
*/
getResults: protectedProcedure
.input(z.object({ sessionId: z.string() }))
@@ -367,36 +367,281 @@ export const liveVotingRouter = router({
},
})
// Get all votes grouped by project
const projectScores = await ctx.prisma.liveVote.groupBy({
const audienceWeight = session.audienceVoteWeight || 0
const juryWeight = 1 - audienceWeight
// Get jury votes grouped by project
const juryScores = await ctx.prisma.liveVote.groupBy({
by: ['projectId'],
where: { sessionId: input.sessionId, isAudienceVote: false },
_avg: { score: true },
_count: true,
})
// Get audience votes grouped by project
const audienceScores = await ctx.prisma.liveVote.groupBy({
by: ['projectId'],
where: { sessionId: input.sessionId, isAudienceVote: true },
_avg: { score: true },
_count: true,
})
// Get project details
const allProjectIds = [
...new Set([
...juryScores.map((s) => s.projectId),
...audienceScores.map((s) => s.projectId),
]),
]
const projects = await ctx.prisma.project.findMany({
where: { id: { in: allProjectIds } },
select: { id: true, title: true, teamName: true },
})
const audienceMap = new Map(audienceScores.map((s) => [s.projectId, s]))
// Combine and calculate weighted scores
const results = juryScores
.map((jurySc) => {
const project = projects.find((p) => p.id === jurySc.projectId)
const audienceSc = audienceMap.get(jurySc.projectId)
const juryAvg = jurySc._avg.score || 0
const audienceAvg = audienceSc?._avg.score || 0
const weightedTotal = audienceWeight > 0 && audienceSc
? juryAvg * juryWeight + audienceAvg * audienceWeight
: juryAvg
return {
project,
juryAverage: juryAvg,
juryVoteCount: jurySc._count,
audienceAverage: audienceAvg,
audienceVoteCount: audienceSc?._count || 0,
weightedTotal,
}
})
.sort((a, b) => b.weightedTotal - a.weightedTotal)
// Detect ties
const ties: string[][] = []
for (let i = 0; i < results.length - 1; i++) {
if (Math.abs(results[i].weightedTotal - results[i + 1].weightedTotal) < 0.001) {
const tieGroup = [results[i].project?.id, results[i + 1].project?.id].filter(Boolean) as string[]
ties.push(tieGroup)
}
}
return {
session,
results,
ties,
tieBreakerMethod: session.tieBreakerMethod,
}
}),
/**
* Update presentation settings for a live voting session
*/
updatePresentationSettings: adminProcedure
.input(
z.object({
sessionId: z.string(),
presentationSettingsJson: z.object({
theme: z.string().optional(),
autoAdvance: z.boolean().optional(),
autoAdvanceDelay: z.number().int().min(5).max(120).optional(),
scoreDisplayFormat: z.enum(['bar', 'number', 'radial']).optional(),
showVoteCount: z.boolean().optional(),
brandingOverlay: z.string().optional(),
}),
})
)
.mutation(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.update({
where: { id: input.sessionId },
data: {
presentationSettingsJson: input.presentationSettingsJson,
},
})
return session
}),
/**
* Update session config (audience voting, tie-breaker)
*/
updateSessionConfig: adminProcedure
.input(
z.object({
sessionId: z.string(),
allowAudienceVotes: z.boolean().optional(),
audienceVoteWeight: z.number().min(0).max(1).optional(),
tieBreakerMethod: z.enum(['admin_decides', 'highest_individual', 'revote']).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { sessionId, ...data } = input
const session = await ctx.prisma.liveVotingSession.update({
where: { id: sessionId },
data,
})
// Audit log
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_SESSION_CONFIG',
entityType: 'LiveVotingSession',
entityId: sessionId,
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Audit log errors should never break the operation
}
return session
}),
/**
* Cast an audience vote
*/
castAudienceVote: protectedProcedure
.input(
z.object({
sessionId: z.string(),
projectId: z.string(),
score: z.number().int().min(1).max(10),
})
)
.mutation(async ({ ctx, input }) => {
// Verify session is in progress and allows audience votes
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
})
if (session.status !== 'IN_PROGRESS') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Voting is not currently active',
})
}
if (!session.allowAudienceVotes) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Audience voting is not enabled for this session',
})
}
if (session.currentProjectId !== input.projectId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot vote for this project right now',
})
}
if (session.votingEndsAt && new Date() > session.votingEndsAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Voting window has closed',
})
}
// Upsert audience vote
const vote = await ctx.prisma.liveVote.upsert({
where: {
sessionId_projectId_userId: {
sessionId: input.sessionId,
projectId: input.projectId,
userId: ctx.user.id,
},
},
create: {
sessionId: input.sessionId,
projectId: input.projectId,
userId: ctx.user.id,
score: input.score,
isAudienceVote: true,
},
update: {
score: input.score,
votedAt: new Date(),
},
})
return vote
}),
/**
* Get public results for a live voting session (no auth required)
*/
getPublicResults: publicProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
select: {
id: true,
status: true,
currentProjectId: true,
votingEndsAt: true,
presentationSettingsJson: true,
allowAudienceVotes: true,
audienceVoteWeight: true,
},
})
// Only return data if session is in progress or completed
if (session.status !== 'IN_PROGRESS' && session.status !== 'COMPLETED') {
return {
session: {
id: session.id,
status: session.status,
presentationSettings: session.presentationSettingsJson,
},
projects: [],
}
}
// Get all votes grouped by project (anonymized - no user data)
const scores = await ctx.prisma.liveVote.groupBy({
by: ['projectId'],
where: { sessionId: input.sessionId },
_avg: { score: true },
_count: true,
})
// Get project details
const projectIds = projectScores.map((s) => s.projectId)
const projectIds = scores.map((s) => s.projectId)
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, title: true, teamName: true },
})
// Combine and sort by average score
const results = projectScores
.map((score) => {
const project = projects.find((p) => p.id === score.projectId)
return {
project,
averageScore: score._avg.score || 0,
voteCount: score._count,
}
})
.sort((a, b) => b.averageScore - a.averageScore)
const projectsWithScores = scores.map((score) => {
const project = projects.find((p) => p.id === score.projectId)
return {
id: project?.id,
title: project?.title,
teamName: project?.teamName,
averageScore: score._avg.score || 0,
voteCount: score._count,
}
}).sort((a, b) => b.averageScore - a.averageScore)
return {
session,
results,
session: {
id: session.id,
status: session.status,
currentProjectId: session.currentProjectId,
votingEndsAt: session.votingEndsAt,
presentationSettings: session.presentationSettingsJson,
allowAudienceVotes: session.allowAudienceVotes,
},
projects: projectsWithScores,
}
}),
})