2026-01-30 13:41:32 +01:00
|
|
|
import { z } from 'zod'
|
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>
2026-02-05 23:31:41 +01:00
|
|
|
import { router, adminProcedure, observerProcedure } from '../trpc'
|
2026-02-05 21:09:06 +01:00
|
|
|
import { logAudit } from '../utils/audit'
|
2026-01-30 13:41:32 +01:00
|
|
|
|
|
|
|
|
export const exportRouter = router({
|
|
|
|
|
/**
|
|
|
|
|
* Export evaluations as CSV data
|
|
|
|
|
*/
|
|
|
|
|
evaluations: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
roundId: z.string(),
|
|
|
|
|
includeDetails: z.boolean().default(true),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
status: 'SUBMITTED',
|
|
|
|
|
assignment: { roundId: input.roundId },
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
assignment: {
|
|
|
|
|
include: {
|
|
|
|
|
user: { select: { name: true, email: true } },
|
|
|
|
|
project: { select: { title: true, teamName: true, tags: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
form: { select: { criteriaJson: true } },
|
|
|
|
|
},
|
|
|
|
|
orderBy: [
|
|
|
|
|
{ assignment: { project: { title: 'asc' } } },
|
|
|
|
|
{ submittedAt: 'asc' },
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get criteria labels from form
|
|
|
|
|
const criteriaLabels: Record<string, string> = {}
|
|
|
|
|
if (evaluations.length > 0) {
|
|
|
|
|
const criteria = evaluations[0].form.criteriaJson as Array<{
|
|
|
|
|
id: string
|
|
|
|
|
label: string
|
|
|
|
|
}>
|
|
|
|
|
criteria.forEach((c) => {
|
|
|
|
|
criteriaLabels[c.id] = c.label
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build export data
|
|
|
|
|
const data = evaluations.map((e) => {
|
|
|
|
|
const scores = e.criterionScoresJson as Record<string, number> | null
|
|
|
|
|
const criteriaScores: Record<string, number | null> = {}
|
|
|
|
|
|
|
|
|
|
Object.keys(criteriaLabels).forEach((id) => {
|
|
|
|
|
criteriaScores[criteriaLabels[id]] = scores?.[id] ?? null
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
projectTitle: e.assignment.project.title,
|
|
|
|
|
teamName: e.assignment.project.teamName,
|
|
|
|
|
tags: e.assignment.project.tags.join(', '),
|
|
|
|
|
jurorName: e.assignment.user.name,
|
|
|
|
|
jurorEmail: e.assignment.user.email,
|
|
|
|
|
...criteriaScores,
|
|
|
|
|
globalScore: e.globalScore,
|
|
|
|
|
decision: e.binaryDecision ? 'Yes' : 'No',
|
|
|
|
|
feedback: input.includeDetails ? e.feedbackText : null,
|
|
|
|
|
submittedAt: e.submittedAt?.toISOString(),
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
2026-02-05 21:09:06 +01:00
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'EXPORT',
|
|
|
|
|
entityType: 'Evaluation',
|
|
|
|
|
detailsJson: { roundId: input.roundId, count: data.length },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
data,
|
|
|
|
|
columns: [
|
|
|
|
|
'projectTitle',
|
|
|
|
|
'teamName',
|
|
|
|
|
'tags',
|
|
|
|
|
'jurorName',
|
|
|
|
|
'jurorEmail',
|
|
|
|
|
...Object.values(criteriaLabels),
|
|
|
|
|
'globalScore',
|
|
|
|
|
'decision',
|
|
|
|
|
...(input.includeDetails ? ['feedback'] : []),
|
|
|
|
|
'submittedAt',
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Export project scores summary
|
|
|
|
|
*/
|
|
|
|
|
projectScores: adminProcedure
|
|
|
|
|
.input(z.object({ roundId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
2026-02-04 14:15:06 +01:00
|
|
|
const projects = await ctx.prisma.project.findMany({
|
2026-01-30 13:41:32 +01:00
|
|
|
where: { roundId: input.roundId },
|
|
|
|
|
include: {
|
2026-02-04 14:15:06 +01:00
|
|
|
assignments: {
|
2026-01-30 13:41:32 +01:00
|
|
|
include: {
|
2026-02-04 14:15:06 +01:00
|
|
|
evaluation: {
|
|
|
|
|
where: { status: 'SUBMITTED' },
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-02-04 14:15:06 +01:00
|
|
|
orderBy: { title: 'asc' },
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
2026-02-04 14:15:06 +01:00
|
|
|
const data = projects.map((p) => {
|
2026-01-30 13:41:32 +01:00
|
|
|
const evaluations = p.assignments
|
|
|
|
|
.map((a) => a.evaluation)
|
|
|
|
|
.filter((e) => e !== null)
|
|
|
|
|
|
|
|
|
|
const globalScores = evaluations
|
|
|
|
|
.map((e) => e?.globalScore)
|
|
|
|
|
.filter((s): s is number => s !== null)
|
|
|
|
|
|
|
|
|
|
const yesVotes = evaluations.filter(
|
|
|
|
|
(e) => e?.binaryDecision === true
|
|
|
|
|
).length
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
title: p.title,
|
|
|
|
|
teamName: p.teamName,
|
2026-02-04 14:15:06 +01:00
|
|
|
status: p.status,
|
2026-01-30 13:41:32 +01:00
|
|
|
tags: p.tags.join(', '),
|
|
|
|
|
totalEvaluations: evaluations.length,
|
|
|
|
|
averageScore:
|
|
|
|
|
globalScores.length > 0
|
|
|
|
|
? (
|
|
|
|
|
globalScores.reduce((a, b) => a + b, 0) / globalScores.length
|
|
|
|
|
).toFixed(2)
|
|
|
|
|
: null,
|
|
|
|
|
minScore: globalScores.length > 0 ? Math.min(...globalScores) : null,
|
|
|
|
|
maxScore: globalScores.length > 0 ? Math.max(...globalScores) : null,
|
|
|
|
|
yesVotes,
|
|
|
|
|
noVotes: evaluations.length - yesVotes,
|
|
|
|
|
yesPercentage:
|
|
|
|
|
evaluations.length > 0
|
|
|
|
|
? ((yesVotes / evaluations.length) * 100).toFixed(1)
|
|
|
|
|
: null,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
2026-02-05 21:09:06 +01:00
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'EXPORT',
|
|
|
|
|
entityType: 'ProjectScores',
|
|
|
|
|
detailsJson: { roundId: input.roundId, count: data.length },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
data,
|
|
|
|
|
columns: [
|
|
|
|
|
'title',
|
|
|
|
|
'teamName',
|
|
|
|
|
'status',
|
|
|
|
|
'tags',
|
|
|
|
|
'totalEvaluations',
|
|
|
|
|
'averageScore',
|
|
|
|
|
'minScore',
|
|
|
|
|
'maxScore',
|
|
|
|
|
'yesVotes',
|
|
|
|
|
'noVotes',
|
|
|
|
|
'yesPercentage',
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Export assignments
|
|
|
|
|
*/
|
|
|
|
|
assignments: adminProcedure
|
|
|
|
|
.input(z.object({ roundId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const assignments = await ctx.prisma.assignment.findMany({
|
|
|
|
|
where: { roundId: input.roundId },
|
|
|
|
|
include: {
|
|
|
|
|
user: { select: { name: true, email: true } },
|
|
|
|
|
project: { select: { title: true, teamName: true } },
|
|
|
|
|
evaluation: { select: { status: true, submittedAt: true } },
|
|
|
|
|
},
|
|
|
|
|
orderBy: [{ project: { title: 'asc' } }, { user: { name: 'asc' } }],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const data = assignments.map((a) => ({
|
|
|
|
|
projectTitle: a.project.title,
|
|
|
|
|
teamName: a.project.teamName,
|
|
|
|
|
jurorName: a.user.name,
|
|
|
|
|
jurorEmail: a.user.email,
|
|
|
|
|
method: a.method,
|
|
|
|
|
isRequired: a.isRequired ? 'Yes' : 'No',
|
|
|
|
|
isCompleted: a.isCompleted ? 'Yes' : 'No',
|
|
|
|
|
evaluationStatus: a.evaluation?.status ?? 'NOT_STARTED',
|
|
|
|
|
submittedAt: a.evaluation?.submittedAt?.toISOString() ?? null,
|
|
|
|
|
assignedAt: a.createdAt.toISOString(),
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
data,
|
|
|
|
|
columns: [
|
|
|
|
|
'projectTitle',
|
|
|
|
|
'teamName',
|
|
|
|
|
'jurorName',
|
|
|
|
|
'jurorEmail',
|
|
|
|
|
'method',
|
|
|
|
|
'isRequired',
|
|
|
|
|
'isCompleted',
|
|
|
|
|
'evaluationStatus',
|
|
|
|
|
'submittedAt',
|
|
|
|
|
'assignedAt',
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* Export filtering results as CSV data
|
|
|
|
|
*/
|
|
|
|
|
filteringResults: adminProcedure
|
|
|
|
|
.input(z.object({ roundId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const results = await ctx.prisma.filteringResult.findMany({
|
|
|
|
|
where: { roundId: input.roundId },
|
|
|
|
|
include: {
|
|
|
|
|
project: {
|
|
|
|
|
select: {
|
|
|
|
|
title: true,
|
|
|
|
|
teamName: true,
|
|
|
|
|
competitionCategory: true,
|
|
|
|
|
country: true,
|
|
|
|
|
oceanIssue: true,
|
|
|
|
|
tags: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { project: { title: 'asc' } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Collect all unique AI screening keys across all results
|
|
|
|
|
const aiKeys = new Set<string>()
|
|
|
|
|
results.forEach((r) => {
|
|
|
|
|
if (r.aiScreeningJson && typeof r.aiScreeningJson === 'object') {
|
|
|
|
|
const screening = r.aiScreeningJson as Record<string, Record<string, unknown>>
|
|
|
|
|
for (const ruleResult of Object.values(screening)) {
|
|
|
|
|
if (ruleResult && typeof ruleResult === 'object') {
|
|
|
|
|
Object.keys(ruleResult).forEach((k) => aiKeys.add(k))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const sortedAiKeys = Array.from(aiKeys).sort()
|
|
|
|
|
|
|
|
|
|
const data = results.map((r) => {
|
|
|
|
|
// Flatten AI screening - take first rule result's values
|
|
|
|
|
const aiFlat: Record<string, unknown> = {}
|
|
|
|
|
if (r.aiScreeningJson && typeof r.aiScreeningJson === 'object') {
|
|
|
|
|
const screening = r.aiScreeningJson as Record<string, Record<string, unknown>>
|
|
|
|
|
const firstEntry = Object.values(screening)[0]
|
|
|
|
|
if (firstEntry && typeof firstEntry === 'object') {
|
|
|
|
|
for (const key of sortedAiKeys) {
|
|
|
|
|
const val = firstEntry[key]
|
|
|
|
|
aiFlat[`ai_${key}`] = val !== undefined ? String(val) : ''
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
projectTitle: r.project.title,
|
|
|
|
|
teamName: r.project.teamName ?? '',
|
|
|
|
|
category: r.project.competitionCategory ?? '',
|
|
|
|
|
country: r.project.country ?? '',
|
|
|
|
|
oceanIssue: r.project.oceanIssue ?? '',
|
|
|
|
|
tags: r.project.tags.join(', '),
|
|
|
|
|
outcome: r.outcome,
|
|
|
|
|
finalOutcome: r.finalOutcome ?? '',
|
|
|
|
|
overrideReason: r.overrideReason ?? '',
|
|
|
|
|
...aiFlat,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Build columns list
|
|
|
|
|
const baseColumns = [
|
|
|
|
|
'projectTitle',
|
|
|
|
|
'teamName',
|
|
|
|
|
'category',
|
|
|
|
|
'country',
|
|
|
|
|
'oceanIssue',
|
|
|
|
|
'tags',
|
|
|
|
|
'outcome',
|
|
|
|
|
'finalOutcome',
|
|
|
|
|
'overrideReason',
|
|
|
|
|
]
|
|
|
|
|
const aiColumns = sortedAiKeys.map((k) => `ai_${k}`)
|
|
|
|
|
|
|
|
|
|
// Audit log
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'EXPORT',
|
|
|
|
|
entityType: 'FilteringResult',
|
|
|
|
|
detailsJson: { roundId: input.roundId, count: data.length },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
data,
|
|
|
|
|
columns: [...baseColumns, ...aiColumns],
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
/**
|
|
|
|
|
* Export audit logs as CSV data
|
|
|
|
|
*/
|
|
|
|
|
auditLogs: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
userId: z.string().optional(),
|
|
|
|
|
action: z.string().optional(),
|
|
|
|
|
entityType: z.string().optional(),
|
|
|
|
|
startDate: z.date().optional(),
|
|
|
|
|
endDate: z.date().optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const { userId, action, entityType, startDate, endDate } = input
|
|
|
|
|
|
|
|
|
|
const where: Record<string, unknown> = {}
|
|
|
|
|
|
|
|
|
|
if (userId) where.userId = userId
|
|
|
|
|
if (action) where.action = { contains: action, mode: 'insensitive' }
|
|
|
|
|
if (entityType) where.entityType = entityType
|
|
|
|
|
if (startDate || endDate) {
|
|
|
|
|
where.timestamp = {}
|
|
|
|
|
if (startDate) (where.timestamp as Record<string, Date>).gte = startDate
|
|
|
|
|
if (endDate) (where.timestamp as Record<string, Date>).lte = endDate
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const logs = await ctx.prisma.auditLog.findMany({
|
|
|
|
|
where,
|
|
|
|
|
orderBy: { timestamp: 'desc' },
|
|
|
|
|
include: {
|
|
|
|
|
user: { select: { name: true, email: true } },
|
|
|
|
|
},
|
|
|
|
|
take: 10000, // Limit export to 10k records
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const data = logs.map((log) => ({
|
|
|
|
|
timestamp: log.timestamp.toISOString(),
|
|
|
|
|
userName: log.user?.name ?? 'System',
|
|
|
|
|
userEmail: log.user?.email ?? 'N/A',
|
|
|
|
|
action: log.action,
|
|
|
|
|
entityType: log.entityType,
|
|
|
|
|
entityId: log.entityId ?? '',
|
|
|
|
|
ipAddress: log.ipAddress ?? '',
|
|
|
|
|
userAgent: log.userAgent ?? '',
|
|
|
|
|
details: log.detailsJson ? JSON.stringify(log.detailsJson) : '',
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
data,
|
|
|
|
|
columns: [
|
|
|
|
|
'timestamp',
|
|
|
|
|
'userName',
|
|
|
|
|
'userEmail',
|
|
|
|
|
'action',
|
|
|
|
|
'entityType',
|
|
|
|
|
'entityId',
|
|
|
|
|
'ipAddress',
|
|
|
|
|
'userAgent',
|
|
|
|
|
'details',
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
}),
|
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>
2026-02-05 23:31:41 +01:00
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// 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
|
|
|
|
|
}),
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|