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

@@ -1,11 +1,11 @@
import { z } from 'zod'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc'
export const analyticsRouter = router({
/**
* Get score distribution for a round (histogram data)
*/
getScoreDistribution: adminProcedure
getScoreDistribution: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
@@ -50,7 +50,7 @@ export const analyticsRouter = router({
/**
* Get evaluation completion over time (timeline data)
*/
getEvaluationTimeline: adminProcedure
getEvaluationTimeline: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
@@ -96,7 +96,7 @@ export const analyticsRouter = router({
/**
* Get juror workload distribution
*/
getJurorWorkload: adminProcedure
getJurorWorkload: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const assignments = await ctx.prisma.assignment.findMany({
@@ -145,7 +145,7 @@ export const analyticsRouter = router({
/**
* Get project rankings with average scores
*/
getProjectRankings: adminProcedure
getProjectRankings: observerProcedure
.input(z.object({ roundId: z.string(), limit: z.number().optional() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
@@ -213,7 +213,7 @@ export const analyticsRouter = router({
/**
* Get status breakdown (pie chart data)
*/
getStatusBreakdown: adminProcedure
getStatusBreakdown: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.groupBy({
@@ -231,7 +231,7 @@ export const analyticsRouter = router({
/**
* Get overview stats for dashboard
*/
getOverviewStats: adminProcedure
getOverviewStats: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const [
@@ -281,7 +281,7 @@ export const analyticsRouter = router({
/**
* Get criteria-level score distribution
*/
getCriteriaScores: adminProcedure
getCriteriaScores: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Get active evaluation form for this round
@@ -343,7 +343,7 @@ export const analyticsRouter = router({
/**
* Get geographic distribution of projects by country
*/
getGeographicDistribution: adminProcedure
getGeographicDistribution: observerProcedure
.input(
z.object({
programId: z.string(),

View File

@@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { getPresignedUrl } from '@/lib/minio'
import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification'
// Bucket for applicant submissions
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
@@ -231,6 +232,7 @@ export const applicantRouter = router({
fileName: z.string(),
mimeType: z.string(),
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
roundId: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -248,6 +250,9 @@ export const applicantRouter = router({
id: input.projectId,
submittedByUserId: ctx.user.id,
},
include: {
round: { select: { id: true, votingStartAt: true, settingsJson: true } },
},
})
if (!project) {
@@ -257,8 +262,38 @@ export const applicantRouter = router({
})
}
// Can't upload if already submitted
if (project.submittedAt) {
// Check round upload deadline policy if roundId provided
let isLate = false
const targetRoundId = input.roundId || project.roundId
if (targetRoundId) {
const round = input.roundId
? await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { votingStartAt: true, settingsJson: true },
})
: project.round
if (round) {
const settings = round.settingsJson as Record<string, unknown> | null
const uploadPolicy = settings?.uploadDeadlinePolicy as string | undefined
const now = new Date()
const roundStarted = round.votingStartAt && now > round.votingStartAt
if (roundStarted && uploadPolicy === 'BLOCK') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Uploads are blocked after the round has started',
})
}
if (roundStarted && uploadPolicy === 'ALLOW_LATE') {
isLate = true
}
}
}
// Can't upload if already submitted (unless round allows it)
if (project.submittedAt && !isLate) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot modify a submitted project',
@@ -275,6 +310,8 @@ export const applicantRouter = router({
url,
bucket: SUBMISSIONS_BUCKET,
objectKey,
isLate,
roundId: targetRoundId,
}
}),
@@ -291,6 +328,8 @@ export const applicantRouter = router({
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
bucket: z.string(),
objectKey: z.string(),
roundId: z.string().optional(),
isLate: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -317,13 +356,14 @@ export const applicantRouter = router({
})
}
const { projectId, ...fileData } = input
const { projectId, roundId, isLate, ...fileData } = input
// Delete existing file of same type if exists
// Delete existing file of same type, scoped by roundId if provided
await ctx.prisma.projectFile.deleteMany({
where: {
projectId,
fileType: input.fileType,
...(roundId ? { roundId } : {}),
},
})
@@ -332,6 +372,8 @@ export const applicantRouter = router({
data: {
projectId,
...fileData,
roundId: roundId || null,
isLate: isLate || false,
},
})
@@ -380,6 +422,48 @@ export const applicantRouter = router({
return { success: true }
}),
/**
* Get status timeline from ProjectStatusHistory
*/
getStatusTimeline: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
// Verify user has access to this project
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: { userId: ctx.user.id },
},
},
],
},
select: { id: true },
})
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found',
})
}
const history = await ctx.prisma.projectStatusHistory.findMany({
where: { projectId: input.projectId },
orderBy: { changedAt: 'asc' },
select: {
status: true,
changedAt: true,
changedBy: true,
},
})
return history
}),
/**
* Get submission status timeline
*/
@@ -412,6 +496,9 @@ export const applicantRouter = router({
},
},
},
wonAwards: {
select: { id: true, name: true },
},
},
})
@@ -425,40 +512,89 @@ export const applicantRouter = router({
// Get the project status
const currentStatus = project.status ?? 'SUBMITTED'
// Build timeline
// Fetch actual status history
const statusHistory = await ctx.prisma.projectStatusHistory.findMany({
where: { projectId: input.projectId },
orderBy: { changedAt: 'asc' },
select: { status: true, changedAt: true },
})
// Build a map of status -> earliest changedAt
const statusDateMap = new Map<string, Date>()
for (const entry of statusHistory) {
if (!statusDateMap.has(entry.status)) {
statusDateMap.set(entry.status, entry.changedAt)
}
}
const isRejected = currentStatus === 'REJECTED'
const hasWonAward = project.wonAwards.length > 0
// Build timeline - handle REJECTED as terminal state
const timeline = [
{
status: 'CREATED',
label: 'Application Started',
date: project.createdAt,
completed: true,
isTerminal: false,
},
{
status: 'SUBMITTED',
label: 'Application Submitted',
date: project.submittedAt,
completed: !!project.submittedAt,
date: project.submittedAt || statusDateMap.get('SUBMITTED') || null,
completed: !!project.submittedAt || statusDateMap.has('SUBMITTED'),
isTerminal: false,
},
{
status: 'UNDER_REVIEW',
label: 'Under Review',
date: currentStatus === 'SUBMITTED' && project.submittedAt ? project.submittedAt : null,
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus),
},
{
status: 'SEMIFINALIST',
label: 'Semi-finalist',
date: null, // Would need status change tracking
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus),
},
{
status: 'FINALIST',
label: 'Finalist',
date: null,
completed: ['FINALIST', 'WINNER'].includes(currentStatus),
date: statusDateMap.get('ELIGIBLE') || statusDateMap.get('ASSIGNED') ||
(currentStatus !== 'SUBMITTED' && project.submittedAt ? project.submittedAt : null),
completed: ['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(currentStatus),
isTerminal: false,
},
]
if (isRejected) {
// For rejected projects, show REJECTED as the terminal red step
timeline.push({
status: 'REJECTED',
label: 'Not Selected',
date: statusDateMap.get('REJECTED') || null,
completed: true,
isTerminal: true,
})
} else {
// Normal progression
timeline.push(
{
status: 'SEMIFINALIST',
label: 'Semi-finalist',
date: statusDateMap.get('SEMIFINALIST') || null,
completed: ['SEMIFINALIST', 'FINALIST'].includes(currentStatus) || hasWonAward,
isTerminal: false,
},
{
status: 'FINALIST',
label: 'Finalist',
date: statusDateMap.get('FINALIST') || null,
completed: currentStatus === 'FINALIST' || hasWonAward,
isTerminal: false,
},
)
if (hasWonAward) {
timeline.push({
status: 'WINNER',
label: `Winner${project.wonAwards.length > 0 ? ` - ${project.wonAwards[0].name}` : ''}`,
date: null,
completed: true,
isTerminal: false,
})
}
}
return {
project,
timeline,
@@ -714,4 +850,130 @@ export const applicantRouter = router({
return { success: true }
}),
/**
* Send a message to the assigned mentor
*/
sendMentorMessage: protectedProcedure
.input(
z.object({
projectId: z.string(),
message: z.string().min(1).max(5000),
})
)
.mutation(async ({ ctx, input }) => {
// Verify user is part of this project team
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: { userId: ctx.user.id },
},
},
],
},
include: {
mentorAssignment: { select: { mentorId: true } },
},
})
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found or you do not have access',
})
}
if (!project.mentorAssignment) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No mentor assigned to this project',
})
}
const mentorMessage = await ctx.prisma.mentorMessage.create({
data: {
projectId: input.projectId,
senderId: ctx.user.id,
message: input.message,
},
include: {
sender: {
select: { id: true, name: true, email: true },
},
},
})
// Notify the mentor
await createNotification({
userId: project.mentorAssignment.mentorId,
type: 'MENTOR_MESSAGE',
title: 'New Message',
message: `${ctx.user.name || 'Team member'} sent a message about "${project.title}"`,
linkUrl: `/mentor/projects/${input.projectId}`,
linkLabel: 'View Message',
priority: 'normal',
metadata: {
projectId: input.projectId,
projectName: project.title,
},
})
return mentorMessage
}),
/**
* Get mentor messages for a project (applicant side)
*/
getMentorMessages: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
// Verify user is part of this project team
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: { userId: ctx.user.id },
},
},
],
},
select: { id: true },
})
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found or you do not have access',
})
}
const messages = await ctx.prisma.mentorMessage.findMany({
where: { projectId: input.projectId },
include: {
sender: {
select: { id: true, name: true, email: true, role: true },
},
},
orderBy: { createdAt: 'asc' },
})
// Mark unread messages from mentor as read
await ctx.prisma.mentorMessage.updateMany({
where: {
projectId: input.projectId,
senderId: { not: ctx.user.id },
isRead: false,
},
data: { isRead: true },
})
return messages
}),
})

