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