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>
This commit is contained in:
2026-02-05 21:58:27 +01:00
parent 002a9dbfc3
commit 699248e40b
38 changed files with 5437 additions and 533 deletions

View File

@@ -228,6 +228,103 @@ export const exportRouter = router({
}
}),
/**
* 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
*/