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:
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
405
src/server/services/ai-evaluation-summary.ts
Normal file
405
src/server/services/ai-evaluation-summary.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
178
src/server/services/evaluation-reminders.ts
Normal file
178
src/server/services/evaluation-reminders.ts
Normal 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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -17,6 +17,7 @@ export type AIAction =
|
||||
| 'AWARD_ELIGIBILITY'
|
||||
| 'MENTOR_MATCHING'
|
||||
| 'PROJECT_TAGGING'
|
||||
| 'EVALUATION_SUMMARY'
|
||||
|
||||
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user