View File

@@ -2,6 +2,9 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
import { processEvaluationReminders } from '../services/evaluation-reminders'
import { generateSummary } from '@/server/services/ai-evaluation-summary'
export const evaluationRouter = router({
/**
@@ -89,7 +92,7 @@ export const evaluationRouter = router({
.input(
z.object({
id: z.string(),
criterionScoresJson: z.record(z.number()).optional(),
criterionScoresJson: z.record(z.union([z.number(), z.string(), z.boolean()])).optional(),
globalScore: z.number().int().min(1).max(10).optional().nullable(),
binaryDecision: z.boolean().optional().nullable(),
feedbackText: z.string().optional().nullable(),
@@ -134,7 +137,7 @@ export const evaluationRouter = router({
.input(
z.object({
id: z.string(),
criterionScoresJson: z.record(z.number()),
criterionScoresJson: z.record(z.union([z.number(), z.string(), z.boolean()])),
globalScore: z.number().int().min(1).max(10),
binaryDecision: z.boolean(),
feedbackText: z.string().min(10),
@@ -325,4 +328,297 @@ export const evaluationRouter = router({
orderBy: { submittedAt: 'desc' },
})
}),
// =========================================================================
// Conflict of Interest (COI) Endpoints
// =========================================================================
/**
* Declare a conflict of interest for an assignment
*/
declareCOI: protectedProcedure
.input(
z.object({
assignmentId: z.string(),
hasConflict: z.boolean(),
conflictType: z.string().optional(),
description: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Look up the assignment to get projectId, roundId, userId
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
where: { id: input.assignmentId },
include: {
project: { select: { title: true } },
round: { select: { name: true } },
},
})
// Verify ownership
if (assignment.userId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' })
}
// Upsert COI record
const coi = await ctx.prisma.conflictOfInterest.upsert({
where: { assignmentId: input.assignmentId },
create: {
assignmentId: input.assignmentId,
userId: ctx.user.id,
projectId: assignment.projectId,
roundId: assignment.roundId,
hasConflict: input.hasConflict,
conflictType: input.hasConflict ? input.conflictType : null,
description: input.hasConflict ? input.description : null,
},
update: {
hasConflict: input.hasConflict,
conflictType: input.hasConflict ? input.conflictType : null,
description: input.hasConflict ? input.description : null,
declaredAt: new Date(),
},
})
// Notify admins if conflict declared
if (input.hasConflict) {
await notifyAdmins({
type: NotificationTypes.JURY_INACTIVE,
title: 'Conflict of Interest Declared',
message: `${ctx.user.name || ctx.user.email} declared a conflict of interest (${input.conflictType || 'unspecified'}) for project "${assignment.project.title}" in ${assignment.round.name}.`,
linkUrl: `/admin/rounds/${assignment.roundId}/coi`,
linkLabel: 'Review COI',
priority: 'high',
metadata: {
assignmentId: input.assignmentId,
userId: ctx.user.id,
projectId: assignment.projectId,
roundId: assignment.roundId,
conflictType: input.conflictType,
},
})
}
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'COI_DECLARED',
entityType: 'ConflictOfInterest',
entityId: coi.id,
detailsJson: {
assignmentId: input.assignmentId,
projectId: assignment.projectId,
roundId: assignment.roundId,
hasConflict: input.hasConflict,
conflictType: input.conflictType,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return coi
}),
/**
* Get COI status for an assignment
*/
getCOIStatus: protectedProcedure
.input(z.object({ assignmentId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.conflictOfInterest.findUnique({
where: { assignmentId: input.assignmentId },
})
}),
/**
* List COI declarations for a round (admin only)
*/
listCOIByRound: adminProcedure
.input(
z.object({
roundId: z.string(),
hasConflictOnly: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
return ctx.prisma.conflictOfInterest.findMany({
where: {
roundId: input.roundId,
...(input.hasConflictOnly && { hasConflict: true }),
},
include: {
user: { select: { id: true, name: true, email: true } },
assignment: {
include: {
project: { select: { id: true, title: true } },
},
},
reviewedBy: { select: { id: true, name: true, email: true } },
},
orderBy: { declaredAt: 'desc' },
})
}),
/**
* Review a COI declaration (admin only)
*/
reviewCOI: adminProcedure
.input(
z.object({
id: z.string(),
reviewAction: z.enum(['cleared', 'reassigned', 'noted']),
})
)
.mutation(async ({ ctx, input }) => {
const coi = await ctx.prisma.conflictOfInterest.update({
where: { id: input.id },
data: {
reviewedById: ctx.user.id,
reviewedAt: new Date(),
reviewAction: input.reviewAction,
},
})
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'COI_REVIEWED',
entityType: 'ConflictOfInterest',
entityId: input.id,
detailsJson: {
reviewAction: input.reviewAction,
assignmentId: coi.assignmentId,
userId: coi.userId,
projectId: coi.projectId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return coi
}),
// =========================================================================
// Reminder Triggers
// =========================================================================
/**
* Manually trigger reminder check for a specific round (admin only)
*/
triggerReminders: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const result = await processEvaluationReminders(input.roundId)
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REMINDERS_TRIGGERED',
entityType: 'Round',
entityId: input.roundId,
detailsJson: {
sent: result.sent,
errors: result.errors,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
}),
// =========================================================================
// AI Evaluation Summary Endpoints
// =========================================================================
/**
* Generate an AI-powered evaluation summary for a project (admin only)
*/
generateSummary: adminProcedure
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
return generateSummary({
projectId: input.projectId,
roundId: input.roundId,
userId: ctx.user.id,
prisma: ctx.prisma,
})
}),
/**
* Get an existing evaluation summary for a project (admin only)
*/
getSummary: adminProcedure
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
})
)
.query(async ({ ctx, input }) => {
return ctx.prisma.evaluationSummary.findUnique({
where: {
projectId_roundId: {
projectId: input.projectId,
roundId: input.roundId,
},
},
})
}),
/**
* Generate summaries for all projects in a round with submitted evaluations (admin only)
*/
generateBulkSummaries: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Find all projects in the round with at least 1 submitted evaluation
const projects = await ctx.prisma.project.findMany({
where: {
roundId: input.roundId,
assignments: {
some: {
evaluation: {
status: 'SUBMITTED',
},
},
},
},
select: { id: true },
})
let generated = 0
const errors: Array<{ projectId: string; error: string }> = []
// Generate summaries sequentially to avoid rate limits
for (const project of projects) {
try {
await generateSummary({
projectId: project.id,
roundId: input.roundId,
userId: ctx.user.id,
prisma: ctx.prisma,
})
generated++
} catch (error) {
errors.push({
projectId: project.id,
error: error instanceof Error ? error.message : 'Unknown error',
})
}
}
return {
total: projects.length,
generated,
errors,
}
}),
})

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
*/

