import { z } from 'zod' import { router, adminProcedure, observerProcedure } from '../trpc' import { logAudit } from '../utils/audit' 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 = {} 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 | null const criteriaScores: Record = {} 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 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, }) 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 }) => { const projects = await ctx.prisma.project.findMany({ where: { roundId: input.roundId }, include: { assignments: { include: { evaluation: { where: { status: 'SUBMITTED' }, }, }, }, }, orderBy: { title: 'asc' }, }) const data = projects.map((p) => { 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, status: p.status, 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 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, }) 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', ], } }), /** * 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() results.forEach((r) => { if (r.aiScreeningJson && typeof r.aiScreeningJson === 'object') { const screening = r.aiScreeningJson as Record> 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 = {} if (r.aiScreeningJson && typeof r.aiScreeningJson === 'object') { const screening = r.aiScreeningJson as Record> 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], } }), /** * 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 = {} 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).gte = startDate if (endDate) (where.timestamp as Record).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', ], } }), // ========================================================================= // 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 = { 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 = {} 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 | 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 }), })