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

@@ -366,4 +366,272 @@ export const analyticsRouter = router({
count: d._count.id,
}))
}),
// =========================================================================
// Advanced Analytics (F10)
// =========================================================================
/**
* Compare metrics across multiple rounds
*/
getCrossRoundComparison: observerProcedure
.input(z.object({ roundIds: z.array(z.string()).min(2) }))
.query(async ({ ctx, input }) => {
const comparisons = await Promise.all(
input.roundIds.map(async (roundId) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true, name: true },
})
const [projectCount, assignmentCount, evaluationCount] = await Promise.all([
ctx.prisma.project.count({ where: { roundId } }),
ctx.prisma.assignment.count({ where: { roundId } }),
ctx.prisma.evaluation.count({
where: {
assignment: { roundId },
status: 'SUBMITTED',
},
}),
])
const completionRate = assignmentCount > 0
? Math.round((evaluationCount / assignmentCount) * 100)
: 0
// Get average scores
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId },
status: 'SUBMITTED',
},
select: { globalScore: true },
})
const globalScores = evaluations
.map((e) => e.globalScore)
.filter((s): s is number => s !== null)
const averageScore = globalScores.length > 0
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
: null
// Score distribution
const distribution = Array.from({ length: 10 }, (_, i) => ({
score: i + 1,
count: globalScores.filter((s) => Math.round(s) === i + 1).length,
}))
return {
roundId,
roundName: round.name,
projectCount,
evaluationCount,
completionRate,
averageScore,
scoreDistribution: distribution,
}
})
)
return comparisons
}),
/**
* Get juror consistency metrics for a round
*/
getJurorConsistency: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
include: {
assignment: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
})
// Group scores by juror
const jurorScores: Record<string, { name: string; email: string; scores: number[] }> = {}
evaluations.forEach((e) => {
const userId = e.assignment.userId
if (!jurorScores[userId]) {
jurorScores[userId] = {
name: e.assignment.user.name || e.assignment.user.email || 'Unknown',
email: e.assignment.user.email || '',
scores: [],
}
}
if (e.globalScore !== null) {
jurorScores[userId].scores.push(e.globalScore)
}
})
// Calculate overall average
const allScores = Object.values(jurorScores).flatMap((j) => j.scores)
const overallAverage = allScores.length > 0
? allScores.reduce((a, b) => a + b, 0) / allScores.length
: 0
// Calculate per-juror metrics
const metrics = Object.entries(jurorScores).map(([userId, data]) => {
const avg = data.scores.length > 0
? data.scores.reduce((a, b) => a + b, 0) / data.scores.length
: 0
const variance = data.scores.length > 1
? data.scores.reduce((sum, s) => sum + Math.pow(s - avg, 2), 0) / data.scores.length
: 0
const stddev = Math.sqrt(variance)
const deviationFromOverall = Math.abs(avg - overallAverage)
return {
userId,
name: data.name,
email: data.email,
evaluationCount: data.scores.length,
averageScore: avg,
stddev,
deviationFromOverall,
isOutlier: deviationFromOverall > 2, // Flag if 2+ points from mean
}
})
return {
overallAverage,
jurors: metrics.sort((a, b) => b.deviationFromOverall - a.deviationFromOverall),
}
}),
/**
* Get diversity metrics for projects in a round
*/
getDiversityMetrics: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
select: {
country: true,
competitionCategory: true,
oceanIssue: true,
tags: true,
},
})
const total = projects.length
if (total === 0) {
return { total: 0, byCountry: [], byCategory: [], byOceanIssue: [], byTag: [] }
}
// By country
const countryCounts: Record<string, number> = {}
projects.forEach((p) => {
const key = p.country || 'Unknown'
countryCounts[key] = (countryCounts[key] || 0) + 1
})
const byCountry = Object.entries(countryCounts)
.map(([country, count]) => ({ country, count, percentage: (count / total) * 100 }))
.sort((a, b) => b.count - a.count)
// By competition category
const categoryCounts: Record<string, number> = {}
projects.forEach((p) => {
const key = p.competitionCategory || 'Uncategorized'
categoryCounts[key] = (categoryCounts[key] || 0) + 1
})
const byCategory = Object.entries(categoryCounts)
.map(([category, count]) => ({ category, count, percentage: (count / total) * 100 }))
.sort((a, b) => b.count - a.count)
// By ocean issue
const issueCounts: Record<string, number> = {}
projects.forEach((p) => {
const key = p.oceanIssue || 'Unspecified'
issueCounts[key] = (issueCounts[key] || 0) + 1
})
const byOceanIssue = Object.entries(issueCounts)
.map(([issue, count]) => ({ issue, count, percentage: (count / total) * 100 }))
.sort((a, b) => b.count - a.count)
// By tag
const tagCounts: Record<string, number> = {}
projects.forEach((p) => {
p.tags.forEach((tag) => {
tagCounts[tag] = (tagCounts[tag] || 0) + 1
})
})
const byTag = Object.entries(tagCounts)
.map(([tag, count]) => ({ tag, count, percentage: (count / total) * 100 }))
.sort((a, b) => b.count - a.count)
return { total, byCountry, byCategory, byOceanIssue, byTag }
}),
/**
* Get year-over-year stats across all rounds in a program
*/
getYearOverYear: observerProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const rounds = await ctx.prisma.round.findMany({
where: { programId: input.programId },
select: { id: true, name: true, createdAt: true },
orderBy: { createdAt: 'asc' },
})
const stats = await Promise.all(
rounds.map(async (round) => {
const [projectCount, evaluationCount, assignmentCount] = await Promise.all([
ctx.prisma.project.count({ where: { roundId: round.id } }),
ctx.prisma.evaluation.count({
where: {
assignment: { roundId: round.id },
status: 'SUBMITTED',
},
}),
ctx.prisma.assignment.count({ where: { roundId: round.id } }),
])
const completionRate = assignmentCount > 0
? Math.round((evaluationCount / assignmentCount) * 100)
: 0
// Average score
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: round.id },
status: 'SUBMITTED',
},
select: { globalScore: true },
})
const scores = evaluations
.map((e) => e.globalScore)
.filter((s): s is number => s !== null)
const averageScore = scores.length > 0
? scores.reduce((a, b) => a + b, 0) / scores.length
: null
return {
roundId: round.id,
roundName: round.name,
createdAt: round.createdAt,
projectCount,
evaluationCount,
completionRate,
averageScore,
}
})
)
return stats
}),
})