View File

@@ -20,10 +20,14 @@ export const fileRouter = router({
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
// Find the file record to get the project
// Find the file record to get the project and round info
const file = await ctx.prisma.projectFile.findFirst({
where: { bucket: input.bucket, objectKey: input.objectKey },
select: { projectId: true },
select: {
projectId: true,
roundId: true,
round: { select: { programId: true, sortOrder: true } },
},
})
if (!file) {
@@ -33,24 +37,55 @@ export const fileRouter = router({
})
}
// Check if user is assigned as jury or mentor for this project
const [juryAssignment, mentorAssignment] = await Promise.all([
// Check if user is assigned as jury, mentor, or team member for this project
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: file.projectId },
select: { id: true },
select: { id: true, roundId: true },
}),
ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: file.projectId },
select: { id: true },
}),
ctx.prisma.project.findFirst({
where: {
id: file.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true },
}),
])
if (!juryAssignment && !mentorAssignment) {
if (!juryAssignment && !mentorAssignment && !teamMembership) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this file',
})
}
// For jury members, verify round-scoped access:
// File must belong to the jury's assigned round or a prior round in the same program
if (juryAssignment && !mentorAssignment && !teamMembership && file.roundId && file.round) {
const assignedRound = await ctx.prisma.round.findUnique({
where: { id: juryAssignment.roundId },
select: { programId: true, sortOrder: true },
})
if (assignedRound) {
const sameProgram = assignedRound.programId === file.round.programId
const priorOrSameRound = file.round.sortOrder <= assignedRound.sortOrder
if (!sameProgram || !priorOrSameRound) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this file',
})
}
}
}
}
const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900) // 15 min
@@ -189,12 +224,15 @@ export const fileRouter = router({
* Checks that the user is authorized to view the project's files
*/
listByProject: protectedProcedure
.input(z.object({ projectId: z.string() }))
.input(z.object({
projectId: z.string(),
roundId: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
const [juryAssignment, mentorAssignment] = await Promise.all([
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId },
select: { id: true },
@@ -203,9 +241,19 @@ export const fileRouter = router({
where: { mentorId: ctx.user.id, projectId: input.projectId },
select: { id: true },
}),
ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true },
}),
])
if (!juryAssignment && !mentorAssignment) {
if (!juryAssignment && !mentorAssignment && !teamMembership) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this project\'s files',
@@ -213,9 +261,127 @@ export const fileRouter = router({
}
}
const where: Record<string, unknown> = { projectId: input.projectId }
if (input.roundId) {
where.roundId = input.roundId
}
return ctx.prisma.projectFile.findMany({
where: { projectId: input.projectId },
where,
include: {
round: { select: { id: true, name: true, sortOrder: true } },
},
orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }],
})
}),
/**
* List files for a project grouped by round
* Returns files for the specified round + all prior rounds in the same program
*/
listByProjectForRound: protectedProcedure
.input(z.object({
projectId: z.string(),
roundId: z.string(),
}))
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId },
select: { id: true, roundId: true },
}),
ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: input.projectId },
select: { id: true },
}),
ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true },
}),
])
if (!juryAssignment && !mentorAssignment && !teamMembership) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this project\'s files',
})
}
}
// Get the target round with its program and sortOrder
const targetRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { programId: true, sortOrder: true },
})
// Get all rounds in the same program with sortOrder <= target
const eligibleRounds = await ctx.prisma.round.findMany({
where: {
programId: targetRound.programId,
sortOrder: { lte: targetRound.sortOrder },
},
select: { id: true, name: true, sortOrder: true },
orderBy: { sortOrder: 'asc' },
})
const eligibleRoundIds = eligibleRounds.map((r) => r.id)
// Get files for these rounds (or files with no roundId)
const files = await ctx.prisma.projectFile.findMany({
where: {
projectId: input.projectId,
OR: [
{ roundId: { in: eligibleRoundIds } },
{ roundId: null },
],
},
include: {
round: { select: { id: true, name: true, sortOrder: true } },
},
orderBy: [{ createdAt: 'asc' }],
})
// Group by round
const grouped: Array<{
roundId: string | null
roundName: string
sortOrder: number
files: typeof files
}> = []
// Add "General" group for files with no round
const generalFiles = files.filter((f) => !f.roundId)
if (generalFiles.length > 0) {
grouped.push({
roundId: null,
roundName: 'General',
sortOrder: -1,
files: generalFiles,
})
}
// Add groups for each round
for (const round of eligibleRounds) {
const roundFiles = files.filter((f) => f.roundId === round.id)
if (roundFiles.length > 0) {
grouped.push({
roundId: round.id,
roundName: round.name,
sortOrder: round.sortOrder,
files: roundFiles,
})
}
}
return grouped
}),
})

