import { z } from 'zod' import { router, adminProcedure } from '../trpc' import type { RoundType, RoundStatus, ProjectRoundStateValue } from '@prisma/client' // ─── Types ────────────────────────────────────────────────────────────────── type ProjectStateCounts = { PENDING: number IN_PROGRESS: number PASSED: number REJECTED: number COMPLETED: number WITHDRAWN: number total: number } export type PipelineRound = { id: string name: string slug: string roundType: RoundType status: RoundStatus sortOrder: number windowOpenAt: Date | null windowCloseAt: Date | null projectStates: ProjectStateCounts assignmentCount: number evalSubmitted: number evalDraft: number evalTotal: number filteringPassed: number filteringRejected: number filteringFlagged: number filteringTotal: number liveSessionStatus: string | null deliberationCount: number } export type DashboardAction = { id: string severity: 'critical' | 'warning' | 'info' title: string description: string href: string roundId?: string roundType?: RoundType count?: number } // ─── Helpers ───────────────────────────────────────────────────────────────── function emptyStateCounts(): ProjectStateCounts { return { PENDING: 0, IN_PROGRESS: 0, PASSED: 0, REJECTED: 0, COMPLETED: 0, WITHDRAWN: 0, total: 0 } } function daysUntil(date: Date): number { return Math.ceil((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24)) } function formatRoundType(rt: RoundType): string { return rt.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase()) } // ─── Router ────────────────────────────────────────────────────────────────── export const dashboardRouter = router({ /** * Get all dashboard stats in a single query batch. * Returns pipeline rounds, smart actions, and supporting data. */ getStats: adminProcedure .input(z.object({ editionId: z.string() })) .query(async ({ ctx, input }) => { const { editionId } = input const edition = await ctx.prisma.program.findUnique({ where: { id: editionId }, select: { name: true, year: true }, }) if (!edition) return null const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) // ── All queries in parallel ────────────────────────────────────── const [ // Pipeline rounds (all, ordered by sortOrder) allRounds, // Per-round project state breakdown stateBreakdown, // Per-round eval data (assignments with eval status) roundEvalData, // Per-round filtering results filteringStats, // Live session statuses liveSessions, // Deliberation session counts deliberationCounts, // Summary counts projectCount, newProjectsThisWeek, totalJurors, activeJurors, evaluationStats, totalAssignments, // Lists latestProjects, categoryBreakdown, oceanIssueBreakdown, recentActivity, // Recently active recentlyActiveEvals, // Action signals pendingCOIs, ] = await Promise.all([ // 1. All pipeline rounds ctx.prisma.round.findMany({ where: { competition: { programId: editionId } }, orderBy: { sortOrder: 'asc' }, select: { id: true, name: true, slug: true, roundType: true, status: true, sortOrder: true, windowOpenAt: true, windowCloseAt: true, _count: { select: { projectRoundStates: true, assignments: true, }, }, }, }), // 2. Per-round project state counts ctx.prisma.projectRoundState.groupBy({ by: ['roundId', 'state'], where: { round: { competition: { programId: editionId } } }, _count: true, }), // 3. Assignments with eval status (for per-round eval aggregation) ctx.prisma.assignment.findMany({ where: { round: { competition: { programId: editionId } } }, select: { roundId: true, evaluation: { select: { status: true } }, }, }), // 4. Filtering results per round ctx.prisma.filteringResult.groupBy({ by: ['roundId', 'outcome'], where: { round: { competition: { programId: editionId } } }, _count: true, }), // 5. Live session statuses ctx.prisma.liveVotingSession.findMany({ where: { round: { competition: { programId: editionId } } }, select: { roundId: true, status: true }, }), // 6. Deliberation session counts ctx.prisma.deliberationSession.groupBy({ by: ['roundId'], where: { competition: { programId: editionId } }, _count: true, }), // 7. Project count ctx.prisma.project.count({ where: { programId: editionId }, }), // 8. New projects this week ctx.prisma.project.count({ where: { programId: editionId, createdAt: { gte: sevenDaysAgo } }, }), // 9. Total jurors ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: { in: ['ACTIVE', 'INVITED', 'NONE'] }, assignments: { some: { round: { competition: { programId: editionId } } } }, }, }), // 10. Active jurors ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE', assignments: { some: { round: { competition: { programId: editionId } } } }, }, }), // 11. Global evaluation stats ctx.prisma.evaluation.groupBy({ by: ['status'], where: { assignment: { round: { competition: { programId: editionId } } } }, _count: true, }), // 12. Total assignments ctx.prisma.assignment.count({ where: { round: { competition: { programId: editionId } } }, }), // 13. Latest projects ctx.prisma.project.findMany({ where: { programId: editionId }, orderBy: { createdAt: 'desc' }, take: 8, select: { id: true, title: true, teamName: true, country: true, competitionCategory: true, oceanIssue: true, logoKey: true, createdAt: true, submittedAt: true, status: true, }, }), // 14. Category breakdown ctx.prisma.project.groupBy({ by: ['competitionCategory'], where: { programId: editionId }, _count: true, }), // 15. Ocean issue breakdown ctx.prisma.project.groupBy({ by: ['oceanIssue'], where: { programId: editionId }, _count: true, }), // 16. Recent activity ctx.prisma.auditLog.findMany({ where: { timestamp: { gte: sevenDaysAgo } }, orderBy: { timestamp: 'desc' }, take: 8, select: { id: true, action: true, entityType: true, timestamp: true, user: { select: { name: true } }, }, }), // 17. Recently active projects (with recent evaluations) ctx.prisma.evaluation.findMany({ where: { status: 'SUBMITTED', assignment: { round: { competition: { programId: editionId } }, }, }, orderBy: { submittedAt: 'desc' }, take: 8, select: { id: true, globalScore: true, submittedAt: true, assignment: { select: { user: { select: { name: true } }, project: { select: { id: true, title: true, teamName: true, country: true, competitionCategory: true, oceanIssue: true, logoKey: true, createdAt: true, submittedAt: true, status: true, }, }, }, }, }, }), // 18. Pending COIs ctx.prisma.conflictOfInterest.count({ where: { hasConflict: true, reviewedAt: null, assignment: { round: { competition: { programId: editionId } } }, }, }), ]) // ── Assemble pipeline rounds ──────────────────────────────────── // Build state counts map: roundId -> ProjectStateCounts const stateMap = new Map() for (const row of stateBreakdown) { if (!stateMap.has(row.roundId)) stateMap.set(row.roundId, emptyStateCounts()) const counts = stateMap.get(row.roundId)! const state = row.state as ProjectRoundStateValue if (state in counts) { counts[state as keyof Omit] = row._count counts.total += row._count } } // Build eval map: roundId -> { submitted, draft, total } const evalMap = new Map() for (const a of roundEvalData) { if (!evalMap.has(a.roundId)) evalMap.set(a.roundId, { submitted: 0, draft: 0, total: 0 }) const entry = evalMap.get(a.roundId)! entry.total++ if (a.evaluation?.status === 'SUBMITTED') entry.submitted++ else if (a.evaluation?.status === 'DRAFT') entry.draft++ } // Build filtering map: roundId -> { passed, rejected, flagged, total } const filterMap = new Map() for (const row of filteringStats) { if (!filterMap.has(row.roundId)) filterMap.set(row.roundId, { passed: 0, rejected: 0, flagged: 0, total: 0 }) const entry = filterMap.get(row.roundId)! entry.total += row._count if (row.outcome === 'PASSED') entry.passed = row._count else if (row.outcome === 'FILTERED_OUT') entry.rejected = row._count else if (row.outcome === 'FLAGGED') entry.flagged = row._count } // Build live session map: roundId -> status const liveMap = new Map() for (const s of liveSessions) { if (s.roundId) liveMap.set(s.roundId, s.status) } // Build deliberation map: roundId -> count const delibMap = new Map() for (const row of deliberationCounts) { delibMap.set(row.roundId, row._count) } // Assemble pipeline rounds const pipelineRounds: PipelineRound[] = allRounds.map((round) => { const states = stateMap.get(round.id) ?? emptyStateCounts() const evals = evalMap.get(round.id) ?? { submitted: 0, draft: 0, total: 0 } const filters = filterMap.get(round.id) ?? { passed: 0, rejected: 0, flagged: 0, total: 0 } return { id: round.id, name: round.name, slug: round.slug, roundType: round.roundType, status: round.status, sortOrder: round.sortOrder, windowOpenAt: round.windowOpenAt, windowCloseAt: round.windowCloseAt, projectStates: states, assignmentCount: round._count.assignments, evalSubmitted: evals.submitted, evalDraft: evals.draft, evalTotal: evals.total, filteringPassed: filters.passed, filteringRejected: filters.rejected, filteringFlagged: filters.flagged, filteringTotal: filters.total, liveSessionStatus: liveMap.get(round.id) ?? null, deliberationCount: delibMap.get(round.id) ?? 0, } }) // ── Determine active round ────────────────────────────────────── const activeRound = pipelineRounds.find((r) => r.status === 'ROUND_ACTIVE') ?? null const activeRoundId = activeRound?.id ?? null // ── Compute smart actions ─────────────────────────────────────── const nextActions: DashboardAction[] = [] const activeRounds = pipelineRounds.filter((r) => r.status === 'ROUND_ACTIVE') const lastActiveSortOrder = Math.max(...activeRounds.map((r) => r.sortOrder), -1) // 1. Next draft round (only the first one after the last active) const nextDraft = pipelineRounds.find( (r) => r.status === 'ROUND_DRAFT' && r.sortOrder > lastActiveSortOrder ) if (nextDraft) { nextActions.push({ id: `draft-${nextDraft.id}`, severity: 'info', title: `Configure "${nextDraft.name}"`, description: `Next round (${formatRoundType(nextDraft.roundType)}) is in draft`, href: `/admin/rounds/${nextDraft.id}`, roundId: nextDraft.id, roundType: nextDraft.roundType, }) } // 2. Per-active-round actions for (const round of activeRounds) { // Evaluation rounds: flag unassigned projects if (round.roundType === 'EVALUATION' && round.projectStates.total > 0 && round.assignmentCount === 0) { nextActions.push({ id: `unassigned-${round.id}`, severity: 'warning', title: `${round.projectStates.total} unassigned projects`, description: `"${round.name}" has projects without jury assignments`, href: `/admin/rounds/${round.id}`, roundId: round.id, roundType: round.roundType, count: round.projectStates.total, }) } // Filtering rounds: flag if filtering not started if (round.roundType === 'FILTERING' && round.filteringTotal === 0 && round.projectStates.total > 0) { nextActions.push({ id: `filtering-${round.id}`, severity: 'warning', title: 'Filtering not started', description: `"${round.name}" has ${round.projectStates.total} projects awaiting filtering`, href: `/admin/rounds/${round.id}`, roundId: round.id, roundType: round.roundType, }) } // Deadline warnings if (round.windowCloseAt) { const days = daysUntil(round.windowCloseAt) if (days > 0 && days <= 3) { nextActions.push({ id: `deadline-${round.id}`, severity: 'critical', title: `${days}d until "${round.name}" closes`, description: `Window closes ${round.windowCloseAt.toLocaleDateString()}`, href: `/admin/rounds/${round.id}`, roundId: round.id, roundType: round.roundType, }) } else if (days > 3 && days <= 7) { nextActions.push({ id: `deadline-${round.id}`, severity: 'warning', title: `${days}d until "${round.name}" closes`, description: `Window closes ${round.windowCloseAt.toLocaleDateString()}`, href: `/admin/rounds/${round.id}`, roundId: round.id, roundType: round.roundType, }) } } } // 3. Pending COIs if (pendingCOIs > 0) { nextActions.push({ id: 'pending-cois', severity: 'warning', title: `${pendingCOIs} COI declarations pending`, description: 'Jury members have declared conflicts that need admin review', href: '/admin/rounds', count: pendingCOIs, }) } // Sort by severity const severityOrder = { critical: 0, warning: 1, info: 2 } nextActions.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]) // ── Return ────────────────────────────────────────────────────── // Deduplicate recently active projects (same project may have multiple evals) const seenProjectIds = new Set() const recentlyActiveProjects = recentlyActiveEvals .filter((e) => { const pid = e.assignment.project.id if (seenProjectIds.has(pid)) return false seenProjectIds.add(pid) return true }) .map((e) => ({ ...e.assignment.project, latestEvaluator: e.assignment.user.name, latestScore: e.globalScore, evaluatedAt: e.submittedAt, })) return { edition, // Pipeline pipelineRounds, activeRoundId, // Smart actions nextActions, // Summary counts projectCount, newProjectsThisWeek, totalJurors, activeJurors, evaluationStats, totalAssignments, pendingCOIs, // Lists latestProjects, recentlyActiveProjects, categoryBreakdown, oceanIssueBreakdown, recentActivity, } }), getRecentEvaluations: adminProcedure .input(z.object({ editionId: z.string(), limit: z.number().int().min(1).max(50).optional() })) .query(async ({ ctx, input }) => { const take = input.limit ?? 10 const evaluations = await ctx.prisma.evaluation.findMany({ where: { status: 'SUBMITTED', assignment: { round: { competition: { programId: input.editionId } }, }, }, orderBy: { submittedAt: 'desc' }, take, select: { id: true, globalScore: true, binaryDecision: true, submittedAt: true, feedbackText: true, assignment: { select: { project: { select: { id: true, title: true } }, round: { select: { id: true, name: true } }, user: { select: { id: true, name: true, email: true } }, }, }, }, }) return evaluations }), })