2026-01-30 13:41:32 +01:00
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { TRPCError } from '@trpc/server'
|
|
|
|
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
2026-02-02 13:19:28 +01:00
|
|
|
import { getUserAvatarUrl } from '../utils/avatar-url'
|
2026-01-30 13:41:32 +01:00
|
|
|
import {
|
|
|
|
|
generateAIAssignments,
|
|
|
|
|
generateFallbackAssignments,
|
2026-02-04 17:40:26 +01:00
|
|
|
type AssignmentProgressCallback,
|
2026-01-30 13:41:32 +01:00
|
|
|
} from '../services/ai-assignment'
|
|
|
|
|
import { isOpenAIConfigured } from '@/lib/openai'
|
2026-02-04 17:40:26 +01:00
|
|
|
import { prisma } from '@/lib/prisma'
|
2026-02-04 00:10:51 +01:00
|
|
|
import {
|
|
|
|
|
createNotification,
|
|
|
|
|
createBulkNotifications,
|
2026-02-04 17:40:26 +01:00
|
|
|
notifyAdmins,
|
2026-02-04 00:10:51 +01:00
|
|
|
NotificationTypes,
|
|
|
|
|
} from '../services/in-app-notification'
|
2026-02-05 21:09:06 +01:00
|
|
|
import { logAudit } from '@/server/utils/audit'
|
2026-01-30 13:41:32 +01:00
|
|
|
|
2026-02-04 17:40:26 +01:00
|
|
|
// Background job execution function
|
|
|
|
|
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
|
|
|
|
|
try {
|
|
|
|
|
// Update job to running
|
|
|
|
|
await prisma.assignmentJob.update({
|
|
|
|
|
where: { id: jobId },
|
|
|
|
|
data: { status: 'RUNNING', startedAt: new Date() },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get round constraints
|
|
|
|
|
const round = await prisma.round.findUniqueOrThrow({
|
|
|
|
|
where: { id: roundId },
|
|
|
|
|
select: {
|
|
|
|
|
name: true,
|
|
|
|
|
requiredReviews: true,
|
|
|
|
|
minAssignmentsPerJuror: true,
|
|
|
|
|
maxAssignmentsPerJuror: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get all active jury members with their expertise and current load
|
|
|
|
|
const jurors = await prisma.user.findMany({
|
|
|
|
|
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
email: true,
|
|
|
|
|
expertiseTags: true,
|
|
|
|
|
maxAssignments: true,
|
|
|
|
|
_count: {
|
|
|
|
|
select: {
|
|
|
|
|
assignments: { where: { roundId } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get all projects in the round
|
|
|
|
|
const projects = await prisma.project.findMany({
|
|
|
|
|
where: { roundId },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
title: true,
|
|
|
|
|
description: true,
|
|
|
|
|
tags: true,
|
|
|
|
|
teamName: true,
|
|
|
|
|
_count: { select: { assignments: true } },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get existing assignments
|
|
|
|
|
const existingAssignments = await prisma.assignment.findMany({
|
|
|
|
|
where: { roundId },
|
|
|
|
|
select: { userId: true, projectId: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Calculate batch info
|
|
|
|
|
const BATCH_SIZE = 15
|
|
|
|
|
const totalBatches = Math.ceil(projects.length / BATCH_SIZE)
|
|
|
|
|
|
|
|
|
|
await prisma.assignmentJob.update({
|
|
|
|
|
where: { id: jobId },
|
|
|
|
|
data: { totalProjects: projects.length, totalBatches },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Progress callback
|
|
|
|
|
const onProgress: AssignmentProgressCallback = async (progress) => {
|
|
|
|
|
await prisma.assignmentJob.update({
|
|
|
|
|
where: { id: jobId },
|
|
|
|
|
data: {
|
|
|
|
|
currentBatch: progress.currentBatch,
|
|
|
|
|
processedCount: progress.processedCount,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const constraints = {
|
|
|
|
|
requiredReviewsPerProject: round.requiredReviews,
|
|
|
|
|
minAssignmentsPerJuror: round.minAssignmentsPerJuror,
|
|
|
|
|
maxAssignmentsPerJuror: round.maxAssignmentsPerJuror,
|
|
|
|
|
existingAssignments: existingAssignments.map((a) => ({
|
|
|
|
|
jurorId: a.userId,
|
|
|
|
|
projectId: a.projectId,
|
|
|
|
|
})),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Execute AI assignment with progress callback
|
|
|
|
|
const result = await generateAIAssignments(
|
|
|
|
|
jurors,
|
|
|
|
|
projects,
|
|
|
|
|
constraints,
|
|
|
|
|
userId,
|
|
|
|
|
roundId,
|
|
|
|
|
onProgress
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-05 14:38:43 +01:00
|
|
|
// Enrich suggestions with names for storage
|
|
|
|
|
const enrichedSuggestions = result.suggestions.map((s) => {
|
|
|
|
|
const juror = jurors.find((j) => j.id === s.jurorId)
|
|
|
|
|
const project = projects.find((p) => p.id === s.projectId)
|
|
|
|
|
return {
|
|
|
|
|
...s,
|
|
|
|
|
jurorName: juror?.name || juror?.email || 'Unknown',
|
|
|
|
|
projectTitle: project?.title || 'Unknown',
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Mark job as completed and store suggestions
|
2026-02-04 17:40:26 +01:00
|
|
|
await prisma.assignmentJob.update({
|
|
|
|
|
where: { id: jobId },
|
|
|
|
|
data: {
|
|
|
|
|
status: 'COMPLETED',
|
|
|
|
|
completedAt: new Date(),
|
|
|
|
|
processedCount: projects.length,
|
|
|
|
|
suggestionsCount: result.suggestions.length,
|
2026-02-05 14:38:43 +01:00
|
|
|
suggestionsJson: enrichedSuggestions,
|
2026-02-04 17:40:26 +01:00
|
|
|
fallbackUsed: result.fallbackUsed ?? false,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Notify admins that AI assignment is complete
|
|
|
|
|
await notifyAdmins({
|
|
|
|
|
type: NotificationTypes.AI_SUGGESTIONS_READY,
|
|
|
|
|
title: 'AI Assignment Suggestions Ready',
|
|
|
|
|
message: `AI generated ${result.suggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
|
|
|
|
|
linkUrl: `/admin/rounds/${roundId}/assignments`,
|
|
|
|
|
linkLabel: 'View Suggestions',
|
|
|
|
|
priority: 'high',
|
|
|
|
|
metadata: {
|
|
|
|
|
roundId,
|
|
|
|
|
jobId,
|
|
|
|
|
projectCount: projects.length,
|
|
|
|
|
suggestionsCount: result.suggestions.length,
|
|
|
|
|
fallbackUsed: result.fallbackUsed,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[AI Assignment Job] Error:', error)
|
|
|
|
|
|
|
|
|
|
// Mark job as failed
|
|
|
|
|
await prisma.assignmentJob.update({
|
|
|
|
|
where: { id: jobId },
|
|
|
|
|
data: {
|
|
|
|
|
status: 'FAILED',
|
|
|
|
|
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
|
|
|
|
completedAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
export const assignmentRouter = router({
|
|
|
|
|
/**
|
|
|
|
|
* List assignments for a round (admin only)
|
|
|
|
|
*/
|
|
|
|
|
listByRound: adminProcedure
|
|
|
|
|
.input(z.object({ roundId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
return ctx.prisma.assignment.findMany({
|
|
|
|
|
where: { roundId: input.roundId },
|
|
|
|
|
include: {
|
|
|
|
|
user: { select: { id: true, name: true, email: true, expertiseTags: true } },
|
|
|
|
|
project: { select: { id: true, title: true, tags: true } },
|
|
|
|
|
evaluation: { select: { status: true, submittedAt: true } },
|
|
|
|
|
},
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* List assignments for a project (admin only)
|
|
|
|
|
*/
|
|
|
|
|
listByProject: adminProcedure
|
|
|
|
|
.input(z.object({ projectId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
2026-02-02 13:19:28 +01:00
|
|
|
const assignments = await ctx.prisma.assignment.findMany({
|
2026-01-30 13:41:32 +01:00
|
|
|
where: { projectId: input.projectId },
|
|
|
|
|
include: {
|
2026-02-02 13:19:28 +01:00
|
|
|
user: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true } },
|
2026-01-30 13:41:32 +01:00
|
|
|
evaluation: { select: { status: true, submittedAt: true, globalScore: true, binaryDecision: true } },
|
|
|
|
|
},
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
})
|
2026-02-02 13:19:28 +01:00
|
|
|
|
|
|
|
|
// Attach avatar URLs
|
|
|
|
|
return Promise.all(
|
|
|
|
|
assignments.map(async (a) => ({
|
|
|
|
|
...a,
|
|
|
|
|
user: {
|
|
|
|
|
...a.user,
|
|
|
|
|
avatarUrl: await getUserAvatarUrl(a.user.profileImageKey, a.user.profileImageProvider),
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
)
|
2026-01-30 13:41:32 +01:00
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get my assignments (for jury members)
|
|
|
|
|
*/
|
|
|
|
|
myAssignments: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
roundId: z.string().optional(),
|
|
|
|
|
status: z.enum(['all', 'pending', 'completed']).default('all'),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const where: Record<string, unknown> = {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
round: { status: 'ACTIVE' },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (input.roundId) {
|
|
|
|
|
where.roundId = input.roundId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (input.status === 'pending') {
|
|
|
|
|
where.isCompleted = false
|
|
|
|
|
} else if (input.status === 'completed') {
|
|
|
|
|
where.isCompleted = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ctx.prisma.assignment.findMany({
|
|
|
|
|
where,
|
|
|
|
|
include: {
|
|
|
|
|
project: {
|
|
|
|
|
include: { files: true },
|
|
|
|
|
},
|
|
|
|
|
round: true,
|
|
|
|
|
evaluation: true,
|
|
|
|
|
},
|
|
|
|
|
orderBy: [{ isCompleted: 'asc' }, { createdAt: 'asc' }],
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get assignment by ID
|
|
|
|
|
*/
|
|
|
|
|
get: protectedProcedure
|
|
|
|
|
.input(z.object({ id: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.id },
|
|
|
|
|
include: {
|
|
|
|
|
user: { select: { id: true, name: true, email: true } },
|
|
|
|
|
project: { include: { files: true } },
|
|
|
|
|
round: { include: { evaluationForms: { where: { isActive: true } } } },
|
|
|
|
|
evaluation: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Verify access
|
|
|
|
|
if (
|
|
|
|
|
ctx.user.role === 'JURY_MEMBER' &&
|
|
|
|
|
assignment.userId !== ctx.user.id
|
|
|
|
|
) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'You do not have access to this assignment',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return assignment
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a single assignment (admin only)
|
|
|
|
|
*/
|
|
|
|
|
create: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
userId: z.string(),
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
roundId: z.string(),
|
|
|
|
|
isRequired: z.boolean().default(true),
|
2026-02-04 16:01:18 +01:00
|
|
|
forceOverride: z.boolean().default(false), // Allow manual override of limits
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Check if assignment already exists
|
|
|
|
|
const existing = await ctx.prisma.assignment.findUnique({
|
|
|
|
|
where: {
|
|
|
|
|
userId_projectId_roundId: {
|
|
|
|
|
userId: input.userId,
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
roundId: input.roundId,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'CONFLICT',
|
|
|
|
|
message: 'This assignment already exists',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 16:01:18 +01:00
|
|
|
// Get round constraints and user limit
|
|
|
|
|
const [round, user] = await Promise.all([
|
|
|
|
|
ctx.prisma.round.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.roundId },
|
|
|
|
|
select: { maxAssignmentsPerJuror: true },
|
|
|
|
|
}),
|
|
|
|
|
ctx.prisma.user.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.userId },
|
|
|
|
|
select: { maxAssignments: true, name: true },
|
|
|
|
|
}),
|
|
|
|
|
])
|
2026-01-30 13:41:32 +01:00
|
|
|
|
2026-02-04 16:01:18 +01:00
|
|
|
// Calculate effective max: user override takes precedence if set
|
|
|
|
|
const effectiveMax = user.maxAssignments ?? round.maxAssignmentsPerJuror
|
2026-01-30 13:41:32 +01:00
|
|
|
|
2026-02-04 16:01:18 +01:00
|
|
|
const currentCount = await ctx.prisma.assignment.count({
|
|
|
|
|
where: { userId: input.userId, roundId: input.roundId },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Check if at or over limit
|
|
|
|
|
if (currentCount >= effectiveMax) {
|
|
|
|
|
if (!input.forceOverride) {
|
2026-01-30 13:41:32 +01:00
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
2026-02-04 16:01:18 +01:00
|
|
|
message: `${user.name || 'Judge'} has reached their maximum limit of ${effectiveMax} projects. Use manual override to proceed.`,
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
}
|
2026-02-04 16:01:18 +01:00
|
|
|
// Log the override in audit
|
|
|
|
|
console.log(`[Assignment] Manual override: Assigning ${user.name} beyond limit (${currentCount}/${effectiveMax})`)
|
2026-01-30 13:41:32 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-04 16:01:18 +01:00
|
|
|
const { forceOverride: _override, ...assignmentData } = input
|
2026-01-30 13:41:32 +01:00
|
|
|
const assignment = await ctx.prisma.assignment.create({
|
|
|
|
|
data: {
|
2026-02-04 16:01:18 +01:00
|
|
|
...assignmentData,
|
2026-01-30 13:41:32 +01:00
|
|
|
method: 'MANUAL',
|
|
|
|
|
createdBy: ctx.user.id,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
2026-02-05 21:09:06 +01:00
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'CREATE',
|
|
|
|
|
entityType: 'Assignment',
|
|
|
|
|
entityId: assignment.id,
|
|
|
|
|
detailsJson: input,
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
2026-02-04 00:10:51 +01:00
|
|
|
// Send notification to the assigned jury member
|
2026-02-04 16:01:18 +01:00
|
|
|
const [project, roundInfo] = await Promise.all([
|
2026-02-04 00:10:51 +01:00
|
|
|
ctx.prisma.project.findUnique({
|
|
|
|
|
where: { id: input.projectId },
|
|
|
|
|
select: { title: true },
|
|
|
|
|
}),
|
|
|
|
|
ctx.prisma.round.findUnique({
|
|
|
|
|
where: { id: input.roundId },
|
|
|
|
|
select: { name: true, votingEndAt: true },
|
|
|
|
|
}),
|
|
|
|
|
])
|
|
|
|
|
|
2026-02-04 16:01:18 +01:00
|
|
|
if (project && roundInfo) {
|
|
|
|
|
const deadline = roundInfo.votingEndAt
|
|
|
|
|
? new Date(roundInfo.votingEndAt).toLocaleDateString('en-US', {
|
2026-02-04 00:10:51 +01:00
|
|
|
weekday: 'long',
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
month: 'long',
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
})
|
|
|
|
|
: undefined
|
|
|
|
|
|
|
|
|
|
await createNotification({
|
|
|
|
|
userId: input.userId,
|
|
|
|
|
type: NotificationTypes.ASSIGNED_TO_PROJECT,
|
|
|
|
|
title: 'New Project Assignment',
|
2026-02-04 16:01:18 +01:00
|
|
|
message: `You have been assigned to evaluate "${project.title}" for ${roundInfo.name}.`,
|
2026-02-04 00:10:51 +01:00
|
|
|
linkUrl: `/jury/assignments`,
|
|
|
|
|
linkLabel: 'View Assignment',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectName: project.title,
|
2026-02-04 16:01:18 +01:00
|
|
|
roundName: roundInfo.name,
|
2026-02-04 00:10:51 +01:00
|
|
|
deadline,
|
|
|
|
|
assignmentId: assignment.id,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
return assignment
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Bulk create assignments (admin only)
|
|
|
|
|
*/
|
|
|
|
|
bulkCreate: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
assignments: z.array(
|
|
|
|
|
z.object({
|
|
|
|
|
userId: z.string(),
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
roundId: z.string(),
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const result = await ctx.prisma.assignment.createMany({
|
|
|
|
|
data: input.assignments.map((a) => ({
|
|
|
|
|
...a,
|
|
|
|
|
method: 'BULK',
|
|
|
|
|
createdBy: ctx.user.id,
|
|
|
|
|
})),
|
|
|
|
|
skipDuplicates: true,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
2026-02-05 21:09:06 +01:00
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'BULK_CREATE',
|
|
|
|
|
entityType: 'Assignment',
|
|
|
|
|
detailsJson: { count: result.count },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
2026-02-04 00:10:51 +01:00
|
|
|
// Send notifications to assigned jury members (grouped by user)
|
|
|
|
|
if (result.count > 0 && input.assignments.length > 0) {
|
|
|
|
|
// Group assignments by user to get counts
|
|
|
|
|
const userAssignmentCounts = input.assignments.reduce(
|
|
|
|
|
(acc, a) => {
|
|
|
|
|
acc[a.userId] = (acc[a.userId] || 0) + 1
|
|
|
|
|
return acc
|
|
|
|
|
},
|
|
|
|
|
{} as Record<string, number>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Get round info for deadline
|
|
|
|
|
const roundId = input.assignments[0].roundId
|
|
|
|
|
const round = await ctx.prisma.round.findUnique({
|
|
|
|
|
where: { id: roundId },
|
|
|
|
|
select: { name: true, votingEndAt: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const deadline = round?.votingEndAt
|
|
|
|
|
? new Date(round.votingEndAt).toLocaleDateString('en-US', {
|
|
|
|
|
weekday: 'long',
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
month: 'long',
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
})
|
|
|
|
|
: undefined
|
|
|
|
|
|
2026-02-05 20:31:08 +01:00
|
|
|
// Group users by project count so we can send bulk notifications per group
|
|
|
|
|
const usersByProjectCount = new Map<number, string[]>()
|
2026-02-04 00:10:51 +01:00
|
|
|
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
|
2026-02-05 20:31:08 +01:00
|
|
|
const existing = usersByProjectCount.get(projectCount) || []
|
|
|
|
|
existing.push(userId)
|
|
|
|
|
usersByProjectCount.set(projectCount, existing)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Send bulk notifications for each project count group
|
|
|
|
|
for (const [projectCount, userIds] of usersByProjectCount) {
|
|
|
|
|
if (userIds.length === 0) continue
|
|
|
|
|
await createBulkNotifications({
|
|
|
|
|
userIds,
|
2026-02-04 00:10:51 +01:00
|
|
|
type: NotificationTypes.BATCH_ASSIGNED,
|
|
|
|
|
title: `${projectCount} Projects Assigned`,
|
|
|
|
|
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
|
|
|
|
|
linkUrl: `/jury/assignments`,
|
|
|
|
|
linkLabel: 'View Assignments',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectCount,
|
|
|
|
|
roundName: round?.name,
|
|
|
|
|
deadline,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 21:09:06 +01:00
|
|
|
return {
|
|
|
|
|
created: result.count,
|
|
|
|
|
requested: input.assignments.length,
|
|
|
|
|
skipped: input.assignments.length - result.count,
|
|
|
|
|
}
|
2026-01-30 13:41:32 +01:00
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete an assignment (admin only)
|
|
|
|
|
*/
|
|
|
|
|
delete: adminProcedure
|
|
|
|
|
.input(z.object({ id: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const assignment = await ctx.prisma.assignment.delete({
|
|
|
|
|
where: { id: input.id },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
2026-02-05 21:09:06 +01:00
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'DELETE',
|
|
|
|
|
entityType: 'Assignment',
|
|
|
|
|
entityId: input.id,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
userId: assignment.userId,
|
|
|
|
|
projectId: assignment.projectId,
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
2026-02-05 21:09:06 +01:00
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return assignment
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get assignment statistics for a round
|
|
|
|
|
*/
|
|
|
|
|
getStats: adminProcedure
|
|
|
|
|
.input(z.object({ roundId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const [
|
|
|
|
|
totalAssignments,
|
|
|
|
|
completedAssignments,
|
|
|
|
|
assignmentsByUser,
|
|
|
|
|
projectCoverage,
|
2026-02-05 21:09:06 +01:00
|
|
|
round,
|
2026-01-30 13:41:32 +01:00
|
|
|
] = await Promise.all([
|
|
|
|
|
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
|
|
|
|
ctx.prisma.assignment.count({
|
|
|
|
|
where: { roundId: input.roundId, isCompleted: true },
|
|
|
|
|
}),
|
|
|
|
|
ctx.prisma.assignment.groupBy({
|
|
|
|
|
by: ['userId'],
|
|
|
|
|
where: { roundId: input.roundId },
|
|
|
|
|
_count: true,
|
|
|
|
|
}),
|
2026-02-04 14:15:06 +01:00
|
|
|
ctx.prisma.project.findMany({
|
2026-01-30 13:41:32 +01:00
|
|
|
where: { roundId: input.roundId },
|
2026-02-04 14:15:06 +01:00
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
title: true,
|
|
|
|
|
_count: { select: { assignments: true } },
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
}),
|
2026-02-05 21:09:06 +01:00
|
|
|
ctx.prisma.round.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.roundId },
|
|
|
|
|
select: { requiredReviews: true },
|
|
|
|
|
}),
|
2026-01-30 13:41:32 +01:00
|
|
|
])
|
|
|
|
|
|
|
|
|
|
const projectsWithFullCoverage = projectCoverage.filter(
|
2026-02-04 14:15:06 +01:00
|
|
|
(p) => p._count.assignments >= round.requiredReviews
|
2026-01-30 13:41:32 +01:00
|
|
|
).length
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
totalAssignments,
|
|
|
|
|
completedAssignments,
|
|
|
|
|
completionPercentage:
|
|
|
|
|
totalAssignments > 0
|
|
|
|
|
? Math.round((completedAssignments / totalAssignments) * 100)
|
|
|
|
|
: 0,
|
|
|
|
|
juryMembersAssigned: assignmentsByUser.length,
|
|
|
|
|
projectsWithFullCoverage,
|
|
|
|
|
totalProjects: projectCoverage.length,
|
|
|
|
|
coveragePercentage:
|
|
|
|
|
projectCoverage.length > 0
|
|
|
|
|
? Math.round(
|
|
|
|
|
(projectsWithFullCoverage / projectCoverage.length) * 100
|
|
|
|
|
)
|
|
|
|
|
: 0,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get smart assignment suggestions using algorithm
|
|
|
|
|
*/
|
|
|
|
|
getSuggestions: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
roundId: z.string(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
2026-02-04 16:01:18 +01:00
|
|
|
// Get round constraints
|
|
|
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.roundId },
|
|
|
|
|
select: {
|
|
|
|
|
requiredReviews: true,
|
|
|
|
|
minAssignmentsPerJuror: true,
|
|
|
|
|
maxAssignmentsPerJuror: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
// Get all active jury members with their expertise and current load
|
|
|
|
|
const jurors = await ctx.prisma.user.findMany({
|
|
|
|
|
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
email: true,
|
|
|
|
|
expertiseTags: true,
|
|
|
|
|
maxAssignments: true,
|
|
|
|
|
_count: {
|
|
|
|
|
select: {
|
|
|
|
|
assignments: { where: { roundId: input.roundId } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get all projects that need more assignments
|
2026-02-04 14:15:06 +01:00
|
|
|
const projects = await ctx.prisma.project.findMany({
|
2026-01-30 13:41:32 +01:00
|
|
|
where: { roundId: input.roundId },
|
2026-02-04 14:15:06 +01:00
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
title: true,
|
|
|
|
|
tags: true,
|
2026-02-05 14:14:19 +01:00
|
|
|
projectTags: {
|
|
|
|
|
include: { tag: { select: { name: true } } },
|
|
|
|
|
},
|
2026-02-04 14:15:06 +01:00
|
|
|
_count: { select: { assignments: true } },
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get existing assignments to avoid duplicates
|
|
|
|
|
const existingAssignments = await ctx.prisma.assignment.findMany({
|
|
|
|
|
where: { roundId: input.roundId },
|
|
|
|
|
select: { userId: true, projectId: true },
|
|
|
|
|
})
|
|
|
|
|
const assignmentSet = new Set(
|
|
|
|
|
existingAssignments.map((a) => `${a.userId}-${a.projectId}`)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Simple scoring algorithm
|
|
|
|
|
const suggestions: Array<{
|
|
|
|
|
userId: string
|
2026-02-04 09:36:33 +01:00
|
|
|
jurorName: string
|
2026-01-30 13:41:32 +01:00
|
|
|
projectId: string
|
2026-02-04 09:36:33 +01:00
|
|
|
projectTitle: string
|
2026-01-30 13:41:32 +01:00
|
|
|
score: number
|
|
|
|
|
reasoning: string[]
|
|
|
|
|
}> = []
|
|
|
|
|
|
|
|
|
|
for (const project of projects) {
|
|
|
|
|
// Skip if project has enough assignments
|
2026-02-04 16:01:18 +01:00
|
|
|
if (project._count.assignments >= round.requiredReviews) continue
|
2026-01-30 13:41:32 +01:00
|
|
|
|
2026-02-04 16:01:18 +01:00
|
|
|
const neededAssignments = round.requiredReviews - project._count.assignments
|
2026-01-30 13:41:32 +01:00
|
|
|
|
|
|
|
|
// Score each juror for this project
|
|
|
|
|
const jurorScores = jurors
|
|
|
|
|
.filter((j) => {
|
|
|
|
|
// Skip if already assigned
|
|
|
|
|
if (assignmentSet.has(`${j.id}-${project.id}`)) return false
|
2026-02-04 16:01:18 +01:00
|
|
|
// Skip if at max capacity (user override takes precedence)
|
|
|
|
|
const effectiveMax = j.maxAssignments ?? round.maxAssignmentsPerJuror
|
|
|
|
|
if (j._count.assignments >= effectiveMax) return false
|
2026-01-30 13:41:32 +01:00
|
|
|
return true
|
|
|
|
|
})
|
|
|
|
|
.map((juror) => {
|
|
|
|
|
const reasoning: string[] = []
|
|
|
|
|
let score = 0
|
|
|
|
|
|
2026-02-05 14:14:19 +01:00
|
|
|
// Expertise match (35% weight) - use AI-assigned projectTags if available
|
|
|
|
|
const projectTagNames = project.projectTags.map((pt) => pt.tag.name.toLowerCase())
|
|
|
|
|
|
|
|
|
|
// Match against AI-assigned tags first, fall back to raw tags
|
|
|
|
|
const matchingTags = projectTagNames.length > 0
|
|
|
|
|
? juror.expertiseTags.filter((tag) =>
|
|
|
|
|
projectTagNames.includes(tag.toLowerCase())
|
|
|
|
|
)
|
|
|
|
|
: juror.expertiseTags.filter((tag) =>
|
|
|
|
|
project.tags.map((t) => t.toLowerCase()).includes(tag.toLowerCase())
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const totalTags = projectTagNames.length > 0 ? projectTagNames.length : project.tags.length
|
2026-01-30 13:41:32 +01:00
|
|
|
const expertiseScore =
|
|
|
|
|
matchingTags.length > 0
|
2026-02-05 14:14:19 +01:00
|
|
|
? matchingTags.length / Math.max(totalTags, 1)
|
2026-01-30 13:41:32 +01:00
|
|
|
: 0
|
2026-02-04 16:01:18 +01:00
|
|
|
score += expertiseScore * 35
|
2026-01-30 13:41:32 +01:00
|
|
|
if (matchingTags.length > 0) {
|
|
|
|
|
reasoning.push(`Expertise match: ${matchingTags.join(', ')}`)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 16:01:18 +01:00
|
|
|
// Load balancing (20% weight)
|
|
|
|
|
const effectiveMax = juror.maxAssignments ?? round.maxAssignmentsPerJuror
|
|
|
|
|
const loadScore = 1 - juror._count.assignments / effectiveMax
|
|
|
|
|
score += loadScore * 20
|
|
|
|
|
|
|
|
|
|
// Under min target bonus (15% weight) - prioritize judges who need more projects
|
|
|
|
|
const underMinBonus =
|
|
|
|
|
juror._count.assignments < round.minAssignmentsPerJuror
|
|
|
|
|
? (round.minAssignmentsPerJuror - juror._count.assignments) * 3
|
|
|
|
|
: 0
|
|
|
|
|
score += Math.min(15, underMinBonus)
|
|
|
|
|
|
|
|
|
|
// Build reasoning
|
|
|
|
|
if (juror._count.assignments < round.minAssignmentsPerJuror) {
|
|
|
|
|
reasoning.push(
|
|
|
|
|
`Under target: ${juror._count.assignments}/${round.minAssignmentsPerJuror} min`
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-01-30 13:41:32 +01:00
|
|
|
reasoning.push(
|
2026-02-04 16:01:18 +01:00
|
|
|
`Capacity: ${juror._count.assignments}/${effectiveMax} max`
|
2026-01-30 13:41:32 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
userId: juror.id,
|
2026-02-04 09:36:33 +01:00
|
|
|
jurorName: juror.name || juror.email || 'Unknown',
|
2026-01-30 13:41:32 +01:00
|
|
|
projectId: project.id,
|
2026-02-04 09:36:33 +01:00
|
|
|
projectTitle: project.title || 'Unknown',
|
2026-01-30 13:41:32 +01:00
|
|
|
score,
|
|
|
|
|
reasoning,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.sort((a, b) => b.score - a.score)
|
|
|
|
|
.slice(0, neededAssignments)
|
|
|
|
|
|
|
|
|
|
suggestions.push(...jurorScores)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sort by score and return
|
|
|
|
|
return suggestions.sort((a, b) => b.score - a.score)
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if AI assignment is available
|
|
|
|
|
*/
|
|
|
|
|
isAIAvailable: adminProcedure.query(async () => {
|
|
|
|
|
return isOpenAIConfigured()
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-05 14:38:43 +01:00
|
|
|
* Get AI-powered assignment suggestions (retrieves from completed job)
|
2026-01-30 13:41:32 +01:00
|
|
|
*/
|
|
|
|
|
getAISuggestions: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
roundId: z.string(),
|
|
|
|
|
useAI: z.boolean().default(true),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
2026-02-05 14:38:43 +01:00
|
|
|
// Find the latest completed job for this round
|
|
|
|
|
const completedJob = await ctx.prisma.assignmentJob.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
roundId: input.roundId,
|
|
|
|
|
status: 'COMPLETED',
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
2026-02-05 14:38:43 +01:00
|
|
|
orderBy: { completedAt: 'desc' },
|
2026-02-04 14:15:06 +01:00
|
|
|
select: {
|
2026-02-05 14:38:43 +01:00
|
|
|
suggestionsJson: true,
|
|
|
|
|
fallbackUsed: true,
|
|
|
|
|
completedAt: true,
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-05 14:38:43 +01:00
|
|
|
// If we have stored suggestions, return them
|
|
|
|
|
if (completedJob?.suggestionsJson) {
|
|
|
|
|
const suggestions = completedJob.suggestionsJson as Array<{
|
|
|
|
|
jurorId: string
|
|
|
|
|
jurorName: string
|
|
|
|
|
projectId: string
|
|
|
|
|
projectTitle: string
|
|
|
|
|
confidenceScore: number
|
|
|
|
|
expertiseMatchScore: number
|
|
|
|
|
reasoning: string
|
|
|
|
|
}>
|
|
|
|
|
|
|
|
|
|
// Filter out suggestions for assignments that already exist
|
|
|
|
|
const existingAssignments = await ctx.prisma.assignment.findMany({
|
|
|
|
|
where: { roundId: input.roundId },
|
|
|
|
|
select: { userId: true, projectId: true },
|
|
|
|
|
})
|
|
|
|
|
const assignmentSet = new Set(
|
|
|
|
|
existingAssignments.map((a) => `${a.userId}-${a.projectId}`)
|
|
|
|
|
)
|
2026-01-30 13:41:32 +01:00
|
|
|
|
2026-02-05 14:38:43 +01:00
|
|
|
const filteredSuggestions = suggestions.filter(
|
|
|
|
|
(s) => !assignmentSet.has(`${s.jurorId}-${s.projectId}`)
|
|
|
|
|
)
|
2026-01-30 13:41:32 +01:00
|
|
|
|
2026-02-05 14:38:43 +01:00
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
suggestions: filteredSuggestions,
|
|
|
|
|
fallbackUsed: completedJob.fallbackUsed,
|
|
|
|
|
error: null,
|
|
|
|
|
generatedAt: completedJob.completedAt,
|
|
|
|
|
}
|
2026-01-30 13:41:32 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-05 14:38:43 +01:00
|
|
|
// No completed job with suggestions - return empty
|
2026-01-30 13:41:32 +01:00
|
|
|
return {
|
2026-02-05 14:38:43 +01:00
|
|
|
success: true,
|
|
|
|
|
suggestions: [],
|
|
|
|
|
fallbackUsed: false,
|
|
|
|
|
error: null,
|
|
|
|
|
generatedAt: null,
|
2026-01-30 13:41:32 +01:00
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Apply AI-suggested assignments
|
|
|
|
|
*/
|
|
|
|
|
applyAISuggestions: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
roundId: z.string(),
|
|
|
|
|
assignments: z.array(
|
|
|
|
|
z.object({
|
|
|
|
|
userId: z.string(),
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
confidenceScore: z.number().optional(),
|
|
|
|
|
expertiseMatchScore: z.number().optional(),
|
|
|
|
|
reasoning: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
usedAI: z.boolean().default(false),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const created = await ctx.prisma.assignment.createMany({
|
|
|
|
|
data: input.assignments.map((a) => ({
|
|
|
|
|
userId: a.userId,
|
|
|
|
|
projectId: a.projectId,
|
|
|
|
|
roundId: input.roundId,
|
|
|
|
|
method: input.usedAI ? 'AI_SUGGESTED' : 'ALGORITHM',
|
|
|
|
|
aiConfidenceScore: a.confidenceScore,
|
|
|
|
|
expertiseMatchScore: a.expertiseMatchScore,
|
|
|
|
|
aiReasoning: a.reasoning,
|
|
|
|
|
createdBy: ctx.user.id,
|
|
|
|
|
})),
|
|
|
|
|
skipDuplicates: true,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
2026-02-05 21:09:06 +01:00
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS',
|
|
|
|
|
entityType: 'Assignment',
|
|
|
|
|
detailsJson: {
|
|
|
|
|
roundId: input.roundId,
|
|
|
|
|
count: created.count,
|
|
|
|
|
usedAI: input.usedAI,
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
2026-02-05 21:09:06 +01:00
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
2026-02-04 00:10:51 +01:00
|
|
|
// Send notifications to assigned jury members
|
|
|
|
|
if (created.count > 0) {
|
|
|
|
|
const userAssignmentCounts = input.assignments.reduce(
|
|
|
|
|
(acc, a) => {
|
|
|
|
|
acc[a.userId] = (acc[a.userId] || 0) + 1
|
|
|
|
|
return acc
|
|
|
|
|
},
|
|
|
|
|
{} as Record<string, number>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const round = await ctx.prisma.round.findUnique({
|
|
|
|
|
where: { id: input.roundId },
|
|
|
|
|
select: { name: true, votingEndAt: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const deadline = round?.votingEndAt
|
|
|
|
|
? new Date(round.votingEndAt).toLocaleDateString('en-US', {
|
|
|
|
|
weekday: 'long',
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
month: 'long',
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
})
|
|
|
|
|
: undefined
|
|
|
|
|
|
2026-02-05 20:31:08 +01:00
|
|
|
// Group users by project count so we can send bulk notifications per group
|
|
|
|
|
const usersByProjectCount = new Map<number, string[]>()
|
2026-02-04 00:10:51 +01:00
|
|
|
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
|
2026-02-05 20:31:08 +01:00
|
|
|
const existing = usersByProjectCount.get(projectCount) || []
|
|
|
|
|
existing.push(userId)
|
|
|
|
|
usersByProjectCount.set(projectCount, existing)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Send bulk notifications for each project count group
|
|
|
|
|
for (const [projectCount, userIds] of usersByProjectCount) {
|
|
|
|
|
if (userIds.length === 0) continue
|
|
|
|
|
await createBulkNotifications({
|
|
|
|
|
userIds,
|
2026-02-04 00:10:51 +01:00
|
|
|
type: NotificationTypes.BATCH_ASSIGNED,
|
|
|
|
|
title: `${projectCount} Projects Assigned`,
|
|
|
|
|
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
|
|
|
|
|
linkUrl: `/jury/assignments`,
|
|
|
|
|
linkLabel: 'View Assignments',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectCount,
|
|
|
|
|
roundName: round?.name,
|
|
|
|
|
deadline,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
return { created: created.count }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Apply suggested assignments
|
|
|
|
|
*/
|
|
|
|
|
applySuggestions: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
roundId: z.string(),
|
|
|
|
|
assignments: z.array(
|
|
|
|
|
z.object({
|
|
|
|
|
userId: z.string(),
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
reasoning: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const created = await ctx.prisma.assignment.createMany({
|
|
|
|
|
data: input.assignments.map((a) => ({
|
|
|
|
|
userId: a.userId,
|
|
|
|
|
projectId: a.projectId,
|
|
|
|
|
roundId: input.roundId,
|
|
|
|
|
method: 'ALGORITHM',
|
|
|
|
|
aiReasoning: a.reasoning,
|
|
|
|
|
createdBy: ctx.user.id,
|
|
|
|
|
})),
|
|
|
|
|
skipDuplicates: true,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
2026-02-05 21:09:06 +01:00
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'APPLY_SUGGESTIONS',
|
|
|
|
|
entityType: 'Assignment',
|
|
|
|
|
detailsJson: {
|
|
|
|
|
roundId: input.roundId,
|
|
|
|
|
count: created.count,
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
2026-02-05 21:09:06 +01:00
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
2026-02-04 00:10:51 +01:00
|
|
|
// Send notifications to assigned jury members
|
|
|
|
|
if (created.count > 0) {
|
|
|
|
|
const userAssignmentCounts = input.assignments.reduce(
|
|
|
|
|
(acc, a) => {
|
|
|
|
|
acc[a.userId] = (acc[a.userId] || 0) + 1
|
|
|
|
|
return acc
|
|
|
|
|
},
|
|
|
|
|
{} as Record<string, number>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const round = await ctx.prisma.round.findUnique({
|
|
|
|
|
where: { id: input.roundId },
|
|
|
|
|
select: { name: true, votingEndAt: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const deadline = round?.votingEndAt
|
|
|
|
|
? new Date(round.votingEndAt).toLocaleDateString('en-US', {
|
|
|
|
|
weekday: 'long',
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
month: 'long',
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
})
|
|
|
|
|
: undefined
|
|
|
|
|
|
2026-02-05 20:31:08 +01:00
|
|
|
// Group users by project count so we can send bulk notifications per group
|
|
|
|
|
const usersByProjectCount = new Map<number, string[]>()
|
2026-02-04 00:10:51 +01:00
|
|
|
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
|
2026-02-05 20:31:08 +01:00
|
|
|
const existing = usersByProjectCount.get(projectCount) || []
|
|
|
|
|
existing.push(userId)
|
|
|
|
|
usersByProjectCount.set(projectCount, existing)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Send bulk notifications for each project count group
|
|
|
|
|
for (const [projectCount, userIds] of usersByProjectCount) {
|
|
|
|
|
if (userIds.length === 0) continue
|
|
|
|
|
await createBulkNotifications({
|
|
|
|
|
userIds,
|
2026-02-04 00:10:51 +01:00
|
|
|
type: NotificationTypes.BATCH_ASSIGNED,
|
|
|
|
|
title: `${projectCount} Projects Assigned`,
|
|
|
|
|
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
|
|
|
|
|
linkUrl: `/jury/assignments`,
|
|
|
|
|
linkLabel: 'View Assignments',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectCount,
|
|
|
|
|
roundName: round?.name,
|
|
|
|
|
deadline,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
return { created: created.count }
|
|
|
|
|
}),
|
2026-02-04 17:40:26 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Start an AI assignment job (background processing)
|
|
|
|
|
*/
|
|
|
|
|
startAIAssignmentJob: adminProcedure
|
|
|
|
|
.input(z.object({ roundId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Check for existing running job
|
|
|
|
|
const existingJob = await ctx.prisma.assignmentJob.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
roundId: input.roundId,
|
|
|
|
|
status: { in: ['PENDING', 'RUNNING'] },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (existingJob) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'An AI assignment job is already running for this round',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify AI is available
|
|
|
|
|
if (!isOpenAIConfigured()) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'OpenAI API is not configured',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create job record
|
|
|
|
|
const job = await ctx.prisma.assignmentJob.create({
|
|
|
|
|
data: {
|
|
|
|
|
roundId: input.roundId,
|
|
|
|
|
status: 'PENDING',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Start background job (non-blocking)
|
|
|
|
|
runAIAssignmentJob(job.id, input.roundId, ctx.user.id).catch(console.error)
|
|
|
|
|
|
|
|
|
|
return { jobId: job.id }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get AI assignment job status (for polling)
|
|
|
|
|
*/
|
2026-02-05 20:31:08 +01:00
|
|
|
getAIAssignmentJobStatus: adminProcedure
|
2026-02-04 17:40:26 +01:00
|
|
|
.input(z.object({ jobId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const job = await ctx.prisma.assignmentJob.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.jobId },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: job.id,
|
|
|
|
|
status: job.status,
|
|
|
|
|
totalProjects: job.totalProjects,
|
|
|
|
|
totalBatches: job.totalBatches,
|
|
|
|
|
currentBatch: job.currentBatch,
|
|
|
|
|
processedCount: job.processedCount,
|
|
|
|
|
suggestionsCount: job.suggestionsCount,
|
|
|
|
|
fallbackUsed: job.fallbackUsed,
|
|
|
|
|
errorMessage: job.errorMessage,
|
|
|
|
|
startedAt: job.startedAt,
|
|
|
|
|
completedAt: job.completedAt,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the latest AI assignment job for a round
|
|
|
|
|
*/
|
|
|
|
|
getLatestAIAssignmentJob: adminProcedure
|
|
|
|
|
.input(z.object({ roundId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const job = await ctx.prisma.assignmentJob.findFirst({
|
|
|
|
|
where: { roundId: input.roundId },
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!job) return null
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: job.id,
|
|
|
|
|
status: job.status,
|
|
|
|
|
totalProjects: job.totalProjects,
|
|
|
|
|
totalBatches: job.totalBatches,
|
|
|
|
|
currentBatch: job.currentBatch,
|
|
|
|
|
processedCount: job.processedCount,
|
|
|
|
|
suggestionsCount: job.suggestionsCount,
|
|
|
|
|
fallbackUsed: job.fallbackUsed,
|
|
|
|
|
errorMessage: job.errorMessage,
|
|
|
|
|
startedAt: job.startedAt,
|
|
|
|
|
completedAt: job.completedAt,
|
|
|
|
|
createdAt: job.createdAt,
|
|
|
|
|
}
|
|
|
|
|
}),
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|