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

638 lines
19 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc'
export const analyticsRouter = router({
/**
* Get score distribution for a round (histogram data)
*/
getScoreDistribution: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
select: {
criterionScoresJson: true,
},
})
// Extract all scores and calculate distribution
const allScores: number[] = []
evaluations.forEach((evaluation) => {
const scores = evaluation.criterionScoresJson as Record<string, number> | null
if (scores) {
Object.values(scores).forEach((score) => {
if (typeof score === 'number') {
allScores.push(score)
}
})
}
})
// Count scores by bucket (1-10)
const distribution = Array.from({ length: 10 }, (_, i) => ({
score: i + 1,
count: allScores.filter((s) => Math.round(s) === i + 1).length,
}))
return {
distribution,
totalScores: allScores.length,
averageScore:
allScores.length > 0
? allScores.reduce((a, b) => a + b, 0) / allScores.length
: 0,
}
}),
/**
* Get evaluation completion over time (timeline data)
*/
getEvaluationTimeline: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
select: {
submittedAt: true,
},
orderBy: { submittedAt: 'asc' },
})
// Group by date
const byDate: Record<string, number> = {}
let cumulative = 0
evaluations.forEach((evaluation) => {
if (evaluation.submittedAt) {
const date = evaluation.submittedAt.toISOString().split('T')[0]
if (!byDate[date]) {
byDate[date] = 0
}
byDate[date]++
}
})
// Convert to cumulative timeline
const timeline = Object.entries(byDate)
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, count]) => {
cumulative += count
return {
date,
daily: count,
cumulative,
}
})
return timeline
}),
/**
* Get juror workload distribution
*/
getJurorWorkload: observerProcedure
.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 } },
evaluation: {
select: { id: true, status: true },
},
},
})
// Group by user
const byUser: Record<
string,
{ name: string; assigned: number; completed: number }
> = {}
assignments.forEach((assignment) => {
const userId = assignment.userId
if (!byUser[userId]) {
byUser[userId] = {
name: assignment.user.name || assignment.user.email || 'Unknown',
assigned: 0,
completed: 0,
}
}
byUser[userId].assigned++
if (assignment.evaluation?.status === 'SUBMITTED') {
byUser[userId].completed++
}
})
return Object.entries(byUser)
.map(([id, data]) => ({
id,
...data,
completionRate:
data.assigned > 0
? Math.round((data.completed / data.assigned) * 100)
: 0,
}))
.sort((a, b) => b.assigned - a.assigned)
}),
/**
* Get project rankings with average scores
*/
getProjectRankings: observerProcedure
.input(z.object({ roundId: z.string(), limit: z.number().optional() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
Comprehensive platform review: security fixes, query optimization, UI improvements, and code cleanup Security (Critical/High): - Fix path traversal bypass in local storage provider (path.resolve + prefix check) - Fix timing-unsafe HMAC comparison (crypto.timingSafeEqual) - Add auth + ownership checks to email API routes (verify-credentials, change-password) - Remove hardcoded secret key fallback in local storage provider - Add production credential check for MinIO (fail loudly if not set) - Remove DB error details from health check response - Add stricter rate limiting on application submissions (5/hour) - Add rate limiting on email availability check (anti-enumeration) - Change getAIAssignmentJobStatus to adminProcedure - Block dangerous file extensions on upload - Reduce project list max perPage from 5000 to 200 Query Optimization: - Optimize analytics getProjectRankings with select instead of full includes - Fix N+1 in mentor.getSuggestions (batch findMany instead of loop) - Use _count for files instead of fetching full file records in project list - Switch to bulk notifications in assignment and user bulk operations - Batch filtering upserts (25 per transaction instead of all at once) UI/UX: - Replace Inter font with Montserrat in public layout (brand consistency) - Use Logo component in public layout instead of placeholder - Create branded 404 and error pages - Make admin rounds table responsive with mobile card layout - Fix notification bell paths to be role-aware - Replace hardcoded slate colors with semantic tokens in admin sidebar - Force light mode (dark mode untested) - Adjust CardTitle default size - Improve muted-foreground contrast for accessibility (A11Y) - Move profile form state initialization to useEffect Code Quality: - Extract shared toProjectWithRelations to anonymization.ts (removed 3 duplicates) - Remove dead code: getObjectInfo, isValidImageSize, unused batch tag functions, debug logs - Remove unused twilio dependency - Remove redundant email index from schema - Add actual storage object deletion when file records are deleted - Wrap evaluation submit + assignment update in - Add comprehensive platform review document Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 20:31:08 +01:00
select: {
id: true,
title: true,
teamName: true,
status: true,
assignments: {
Comprehensive platform review: security fixes, query optimization, UI improvements, and code cleanup Security (Critical/High): - Fix path traversal bypass in local storage provider (path.resolve + prefix check) - Fix timing-unsafe HMAC comparison (crypto.timingSafeEqual) - Add auth + ownership checks to email API routes (verify-credentials, change-password) - Remove hardcoded secret key fallback in local storage provider - Add production credential check for MinIO (fail loudly if not set) - Remove DB error details from health check response - Add stricter rate limiting on application submissions (5/hour) - Add rate limiting on email availability check (anti-enumeration) - Change getAIAssignmentJobStatus to adminProcedure - Block dangerous file extensions on upload - Reduce project list max perPage from 5000 to 200 Query Optimization: - Optimize analytics getProjectRankings with select instead of full includes - Fix N+1 in mentor.getSuggestions (batch findMany instead of loop) - Use _count for files instead of fetching full file records in project list - Switch to bulk notifications in assignment and user bulk operations - Batch filtering upserts (25 per transaction instead of all at once) UI/UX: - Replace Inter font with Montserrat in public layout (brand consistency) - Use Logo component in public layout instead of placeholder - Create branded 404 and error pages - Make admin rounds table responsive with mobile card layout - Fix notification bell paths to be role-aware - Replace hardcoded slate colors with semantic tokens in admin sidebar - Force light mode (dark mode untested) - Adjust CardTitle default size - Improve muted-foreground contrast for accessibility (A11Y) - Move profile form state initialization to useEffect Code Quality: - Extract shared toProjectWithRelations to anonymization.ts (removed 3 duplicates) - Remove dead code: getObjectInfo, isValidImageSize, unused batch tag functions, debug logs - Remove unused twilio dependency - Remove redundant email index from schema - Add actual storage object deletion when file records are deleted - Wrap evaluation submit + assignment update in - Add comprehensive platform review document Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 20:31:08 +01:00
select: {
evaluation: {
select: { criterionScoresJson: true, status: true },
},
},
},
},
})
// Calculate average scores
const rankings = projects
.map((project) => {
const allScores: number[] = []
project.assignments.forEach((assignment) => {
const evaluation = assignment.evaluation
if (evaluation?.status === 'SUBMITTED') {
const scores = evaluation.criterionScoresJson as Record<
string,
number
> | null
if (scores) {
const scoreValues = Object.values(scores).filter(
(s): s is number => typeof s === 'number'
)
if (scoreValues.length > 0) {
const average =
scoreValues.reduce((a, b) => a + b, 0) / scoreValues.length
allScores.push(average)
}
}
}
})
const averageScore =
allScores.length > 0
? allScores.reduce((a, b) => a + b, 0) / allScores.length
: null
return {
id: project.id,
title: project.title,
teamName: project.teamName,
status: project.status,
averageScore,
evaluationCount: allScores.length,
}
})
.filter((p) => p.averageScore !== null)
.sort((a, b) => (b.averageScore || 0) - (a.averageScore || 0))
return input.limit ? rankings.slice(0, input.limit) : rankings
}),
/**
* Get status breakdown (pie chart data)
*/
getStatusBreakdown: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.groupBy({
by: ['status'],
where: { roundId: input.roundId },
_count: true,
})
return projects.map((p) => ({
status: p.status,
count: p._count,
}))
}),
/**
* Get overview stats for dashboard
*/
getOverviewStats: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const [
projectCount,
assignmentCount,
evaluationCount,
jurorCount,
statusCounts,
] = 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 },
}),
ctx.prisma.project.groupBy({
by: ['status'],
where: { roundId: input.roundId },
_count: true,
}),
])
const completionRate =
assignmentCount > 0
? Math.round((evaluationCount / assignmentCount) * 100)
: 0
return {
projectCount,
assignmentCount,
evaluationCount,
jurorCount: jurorCount.length,
completionRate,
statusBreakdown: statusCounts.map((s) => ({
status: s.status,
count: s._count,
})),
}
}),
/**
* Get criteria-level score distribution
*/
getCriteriaScores: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Get active evaluation form for this round
const evaluationForm = await ctx.prisma.evaluationForm.findFirst({
where: { roundId: input.roundId, isActive: true },
})
if (!evaluationForm?.criteriaJson) {
return []
}
// Parse criteria from JSON
const criteria = evaluationForm.criteriaJson as Array<{
id: string
label: string
}>
if (!criteria || criteria.length === 0) {
return []
}
// Get all evaluations
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
select: { criterionScoresJson: true },
})
// Calculate average score per criterion
const criteriaScores = criteria.map((criterion) => {
const scores: number[] = []
evaluations.forEach((evaluation) => {
const criterionScoresJson = evaluation.criterionScoresJson as Record<
string,
number
> | null
if (criterionScoresJson && typeof criterionScoresJson[criterion.id] === 'number') {
scores.push(criterionScoresJson[criterion.id])
}
})
return {
id: criterion.id,
name: criterion.label,
averageScore:
scores.length > 0
? scores.reduce((a, b) => a + b, 0) / scores.length
: 0,
count: scores.length,
}
})
return criteriaScores
}),
/**
* Get geographic distribution of projects by country
*/
getGeographicDistribution: observerProcedure
.input(
z.object({
programId: z.string(),
roundId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const where = input.roundId
? { roundId: input.roundId }
: { round: { programId: input.programId } }
const distribution = await ctx.prisma.project.groupBy({
by: ['country'],
where,
_count: { id: true },
})
return distribution.map((d) => ({
countryCode: d.country || 'UNKNOWN',
count: d._count.id,
}))
}),
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
// =========================================================================
// Advanced Analytics (F10)
// =========================================================================
/**
* Compare metrics across multiple rounds
*/
getCrossRoundComparison: observerProcedure
.input(z.object({ roundIds: z.array(z.string()).min(2) }))
.query(async ({ ctx, input }) => {
const comparisons = await Promise.all(
input.roundIds.map(async (roundId) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true, name: true },
})
const [projectCount, assignmentCount, evaluationCount] = await Promise.all([
ctx.prisma.project.count({ where: { roundId } }),
ctx.prisma.assignment.count({ where: { roundId } }),
ctx.prisma.evaluation.count({
where: {
assignment: { roundId },
status: 'SUBMITTED',
},
}),
])
const completionRate = assignmentCount > 0
? Math.round((evaluationCount / assignmentCount) * 100)
: 0
// Get average scores
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId },
status: 'SUBMITTED',
},
select: { globalScore: true },
})
const globalScores = evaluations
.map((e) => e.globalScore)
.filter((s): s is number => s !== null)
const averageScore = globalScores.length > 0
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
: null
// Score distribution
const distribution = Array.from({ length: 10 }, (_, i) => ({
score: i + 1,
count: globalScores.filter((s) => Math.round(s) === i + 1).length,
}))
return {
roundId,
roundName: round.name,
projectCount,
evaluationCount,
completionRate,
averageScore,
scoreDistribution: distribution,
}
})
)
return comparisons
}),
/**
* Get juror consistency metrics for a round
*/
getJurorConsistency: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
include: {
assignment: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
})
// Group scores by juror
const jurorScores: Record<string, { name: string; email: string; scores: number[] }> = {}
evaluations.forEach((e) => {
const userId = e.assignment.userId
if (!jurorScores[userId]) {
jurorScores[userId] = {
name: e.assignment.user.name || e.assignment.user.email || 'Unknown',
email: e.assignment.user.email || '',
scores: [],
}
}
if (e.globalScore !== null) {
jurorScores[userId].scores.push(e.globalScore)
}
})
// Calculate overall average
const allScores = Object.values(jurorScores).flatMap((j) => j.scores)
const overallAverage = allScores.length > 0
? allScores.reduce((a, b) => a + b, 0) / allScores.length
: 0
// Calculate per-juror metrics
const metrics = Object.entries(jurorScores).map(([userId, data]) => {
const avg = data.scores.length > 0
? data.scores.reduce((a, b) => a + b, 0) / data.scores.length
: 0
const variance = data.scores.length > 1
? data.scores.reduce((sum, s) => sum + Math.pow(s - avg, 2), 0) / data.scores.length
: 0
const stddev = Math.sqrt(variance)
const deviationFromOverall = Math.abs(avg - overallAverage)
return {
userId,
name: data.name,
email: data.email,
evaluationCount: data.scores.length,
averageScore: avg,
stddev,
deviationFromOverall,
isOutlier: deviationFromOverall > 2, // Flag if 2+ points from mean
}
})
return {
overallAverage,
jurors: metrics.sort((a, b) => b.deviationFromOverall - a.deviationFromOverall),
}
}),
/**
* Get diversity metrics for projects in a round
*/
getDiversityMetrics: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
select: {
country: true,
competitionCategory: true,
oceanIssue: true,
tags: true,
},
})
const total = projects.length
if (total === 0) {
return { total: 0, byCountry: [], byCategory: [], byOceanIssue: [], byTag: [] }
}
// By country
const countryCounts: Record<string, number> = {}
projects.forEach((p) => {
const key = p.country || 'Unknown'
countryCounts[key] = (countryCounts[key] || 0) + 1
})
const byCountry = Object.entries(countryCounts)
.map(([country, count]) => ({ country, count, percentage: (count / total) * 100 }))
.sort((a, b) => b.count - a.count)
// By competition category
const categoryCounts: Record<string, number> = {}
projects.forEach((p) => {
const key = p.competitionCategory || 'Uncategorized'
categoryCounts[key] = (categoryCounts[key] || 0) + 1
})
const byCategory = Object.entries(categoryCounts)
.map(([category, count]) => ({ category, count, percentage: (count / total) * 100 }))
.sort((a, b) => b.count - a.count)
// By ocean issue
const issueCounts: Record<string, number> = {}
projects.forEach((p) => {
const key = p.oceanIssue || 'Unspecified'
issueCounts[key] = (issueCounts[key] || 0) + 1
})
const byOceanIssue = Object.entries(issueCounts)
.map(([issue, count]) => ({ issue, count, percentage: (count / total) * 100 }))
.sort((a, b) => b.count - a.count)
// By tag
const tagCounts: Record<string, number> = {}
projects.forEach((p) => {
p.tags.forEach((tag) => {
tagCounts[tag] = (tagCounts[tag] || 0) + 1
})
})
const byTag = Object.entries(tagCounts)
.map(([tag, count]) => ({ tag, count, percentage: (count / total) * 100 }))
.sort((a, b) => b.count - a.count)
return { total, byCountry, byCategory, byOceanIssue, byTag }
}),
/**
* Get year-over-year stats across all rounds in a program
*/
getYearOverYear: observerProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const rounds = await ctx.prisma.round.findMany({
where: { programId: input.programId },
select: { id: true, name: true, createdAt: true },
orderBy: { createdAt: 'asc' },
})
const stats = await Promise.all(
rounds.map(async (round) => {
const [projectCount, evaluationCount, assignmentCount] = await Promise.all([
ctx.prisma.project.count({ where: { roundId: round.id } }),
ctx.prisma.evaluation.count({
where: {
assignment: { roundId: round.id },
status: 'SUBMITTED',
},
}),
ctx.prisma.assignment.count({ where: { roundId: round.id } }),
])
const completionRate = assignmentCount > 0
? Math.round((evaluationCount / assignmentCount) * 100)
: 0
// Average score
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: round.id },
status: 'SUBMITTED',
},
select: { globalScore: true },
})
const scores = evaluations
.map((e) => e.globalScore)
.filter((s): s is number => s !== null)
const averageScore = scores.length > 0
? scores.reduce((a, b) => a + b, 0) / scores.length
: null
return {
roundId: round.id,
roundName: round.name,
createdAt: round.createdAt,
projectCount,
evaluationCount,
completionRate,
averageScore,
}
})
)
return stats
}),
})