View File

@@ -635,6 +635,109 @@ export const mentorRouter = router({
}
}),
/**
* Send a message to the project team (mentor side)
*/
sendMessage: mentorProcedure
.input(
z.object({
projectId: z.string(),
message: z.string().min(1).max(5000),
})
)
.mutation(async ({ ctx, input }) => {
// Verify the mentor is assigned to this project
const assignment = await ctx.prisma.mentorAssignment.findFirst({
where: {
projectId: input.projectId,
mentorId: ctx.user.id,
},
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!assignment && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to mentor this project',
})
}
const mentorMessage = await ctx.prisma.mentorMessage.create({
data: {
projectId: input.projectId,
senderId: ctx.user.id,
message: input.message,
},
include: {
sender: {
select: { id: true, name: true, email: true },
},
},
})
// Notify project team members
await notifyProjectTeam(input.projectId, {
type: 'MENTOR_MESSAGE',
title: 'New Message from Mentor',
message: `${ctx.user.name || 'Your mentor'} sent you a message`,
linkUrl: `/my-submission/${input.projectId}`,
linkLabel: 'View Message',
priority: 'normal',
metadata: {
projectId: input.projectId,
},
})
return mentorMessage
}),
/**
* Get messages for a project (mentor side)
*/
getMessages: mentorProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
// Verify the mentor is assigned to this project
const assignment = await ctx.prisma.mentorAssignment.findFirst({
where: {
projectId: input.projectId,
mentorId: ctx.user.id,
},
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!assignment && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to mentor this project',
})
}
const messages = await ctx.prisma.mentorMessage.findMany({
where: { projectId: input.projectId },
include: {
sender: {
select: { id: true, name: true, email: true, role: true },
},
},
orderBy: { createdAt: 'asc' },
})
// Mark unread messages from the team as read
await ctx.prisma.mentorMessage.updateMany({
where: {
projectId: input.projectId,
senderId: { not: ctx.user.id },
isRead: false,
},
data: { isRead: true },
})
return messages
}),
/**
* List all mentor assignments (admin)
*/

