MOPC-App/src/server/routers/export.ts

622 lines
18 KiB
TypeScript

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<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
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<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],
}
}),
/**
* 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',
],
}
}),
// =========================================================================
// 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
}),
})