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,5 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { router, adminProcedure, observerProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
export const exportRouter = router({
|
||||
@@ -388,4 +388,234 @@ export const exportRouter = router({
|
||||
],
|
||||
}
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// PDF Report Data (F10)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Compile structured data for PDF report generation
|
||||
*/
|
||||
getReportData: observerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
sections: z.array(z.string()).optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const includeSection = (name: string) =>
|
||||
!input.sections || input.sections.length === 0 || input.sections.includes(name)
|
||||
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
roundName: round.name,
|
||||
programName: round.program.name,
|
||||
programYear: round.program.year,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// Summary stats
|
||||
if (includeSection('summary')) {
|
||||
const [projectCount, assignmentCount, evaluationCount, jurorCount] = await Promise.all([
|
||||
ctx.prisma.project.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.evaluation.count({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
}),
|
||||
ctx.prisma.assignment.groupBy({
|
||||
by: ['userId'],
|
||||
where: { roundId: input.roundId },
|
||||
}),
|
||||
])
|
||||
|
||||
result.summary = {
|
||||
projectCount,
|
||||
assignmentCount,
|
||||
evaluationCount,
|
||||
jurorCount: jurorCount.length,
|
||||
completionRate: assignmentCount > 0
|
||||
? Math.round((evaluationCount / assignmentCount) * 100)
|
||||
: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Score distributions
|
||||
if (includeSection('scoreDistribution')) {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
select: { globalScore: true },
|
||||
})
|
||||
|
||||
const scores = evaluations
|
||||
.map((e) => e.globalScore)
|
||||
.filter((s): s is number => s !== null)
|
||||
|
||||
result.scoreDistribution = {
|
||||
distribution: Array.from({ length: 10 }, (_, i) => ({
|
||||
score: i + 1,
|
||||
count: scores.filter((s) => Math.round(s) === i + 1).length,
|
||||
})),
|
||||
average: scores.length > 0
|
||||
? scores.reduce((a, b) => a + b, 0) / scores.length
|
||||
: null,
|
||||
total: scores.length,
|
||||
}
|
||||
}
|
||||
|
||||
// Rankings
|
||||
if (includeSection('rankings')) {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
status: true,
|
||||
assignments: {
|
||||
select: {
|
||||
evaluation: {
|
||||
select: { globalScore: true, binaryDecision: true, status: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const rankings = projects
|
||||
.map((p) => {
|
||||
const submitted = p.assignments
|
||||
.map((a) => a.evaluation)
|
||||
.filter((e) => e?.status === 'SUBMITTED')
|
||||
const scores = submitted
|
||||
.map((e) => e?.globalScore)
|
||||
.filter((s): s is number => s !== null)
|
||||
const yesVotes = submitted.filter((e) => e?.binaryDecision === true).length
|
||||
|
||||
return {
|
||||
title: p.title,
|
||||
teamName: p.teamName,
|
||||
status: p.status,
|
||||
evaluationCount: submitted.length,
|
||||
averageScore: scores.length > 0
|
||||
? scores.reduce((a, b) => a + b, 0) / scores.length
|
||||
: null,
|
||||
yesPercentage: submitted.length > 0
|
||||
? (yesVotes / submitted.length) * 100
|
||||
: null,
|
||||
}
|
||||
})
|
||||
.filter((r) => r.averageScore !== null)
|
||||
.sort((a, b) => (b.averageScore || 0) - (a.averageScore || 0))
|
||||
|
||||
result.rankings = rankings
|
||||
}
|
||||
|
||||
// Juror stats
|
||||
if (includeSection('jurorStats')) {
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
evaluation: { select: { status: true, globalScore: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const byUser: Record<string, { name: string; assigned: number; completed: number; scores: number[] }> = {}
|
||||
assignments.forEach((a) => {
|
||||
if (!byUser[a.userId]) {
|
||||
byUser[a.userId] = {
|
||||
name: a.user.name || a.user.email || 'Unknown',
|
||||
assigned: 0,
|
||||
completed: 0,
|
||||
scores: [],
|
||||
}
|
||||
}
|
||||
byUser[a.userId].assigned++
|
||||
if (a.evaluation?.status === 'SUBMITTED') {
|
||||
byUser[a.userId].completed++
|
||||
if (a.evaluation.globalScore !== null) {
|
||||
byUser[a.userId].scores.push(a.evaluation.globalScore)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
result.jurorStats = Object.values(byUser).map((u) => ({
|
||||
name: u.name,
|
||||
assigned: u.assigned,
|
||||
completed: u.completed,
|
||||
completionRate: u.assigned > 0 ? Math.round((u.completed / u.assigned) * 100) : 0,
|
||||
averageScore: u.scores.length > 0
|
||||
? u.scores.reduce((a, b) => a + b, 0) / u.scores.length
|
||||
: null,
|
||||
}))
|
||||
}
|
||||
|
||||
// Criteria breakdown
|
||||
if (includeSection('criteriaBreakdown')) {
|
||||
const form = await ctx.prisma.evaluationForm.findFirst({
|
||||
where: { roundId: input.roundId, isActive: true },
|
||||
})
|
||||
|
||||
if (form?.criteriaJson) {
|
||||
const criteria = form.criteriaJson as Array<{ id: string; label: string }>
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
select: { criterionScoresJson: true },
|
||||
})
|
||||
|
||||
result.criteriaBreakdown = criteria.map((c) => {
|
||||
const scores: number[] = []
|
||||
evaluations.forEach((e) => {
|
||||
const cs = e.criterionScoresJson as Record<string, number> | null
|
||||
if (cs && typeof cs[c.id] === 'number') {
|
||||
scores.push(cs[c.id])
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
averageScore: scores.length > 0
|
||||
? scores.reduce((a, b) => a + b, 0) / scores.length
|
||||
: null,
|
||||
count: scores.length,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log for report generation
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'REPORT_GENERATED',
|
||||
entityType: 'Round',
|
||||
entityId: input.roundId,
|
||||
detailsJson: { sections: input.sections },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
} catch {
|
||||
// Never throw on audit failure
|
||||
}
|
||||
|
||||
return result
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user