View File

@@ -370,6 +370,17 @@ export const projectRouter = router({
},
})
// Record status change in history
if (status) {
await ctx.prisma.projectStatusHistory.create({
data: {
projectId: id,
status,
changedBy: ctx.user.id,
},
})
}
// Send notifications if status changed
if (status) {
// Get round details for notification
@@ -648,6 +659,17 @@ export const projectRouter = router({
data: { status: input.status },
})
// Record status change in history for each project
if (matchingIds.length > 0) {
await ctx.prisma.projectStatusHistory.createMany({
data: matchingIds.map((projectId) => ({
projectId,
status: input.status,
changedBy: ctx.user.id,
})),
})
}
// Audit log
await logAudit({
prisma: ctx.prisma,

View File

@@ -443,9 +443,25 @@ export const roundRouter = router({
id: z.string(),
label: z.string().min(1),
description: z.string().optional(),
scale: z.number().int().min(1).max(10),
type: z.enum(['numeric', 'text', 'boolean', 'section_header']).default('numeric'),
// Numeric fields
scale: z.number().int().min(1).max(10).optional(),
weight: z.number().optional(),
required: z.boolean(),
required: z.boolean().optional(),
// Text fields
maxLength: z.number().int().min(1).max(10000).optional(),
placeholder: z.string().optional(),
// Boolean fields
trueLabel: z.string().optional(),
falseLabel: z.string().optional(),
// Conditional visibility
condition: z.object({
criterionId: z.string(),
operator: z.enum(['equals', 'greaterThan', 'lessThan']),
value: z.union([z.number(), z.string(), z.boolean()]),
}).optional(),
// Section grouping
sectionId: z.string().optional(),
})
),
})

View File

