Files
MOPC-Portal/src/server/routers/export.ts
Matt 3ccf9b0542 feat: per-category evaluation criteria (startup vs business concept)
Add ability to define completely different evaluation criteria for each
competition category. Admins toggle "Separate Criteria per Category" in
round config, then configure criteria independently via tabbed editor.

- Schema: add nullable `category` to EvaluationForm with updated constraints
- Config: add `perCategoryCriteria` boolean to EvaluationConfigSchema
- Helper: new `findActiveForm()` with category-aware resolution + fallback
- Backend: getForm, upsertForm, getStageForm, startStage all category-aware
- AI services: use project category for form lookup in summaries + ranking
- Export/ranking: merge criteria from all active forms for cross-category reports
- Admin UI: toggle switch + tabbed criteria editor with per-category builders
- Jury UI: auto-selects correct form based on project category (invisible to juror)
- Fully backwards compatible: toggle defaults OFF, existing forms unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:03:22 -04:00

711 lines
22 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 with per-criterion averages
*/
projectScores: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Fetch all active evaluation forms for this round (shared + category-specific)
const activeForms = await ctx.prisma.evaluationForm.findMany({
where: { roundId: input.roundId, isActive: true },
select: { criteriaJson: true },
})
// Merge criteria across all forms, deduplicating by criterion id
const seenCriterionIds = new Set<string>()
const criteria: Array<{ id: string; label: string; type?: string }> = []
for (const f of activeForms) {
const fc = (f.criteriaJson as Array<{ id: string; label: string; type?: string }> | null) ?? []
for (const c of fc) {
if (!seenCriterionIds.has(c.id)) {
seenCriterionIds.add(c.id)
criteria.push(c)
}
}
}
const numericCriteria = criteria.filter((c) => !c.type || c.type === 'numeric')
const projects = await ctx.prisma.project.findMany({
where: {
assignments: { some: { roundId: input.roundId } },
},
include: {
assignments: {
where: { roundId: input.roundId },
include: {
evaluation: {
where: { status: 'SUBMITTED' },
select: {
globalScore: true,
binaryDecision: true,
criterionScoresJson: true,
},
},
},
},
},
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
// Per-criterion averages
const criterionAvgs: Record<string, string | null> = {}
for (const c of numericCriteria) {
const values: number[] = []
for (const e of evaluations) {
const scores = e?.criterionScoresJson as Record<string, number> | null
if (scores && typeof scores[c.id] === 'number') values.push(scores[c.id])
}
criterionAvgs[c.label] = values.length > 0
? (values.reduce((a, b) => a + b, 0) / values.length).toFixed(2)
: null
}
return {
title: p.title,
teamName: p.teamName,
category: p.competitionCategory ?? '',
country: p.country ?? '',
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,
...criterionAvgs,
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',
'category',
'country',
'status',
'tags',
'totalEvaluations',
'averageScore',
'minScore',
'maxScore',
...numericCriteria.map((c) => c.label),
'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' } }],
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'EXPORT',
entityType: 'Assignment',
detailsJson: { roundId: input.roundId, count: assignments.length, exportType: 'assignments' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
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
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'EXPORT',
entityType: 'AuditLog',
detailsJson: {
count: logs.length,
exportType: 'auditLogs',
filters: { userId, action, entityType, startDate: startDate?.toISOString(), endDate: endDate?.toISOString() },
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
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: {
competition: {
include: {
program: { select: { name: true, year: true } },
},
},
},
})
const result: Record<string, unknown> = {
roundName: round.name,
programName: round.competition.program.name,
programYear: round.competition.program.year,
generatedAt: new Date().toISOString(),
}
// Summary stats
if (includeSection('summary')) {
const [projectCount, assignmentCount, evaluationCount, jurorCount] = await Promise.all([
ctx.prisma.project.count({
where: { assignments: { some: { 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: { assignments: { some: { 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')) {
// Fetch all active forms (shared + category-specific) and merge criteria
const allForms = await ctx.prisma.evaluationForm.findMany({
where: { roundId: input.roundId, isActive: true },
})
const seenIds = new Set<string>()
const allCriteria: Array<{ id: string; label: string }> = []
for (const f of allForms) {
const fc = (f.criteriaJson as Array<{ id: string; label: string }> | null) ?? []
for (const c of fc) {
if (!seenIds.has(c.id)) {
seenIds.add(c.id)
allCriteria.push(c)
}
}
}
if (allCriteria.length > 0) {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
select: { criterionScoresJson: true },
})
result.criteriaBreakdown = allCriteria.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 (err) {
console.error('Failed to write audit log for round export:', err)
// Never throw on audit failure
}
return result
}),
})