@@ -0,0 +1,405 @@
/**
* AI-Powered Evaluation Summary Service
*
* Generates AI summaries of jury evaluations for a project in a given round.
* Combines OpenAI analysis with server-side scoring pattern calculations.
*
* GDPR Compliance:
* - All evaluation data is anonymized before AI processing
* - No juror names, emails, or identifiers are sent to OpenAI
* - Only scores, feedback text, and binary decisions are included
*/
import { TRPCError } from '@trpc/server'
import { getOpenAI, getConfiguredModel, buildCompletionParams, AI_MODELS } from '@/lib/openai'
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
import { classifyAIError, createParseError, logAIError } from './ai-errors'
import { sanitizeText } from './anonymization'
import type { PrismaClient, Prisma } from '@prisma/client'
// ─── Types ──────────────────────────────────────────────────────────────────
interface EvaluationForSummary {
id: string
criterionScoresJson: Record<string, number> | null
globalScore: number | null
binaryDecision: boolean | null
feedbackText: string | null
assignment: {
user: {
id: string
name: string | null
email: string
}
}
}
interface AnonymizedEvaluation {
criterionScores: Record<string, number> | null
globalScore: number | null
binaryDecision: boolean | null
feedbackText: string | null
}
interface CriterionDef {
id: string
label: string
}
interface AIResponsePayload {
overallAssessment: string
strengths: string[]
weaknesses: string[]
themes: Array<{
theme: string
sentiment: 'positive' | 'negative' | 'mixed'
frequency: number
}>
recommendation: string
}
interface ScoringPatterns {
averageGlobalScore: number | null
consensus: number
criterionAverages: Record<string, number>
evaluatorCount: number
}
export interface EvaluationSummaryResult {
id: string
projectId: string
roundId: string
summaryJson: AIResponsePayload & { scoringPatterns: ScoringPatterns }
generatedAt: Date
model: string
tokensUsed: number
}
// ─── Anonymization ──────────────────────────────────────────────────────────
/**
* Strip juror names/emails from evaluations, keeping only scores and feedback.
*/
export function anonymizeEvaluations(
evaluations: EvaluationForSummary[]
): AnonymizedEvaluation[] {
return evaluations.map((ev) => ({
criterionScores: ev.criterionScoresJson as Record<string, number> | null,
globalScore: ev.globalScore,
binaryDecision: ev.binaryDecision,
feedbackText: ev.feedbackText ? sanitizeText(ev.feedbackText) : null,
}))
}
// ─── Prompt Building ────────────────────────────────────────────────────────
/**
* Build the OpenAI prompt for evaluation summary generation.
*/
export function buildSummaryPrompt(
anonymizedEvaluations: AnonymizedEvaluation[],
projectTitle: string,
criteriaLabels: string[]
): string {
const sanitizedTitle = sanitizeText(projectTitle)
return `You are analyzing jury evaluations for a project competition.
PROJECT: "${sanitizedTitle}"
EVALUATION CRITERIA: ${criteriaLabels.join(', ')}
EVALUATIONS (${anonymizedEvaluations.length} total):
${JSON.stringify(anonymizedEvaluations, null, 2)}
Analyze these evaluations and return a JSON object with this exact structure:
{
"overallAssessment": "A 2-3 sentence summary of how the project was evaluated overall",
"strengths": ["strength 1", "strength 2", ...],
"weaknesses": ["weakness 1", "weakness 2", ...],
"themes": [
{ "theme": "theme name", "sentiment": "positive" | "negative" | "mixed", "frequency": <number of evaluators mentioning this> }
],
"recommendation": "A brief recommendation based on the evaluation consensus"
}
Guidelines:
- Base your analysis only on the provided evaluation data
- Identify common themes across evaluator feedback
- Note areas of agreement and disagreement
- Keep the assessment objective and balanced
- Do not include any personal identifiers`
}
// ─── Scoring Patterns (Server-Side) ─────────────────────────────────────────
/**
* Compute scoring patterns from evaluations without AI.
*/
export function computeScoringPatterns(
evaluations: EvaluationForSummary[],
criteriaLabels: CriterionDef[]
): ScoringPatterns {
const globalScores = evaluations
.map((e) => e.globalScore)
.filter((s): s is number => s !== null)
// Average global score
const averageGlobalScore =
globalScores.length > 0
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
: null
// Consensus: 1 - normalized standard deviation (1.0 = full consensus)
let consensus = 1
if (globalScores.length > 1 && averageGlobalScore !== null) {
const variance =
globalScores.reduce(
(sum, score) => sum + Math.pow(score - averageGlobalScore, 2),
0
) / globalScores.length
const stdDev = Math.sqrt(variance)
// Normalize by the scoring scale (1-10, so max possible std dev is ~4.5)
consensus = Math.max(0, 1 - stdDev / 4.5)
}
// Criterion averages
const criterionAverages: Record<string, number> = {}
for (const criterion of criteriaLabels) {
const scores: number[] = []
for (const ev of evaluations) {
const criterionScores = ev.criterionScoresJson as Record<string, number> | null
if (criterionScores && criterionScores[criterion.id] !== undefined) {
scores.push(criterionScores[criterion.id])
}
}
if (scores.length > 0) {
criterionAverages[criterion.label] =
scores.reduce((a, b) => a + b, 0) / scores.length
}
}
return {
averageGlobalScore,
consensus: Math.round(consensus * 100) / 100,
criterionAverages,
evaluatorCount: evaluations.length,
}
}
// ─── Main Orchestrator ──────────────────────────────────────────────────────
/**
* Generate an AI-powered evaluation summary for a project in a round.
*/
export async function generateSummary({
projectId,
roundId,
userId,
prisma,
}: {
projectId: string
roundId: string
userId: string
prisma: PrismaClient
}): Promise<EvaluationSummaryResult> {
// 1. Fetch project with evaluations and form criteria
const project = await prisma.project.findUnique({
where: { id: projectId },
select: {
id: true,
title: true,
roundId: true,
},
})
if (!project) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
}
// Fetch submitted evaluations for this project in this round
const evaluations = await prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: {
projectId,
roundId,
},
},
select: {
id: true,
criterionScoresJson: true,
globalScore: true,
binaryDecision: true,
feedbackText: true,
assignment: {
select: {
user: {
select: { id: true, name: true, email: true },
},
},
},
},
})
if (evaluations.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No submitted evaluations found for this project in this round',
})
}
// Get evaluation form criteria for this round
const form = await prisma.evaluationForm.findFirst({
where: { roundId, isActive: true },
select: { criteriaJson: true },
})
const criteria: CriterionDef[] = form?.criteriaJson
? (form.criteriaJson as unknown as CriterionDef[])
: []
const criteriaLabels = criteria.map((c) => c.label)
// 2. Anonymize evaluations
const typedEvaluations = evaluations as unknown as EvaluationForSummary[]
const anonymized = anonymizeEvaluations(typedEvaluations)
// 3. Build prompt and call OpenAI
const openai = await getOpenAI()
if (!openai) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'OpenAI is not configured. Please set up your API key in Settings.',
})
}
const model = await getConfiguredModel(AI_MODELS.QUICK)
const prompt = buildSummaryPrompt(anonymized, project.title, criteriaLabels)
let aiResponse: AIResponsePayload
let tokensUsed = 0
try {
const params = buildCompletionParams(model, {
messages: [
{ role: 'user', content: prompt },
],
jsonMode: true,
temperature: 0.3,
maxTokens: 2000,
})
const response = await openai.chat.completions.create(params)
const usage = extractTokenUsage(response)
tokensUsed = usage.totalTokens
const content = response.choices[0]?.message?.content
if (!content) {
throw new Error('Empty response from AI')
}
aiResponse = JSON.parse(content) as AIResponsePayload
} catch (error) {
if (error instanceof SyntaxError) {
const parseError = createParseError(error.message)
logAIError('EvaluationSummary', 'generateSummary', parseError)
await logAIUsage({
userId,
action: 'EVALUATION_SUMMARY',
entityType: 'Project',
entityId: projectId,
model,
promptTokens: 0,
completionTokens: 0,
totalTokens: tokensUsed,
itemsProcessed: 0,
status: 'ERROR',
errorMessage: parseError.message,
})
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to parse AI response. Please try again.',
})
}
const classified = classifyAIError(error)
logAIError('EvaluationSummary', 'generateSummary', classified)
await logAIUsage({
userId,
action: 'EVALUATION_SUMMARY',
entityType: 'Project',
entityId: projectId,
model,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
itemsProcessed: 0,
status: 'ERROR',
errorMessage: classified.message,
})
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: classified.message,
})
}
// 4. Compute scoring patterns (server-side, no AI)
const scoringPatterns = computeScoringPatterns(typedEvaluations, criteria)
// 5. Merge and upsert
const summaryJson = {
...aiResponse,
scoringPatterns,
}
const summaryJsonValue = summaryJson as unknown as Prisma.InputJsonValue
const summary = await prisma.evaluationSummary.upsert({
where: {
projectId_roundId: { projectId, roundId },
},
create: {
projectId,
roundId,
summaryJson: summaryJsonValue,
generatedById: userId,
model,
tokensUsed,
},
update: {
summaryJson: summaryJsonValue,
generatedAt: new Date(),
generatedById: userId,
model,
tokensUsed,
},
})
// 6. Log AI usage
await logAIUsage({
userId,
action: 'EVALUATION_SUMMARY',
entityType: 'Project',
entityId: projectId,
model,
promptTokens: 0, // Detailed breakdown not always available
completionTokens: 0,
totalTokens: tokensUsed,
itemsProcessed: evaluations.length,
status: 'SUCCESS',
})
return {
id: summary.id,
projectId: summary.projectId,
roundId: summary.roundId,
summaryJson: summaryJson as AIResponsePayload & { scoringPatterns: ScoringPatterns },
generatedAt: summary.generatedAt,
model: summary.model,
tokensUsed: summary.tokensUsed,
}
}

View File

@@ -0,0 +1,178 @@
import { prisma } from '@/lib/prisma'
import { sendStyledNotificationEmail } from '@/lib/email'
const REMINDER_TYPES = [
{ type: '3_DAYS', thresholdMs: 3 * 24 * 60 * 60 * 1000 },
{ type: '24H', thresholdMs: 24 * 60 * 60 * 1000 },
{ type: '1H', thresholdMs: 60 * 60 * 1000 },
] as const
type ReminderType = (typeof REMINDER_TYPES)[number]['type']
interface ReminderResult {
sent: number
errors: number
}
/**
* Find active rounds with approaching voting deadlines and send reminders
* to jurors who have incomplete assignments.
*/
export async function processEvaluationReminders(roundId?: string): Promise<ReminderResult> {
const now = new Date()
let totalSent = 0
let totalErrors = 0
// Find active rounds with voting end dates in the future
const rounds = await prisma.round.findMany({
where: {
status: 'ACTIVE',
votingEndAt: { gt: now },
votingStartAt: { lte: now },
...(roundId && { id: roundId }),
},
select: {
id: true,
name: true,
votingEndAt: true,
program: { select: { name: true } },
},
})
for (const round of rounds) {
if (!round.votingEndAt) continue
const msUntilDeadline = round.votingEndAt.getTime() - now.getTime()
// Determine which reminder types should fire for this round
const applicableTypes = REMINDER_TYPES.filter(
({ thresholdMs }) => msUntilDeadline <= thresholdMs
)
if (applicableTypes.length === 0) continue
for (const { type } of applicableTypes) {
const result = await sendRemindersForRound(round, type, now)
totalSent += result.sent
totalErrors += result.errors
}
}
return { sent: totalSent, errors: totalErrors }
}
async function sendRemindersForRound(
round: {
id: string
name: string
votingEndAt: Date | null
program: { name: string }
},
type: ReminderType,
now: Date
): Promise<ReminderResult> {
let sent = 0
let errors = 0
if (!round.votingEndAt) return { sent, errors }
// Find jurors with incomplete assignments for this round
const incompleteAssignments = await prisma.assignment.findMany({
where: {
roundId: round.id,
isCompleted: false,
},
select: {
userId: true,
},
})
// Get unique user IDs with incomplete work
const userIds = [...new Set(incompleteAssignments.map((a) => a.userId))]
if (userIds.length === 0) return { sent, errors }
// Check which users already received this reminder type for this round
const existingReminders = await prisma.reminderLog.findMany({
where: {
roundId: round.id,
type,
userId: { in: userIds },
},
select: { userId: true },
})
const alreadySent = new Set(existingReminders.map((r) => r.userId))
const usersToNotify = userIds.filter((id) => !alreadySent.has(id))
if (usersToNotify.length === 0) return { sent, errors }
// Get user details and their pending counts
const users = await prisma.user.findMany({
where: { id: { in: usersToNotify } },
select: { id: true, name: true, email: true },
})
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
const deadlineStr = round.votingEndAt.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
})
// Map to get pending count per user
const pendingCounts = new Map<string, number>()
for (const a of incompleteAssignments) {
pendingCounts.set(a.userId, (pendingCounts.get(a.userId) || 0) + 1)
}
// Select email template type based on reminder type
const emailTemplateType = type === '1H' ? 'REMINDER_1H' : 'REMINDER_24H'
for (const user of users) {
const pendingCount = pendingCounts.get(user.id) || 0
if (pendingCount === 0) continue
try {
await sendStyledNotificationEmail(
user.email,
user.name || '',
emailTemplateType,
{
name: user.name || undefined,
title: `Evaluation Reminder - ${round.name}`,
message: `You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''} for ${round.name}.`,
linkUrl: `${baseUrl}/jury/assignments?round=${round.id}`,
metadata: {
pendingCount,
roundName: round.name,
deadline: deadlineStr,
},
}
)
// Log the sent reminder
await prisma.reminderLog.create({
data: {
roundId: round.id,
userId: user.id,
type,
},
})
sent++
} catch (error) {
console.error(
`Failed to send ${type} reminder to ${user.email} for round ${round.name}:`,
error
)
errors++
}
}
return { sent, errors }
}

View File

@@ -6,13 +6,18 @@
* - Bio/description match (text similarity)
* - Workload balance
* - Country match (mentors only)
* - Geographic diversity penalty (prevents clustering by country)
* - Previous round familiarity bonus (continuity across rounds)
* - COI penalty (conflict of interest hard-block)
*
* Score Breakdown (100 points max):
* Score Breakdown:
* - Tag overlap: 0-40 points (weighted by confidence)
* - Bio match: 0-15 points (if bio exists)
* - Workload balance: 0-25 points
* - Country match: 0-15 points (mentors only)
* - Reserved: 0-5 points (future AI boost)
* - Geo diversity: -15 per excess same-country assignment (threshold: 2)
* - Previous round familiarity: +10 if reviewed in earlier round
* - COI: juror skipped entirely if conflict declared
*/
import { prisma } from '@/lib/prisma'
@@ -24,6 +29,9 @@ export interface ScoreBreakdown {
bioMatch: number
workloadBalance: number
countryMatch: number
geoDiversityPenalty: number
previousRoundFamiliarity: number
coiPenalty: number
}
export interface AssignmentScore {
@@ -52,6 +60,12 @@ const MAX_WORKLOAD_SCORE = 25
const MAX_COUNTRY_SCORE = 15
const POINTS_PER_TAG_MATCH = 8
// New scoring factors
const GEO_DIVERSITY_THRESHOLD = 2
const GEO_DIVERSITY_PENALTY_PER_EXCESS = -15
const PREVIOUS_ROUND_FAMILIARITY_BONUS = 10
// COI jurors are skipped entirely rather than penalized (effectively -Infinity)
// Common words to exclude from bio matching
const STOP_WORDS = new Set([
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with',
@@ -284,10 +298,68 @@ export async function getSmartSuggestions(options: {
existingAssignments.map((a) => `${a.userId}:${a.projectId}`)
)
// Calculate target assignments per user
// ── Batch-query data for new scoring factors ──────────────────────────────
// 1. Geographic diversity: per-juror country distribution for existing assignments
const assignmentsWithCountry = await prisma.assignment.findMany({
where: { roundId },
select: {
userId: true,
project: { select: { country: true } },
},
})
// Build map: userId -> { country -> count }
const userCountryDistribution = new Map<string, Map<string, number>>()
for (const a of assignmentsWithCountry) {
const country = a.project.country?.toLowerCase().trim()
if (!country) continue
let countryMap = userCountryDistribution.get(a.userId)
if (!countryMap) {
countryMap = new Map()
userCountryDistribution.set(a.userId, countryMap)
}
countryMap.set(country, (countryMap.get(country) || 0) + 1)
}
// 2. Previous round familiarity: find assignments in earlier rounds of the same program
const currentRound = await prisma.round.findUnique({
where: { id: roundId },
select: { programId: true, sortOrder: true },
})
const previousRoundAssignmentPairs = new Set<string>()
if (currentRound) {
const previousAssignments = await prisma.assignment.findMany({
where: {
round: {
programId: currentRound.programId,
sortOrder: { lt: currentRound.sortOrder },
},
},
select: { userId: true, projectId: true },
})
for (const pa of previousAssignments) {
previousRoundAssignmentPairs.add(`${pa.userId}:${pa.projectId}`)
}
}
// 3. COI declarations: all active conflicts for this round
const coiRecords = await prisma.conflictOfInterest.findMany({
where: {
roundId,
hasConflict: true,
},
select: { userId: true, projectId: true },
})
const coiPairs = new Set(
coiRecords.map((c) => `${c.userId}:${c.projectId}`)
)
// ── Calculate target assignments per user ─────────────────────────────────
const targetPerUser = Math.ceil(projects.length / users.length)
// Calculate scores for all user-project pairs
// ── Calculate scores for all user-project pairs ───────────────────────────
const suggestions: AssignmentScore[] = []
for (const user of users) {
@@ -304,6 +376,11 @@ export async function getSmartSuggestions(options: {
continue
}
// COI check - skip juror entirely for this project if COI declared
if (coiPairs.has(pairKey)) {
continue
}
// Get project tags data
const projectTags: ProjectTagData[] = project.projectTags.map((pt) => ({
tagId: pt.tagId,
@@ -311,13 +388,12 @@ export async function getSmartSuggestions(options: {
confidence: pt.confidence,
}))
// Calculate scores
// Calculate existing scores
const { score: tagScore, matchingTags } = calculateTagOverlapScore(
user.expertiseTags,
projectTags
)
// Bio match (only if user has a bio)
const { score: bioScore, matchingKeywords } = calculateBioMatchScore(
user.bio,
project.description
@@ -329,13 +405,39 @@ export async function getSmartSuggestions(options: {
user.maxAssignments
)
// Country match only for mentors
const countryScore =
type === 'mentor'
? calculateCountryMatchScore(user.country, project.country)
: 0
const totalScore = tagScore + bioScore + workloadScore + countryScore
// ── New scoring factors ─────────────────────────────────────────────
// Geographic diversity penalty
let geoDiversityPenalty = 0
const projectCountry = project.country?.toLowerCase().trim()
if (projectCountry) {
const countryMap = userCountryDistribution.get(user.id)
const sameCountryCount = countryMap?.get(projectCountry) || 0
if (sameCountryCount >= GEO_DIVERSITY_THRESHOLD) {
geoDiversityPenalty =
GEO_DIVERSITY_PENALTY_PER_EXCESS *
(sameCountryCount - GEO_DIVERSITY_THRESHOLD + 1)
}
}
// Previous round familiarity bonus
let previousRoundFamiliarity = 0
if (previousRoundAssignmentPairs.has(pairKey)) {
previousRoundFamiliarity = PREVIOUS_ROUND_FAMILIARITY_BONUS
}
const totalScore =
tagScore +
bioScore +
workloadScore +
countryScore +
geoDiversityPenalty +
previousRoundFamiliarity
// Build reasoning
const reasoning: string[] = []
@@ -353,6 +455,12 @@ export async function getSmartSuggestions(options: {
if (countryScore > 0) {
reasoning.push('Same country')
}
if (geoDiversityPenalty < 0) {
reasoning.push(`Geo diversity penalty (${geoDiversityPenalty})`)
}
if (previousRoundFamiliarity > 0) {
reasoning.push('Reviewed in previous round (+10)')
}
suggestions.push({
userId: user.id,
@@ -366,6 +474,9 @@ export async function getSmartSuggestions(options: {
bioMatch: bioScore,
workloadBalance: workloadScore,
countryMatch: countryScore,
geoDiversityPenalty,
previousRoundFamiliarity,
coiPenalty: 0, // COI jurors are skipped entirely
},
reasoning,
matchingTags,
@@ -488,6 +599,9 @@ export async function getMentorSuggestionsForProject(
bioMatch: bioScore,
workloadBalance: workloadScore,
countryMatch: countryScore,
geoDiversityPenalty: 0,
previousRoundFamiliarity: 0,
coiPenalty: 0,
},
reasoning,
matchingTags,

View File

@@ -17,6 +17,7 @@ export type AIAction =
| 'AWARD_ELIGIBILITY'
| 'MENTOR_MATCHING'
| 'PROJECT_TAGGING'
| 'EVALUATION_SUMMARY'
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'