Round system redesign: Phases 1-7 complete

Full pipeline/track/stage architecture replacing the legacy round system.

Schema: 11 new models (Pipeline, Track, Stage, StageTransition,
ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor,
OverrideAction, AudienceVoter) + 8 new enums.

Backend: 9 new routers (pipeline, stage, routing, stageFiltering,
stageAssignment, cohort, live, decision, award) + 6 new services
(stage-engine, routing-engine, stage-filtering, stage-assignment,
stage-notifications, live-control).

Frontend: Pipeline wizard (17 components), jury stage pages (7),
applicant pipeline pages (3), public stage pages (2), admin pipeline
pages (5), shared stage components (3), SSE route, live hook.

Phase 6 refit: 23 routers/services migrated from roundId to stageId,
all frontend components refitted. Deleted round.ts (985 lines),
roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx,
10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs.

Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing,
TypeScript 0 errors, Next.js build succeeds, 13 integrity checks,
legacy symbol sweep clean, auto-seed on first Docker startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 13:57:09 +01:00
parent 8a328357e3
commit 331b67dae0
256 changed files with 29117 additions and 21424 deletions

View File

@@ -17,27 +17,26 @@ import {
} from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
// Background job execution function
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
async function runAIAssignmentJob(jobId: string, stageId: 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 },
const stage = await prisma.stage.findUniqueOrThrow({
where: { id: stageId },
select: {
name: true,
requiredReviews: true,
minAssignmentsPerJuror: true,
maxAssignmentsPerJuror: true,
configJson: true,
},
})
// Get all active jury members with their expertise and current load
const config = (stage.configJson ?? {}) as Record<string, unknown>
const requiredReviews = (config.requiredReviews as number) ?? 3
const minAssignmentsPerJuror = (config.minAssignmentsPerJuror as number) ?? 1
const maxAssignmentsPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
const jurors = await prisma.user.findMany({
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
select: {
@@ -48,28 +47,32 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
maxAssignments: true,
_count: {
select: {
assignments: { where: { roundId } },
assignments: { where: { stageId } },
},
},
},
})
// Get all projects in the round
const projectStageStates = await prisma.projectStageState.findMany({
where: { stageId },
select: { projectId: true },
})
const projectIds = projectStageStates.map((pss) => pss.projectId)
const projects = await prisma.project.findMany({
where: { roundId },
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
description: true,
tags: true,
teamName: true,
_count: { select: { assignments: true } },
_count: { select: { assignments: { where: { stageId } } } },
},
})
// Get existing assignments
const existingAssignments = await prisma.assignment.findMany({
where: { roundId },
where: { stageId },
select: { userId: true, projectId: true },
})
@@ -94,22 +97,21 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
}
const constraints = {
requiredReviewsPerProject: round.requiredReviews,
minAssignmentsPerJuror: round.minAssignmentsPerJuror,
maxAssignmentsPerJuror: round.maxAssignmentsPerJuror,
requiredReviewsPerProject: requiredReviews,
minAssignmentsPerJuror,
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,
stageId,
onProgress
)
@@ -137,16 +139,15 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
},
})
// 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`,
message: `AI generated ${result.suggestions.length} assignment suggestions for ${stage.name || 'stage'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
linkUrl: `/admin/rounds/pipeline/stages/${stageId}/assignments`,
linkLabel: 'View Suggestions',
priority: 'high',
metadata: {
roundId,
stageId,
jobId,
projectCount: projects.length,
suggestionsCount: result.suggestions.length,
@@ -170,14 +171,11 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
}
export const assignmentRouter = router({
/**
* List assignments for a round (admin only)
*/
listByRound: adminProcedure
.input(z.object({ roundId: z.string() }))
listByStage: adminProcedure
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
include: {
user: { select: { id: true, name: true, email: true, expertiseTags: true } },
project: { select: { id: true, title: true, tags: true } },
@@ -220,18 +218,18 @@ export const assignmentRouter = router({
myAssignments: protectedProcedure
.input(
z.object({
roundId: z.string().optional(),
stageId: 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' },
stage: { status: 'STAGE_ACTIVE' },
}
if (input.roundId) {
where.roundId = input.roundId
if (input.stageId) {
where.stageId = input.stageId
}
if (input.status === 'pending') {
@@ -246,7 +244,7 @@ export const assignmentRouter = router({
project: {
include: { files: true },
},
round: true,
stage: true,
evaluation: true,
},
orderBy: [{ isCompleted: 'asc' }, { createdAt: 'asc' }],
@@ -264,7 +262,7 @@ export const assignmentRouter = router({
include: {
user: { select: { id: true, name: true, email: true } },
project: { include: { files: true } },
round: { include: { evaluationForms: { where: { isActive: true } } } },
stage: { include: { evaluationForms: { where: { isActive: true } } } },
evaluation: true,
},
})
@@ -291,19 +289,18 @@ export const assignmentRouter = router({
z.object({
userId: z.string(),
projectId: z.string(),
roundId: z.string(),
stageId: z.string(),
isRequired: z.boolean().default(true),
forceOverride: z.boolean().default(false), // Allow manual override of limits
forceOverride: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
// Check if assignment already exists
const existing = await ctx.prisma.assignment.findUnique({
where: {
userId_projectId_roundId: {
userId_projectId_stageId: {
userId: input.userId,
projectId: input.projectId,
roundId: input.roundId,
stageId: input.stageId,
},
},
})
@@ -315,11 +312,10 @@ export const assignmentRouter = router({
})
}
// Get round constraints and user limit
const [round, user] = await Promise.all([
ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { maxAssignmentsPerJuror: true },
const [stage, user] = await Promise.all([
ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
select: { configJson: true },
}),
ctx.prisma.user.findUniqueOrThrow({
where: { id: input.userId },
@@ -327,11 +323,12 @@ export const assignmentRouter = router({
}),
])
// Calculate effective max: user override takes precedence if set
const effectiveMax = user.maxAssignments ?? round.maxAssignmentsPerJuror
const config = (stage.configJson ?? {}) as Record<string, unknown>
const maxAssignmentsPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
const effectiveMax = user.maxAssignments ?? maxAssignmentsPerJuror
const currentCount = await ctx.prisma.assignment.count({
where: { userId: input.userId, roundId: input.roundId },
where: { userId: input.userId, stageId: input.stageId },
})
// Check if at or over limit
@@ -367,21 +364,20 @@ export const assignmentRouter = router({
userAgent: ctx.userAgent,
})
// Send notification to the assigned jury member
const [project, roundInfo] = await Promise.all([
const [project, stageInfo] = await Promise.all([
ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { title: true },
}),
ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, votingEndAt: true },
ctx.prisma.stage.findUnique({
where: { id: input.stageId },
select: { name: true, windowCloseAt: true },
}),
])
if (project && roundInfo) {
const deadline = roundInfo.votingEndAt
? new Date(roundInfo.votingEndAt).toLocaleDateString('en-US', {
if (project && stageInfo) {
const deadline = stageInfo.windowCloseAt
? new Date(stageInfo.windowCloseAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
@@ -393,12 +389,12 @@ export const assignmentRouter = router({
userId: input.userId,
type: NotificationTypes.ASSIGNED_TO_PROJECT,
title: 'New Project Assignment',
message: `You have been assigned to evaluate "${project.title}" for ${roundInfo.name}.`,
linkUrl: `/jury/assignments`,
message: `You have been assigned to evaluate "${project.title}" for ${stageInfo.name}.`,
linkUrl: `/jury/stages`,
linkLabel: 'View Assignment',
metadata: {
projectName: project.title,
roundName: roundInfo.name,
stageName: stageInfo.name,
deadline,
assignmentId: assignment.id,
},
@@ -414,11 +410,11 @@ export const assignmentRouter = router({
bulkCreate: adminProcedure
.input(
z.object({
stageId: z.string(),
assignments: z.array(
z.object({
userId: z.string(),
projectId: z.string(),
roundId: z.string(),
})
),
})
@@ -427,6 +423,7 @@ export const assignmentRouter = router({
const result = await ctx.prisma.assignment.createMany({
data: input.assignments.map((a) => ({
...a,
stageId: input.stageId,
method: 'BULK',
createdBy: ctx.user.id,
})),
@@ -455,15 +452,13 @@ export const assignmentRouter = router({
{} 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 stage = await ctx.prisma.stage.findUnique({
where: { id: input.stageId },
select: { name: true, windowCloseAt: true },
})
const deadline = round?.votingEndAt
? new Date(round.votingEndAt).toLocaleDateString('en-US', {
const deadline = stage?.windowCloseAt
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
@@ -471,7 +466,6 @@ export const assignmentRouter = router({
})
: undefined
// Group users by project count so we can send bulk notifications per group
const usersByProjectCount = new Map<number, string[]>()
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
const existing = usersByProjectCount.get(projectCount) || []
@@ -479,19 +473,18 @@ export const assignmentRouter = router({
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,
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`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stage?.name || 'this stage'}.`,
linkUrl: `/jury/stages`,
linkLabel: 'View Assignments',
metadata: {
projectCount,
roundName: round?.name,
stageName: stage?.name,
deadline,
},
})
@@ -537,40 +530,48 @@ export const assignmentRouter = router({
* Get assignment statistics for a round
*/
getStats: adminProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
select: { configJson: true },
})
const config = (stage.configJson ?? {}) as Record<string, unknown>
const requiredReviews = (config.requiredReviews as number) ?? 3
const projectStageStates = await ctx.prisma.projectStageState.findMany({
where: { stageId: input.stageId },
select: { projectId: true },
})
const projectIds = projectStageStates.map((pss) => pss.projectId)
const [
totalAssignments,
completedAssignments,
assignmentsByUser,
projectCoverage,
round,
] = await Promise.all([
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.assignment.count({ where: { stageId: input.stageId } }),
ctx.prisma.assignment.count({
where: { roundId: input.roundId, isCompleted: true },
where: { stageId: input.stageId, isCompleted: true },
}),
ctx.prisma.assignment.groupBy({
by: ['userId'],
where: { roundId: input.roundId },
where: { stageId: input.stageId },
_count: true,
}),
ctx.prisma.project.findMany({
where: { roundId: input.roundId },
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
_count: { select: { assignments: true } },
_count: { select: { assignments: { where: { stageId: input.stageId } } } },
},
}),
ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { requiredReviews: true },
}),
])
const projectsWithFullCoverage = projectCoverage.filter(
(p) => p._count.assignments >= round.requiredReviews
(p) => p._count.assignments >= requiredReviews
).length
return {
@@ -598,21 +599,19 @@ export const assignmentRouter = router({
getSuggestions: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
})
)
.query(async ({ ctx, input }) => {
// Get round constraints
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: {
requiredReviews: true,
minAssignmentsPerJuror: true,
maxAssignmentsPerJuror: true,
},
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
select: { configJson: true },
})
const config = (stage.configJson ?? {}) as Record<string, unknown>
const requiredReviews = (config.requiredReviews as number) ?? 3
const minAssignmentsPerJuror = (config.minAssignmentsPerJuror as number) ?? 1
const maxAssignmentsPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
// 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: {
@@ -623,15 +622,20 @@ export const assignmentRouter = router({
maxAssignments: true,
_count: {
select: {
assignments: { where: { roundId: input.roundId } },
assignments: { where: { stageId: input.stageId } },
},
},
},
})
// Get all projects that need more assignments
const projectStageStates = await ctx.prisma.projectStageState.findMany({
where: { stageId: input.stageId },
select: { projectId: true },
})
const projectIds = projectStageStates.map((pss) => pss.projectId)
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
@@ -639,20 +643,18 @@ export const assignmentRouter = router({
projectTags: {
include: { tag: { select: { name: true } } },
},
_count: { select: { assignments: true } },
_count: { select: { assignments: { where: { stageId: input.stageId } } } },
},
})
// Get existing assignments to avoid duplicates
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
select: { userId: true, projectId: true },
})
const assignmentSet = new Set(
existingAssignments.map((a) => `${a.userId}-${a.projectId}`)
)
// Simple scoring algorithm
const suggestions: Array<{
userId: string
jurorName: string
@@ -663,18 +665,14 @@ export const assignmentRouter = router({
}> = []
for (const project of projects) {
// Skip if project has enough assignments
if (project._count.assignments >= round.requiredReviews) continue
if (project._count.assignments >= requiredReviews) continue
const neededAssignments = round.requiredReviews - project._count.assignments
const neededAssignments = requiredReviews - project._count.assignments
// Score each juror for this project
const jurorScores = jurors
.filter((j) => {
// Skip if already assigned
if (assignmentSet.has(`${j.id}-${project.id}`)) return false
// Skip if at max capacity (user override takes precedence)
const effectiveMax = j.maxAssignments ?? round.maxAssignmentsPerJuror
const effectiveMax = j.maxAssignments ?? maxAssignmentsPerJuror
if (j._count.assignments >= effectiveMax) return false
return true
})
@@ -682,10 +680,8 @@ export const assignmentRouter = router({
const reasoning: string[] = []
let score = 0
// 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())
@@ -704,22 +700,19 @@ export const assignmentRouter = router({
reasoning.push(`Expertise match: ${matchingTags.join(', ')}`)
}
// Load balancing (20% weight)
const effectiveMax = juror.maxAssignments ?? round.maxAssignmentsPerJuror
const effectiveMax = juror.maxAssignments ?? 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
juror._count.assignments < minAssignmentsPerJuror
? (minAssignmentsPerJuror - juror._count.assignments) * 3
: 0
score += Math.min(15, underMinBonus)
// Build reasoning
if (juror._count.assignments < round.minAssignmentsPerJuror) {
if (juror._count.assignments < minAssignmentsPerJuror) {
reasoning.push(
`Under target: ${juror._count.assignments}/${round.minAssignmentsPerJuror} min`
`Under target: ${juror._count.assignments}/${minAssignmentsPerJuror} min`
)
}
reasoning.push(
@@ -741,7 +734,6 @@ export const assignmentRouter = router({
suggestions.push(...jurorScores)
}
// Sort by score and return
return suggestions.sort((a, b) => b.score - a.score)
}),
@@ -758,15 +750,14 @@ export const assignmentRouter = router({
getAISuggestions: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
useAI: z.boolean().default(true),
})
)
.query(async ({ ctx, input }) => {
// Find the latest completed job for this round
const completedJob = await ctx.prisma.assignmentJob.findFirst({
where: {
roundId: input.roundId,
stageId: input.stageId,
status: 'COMPLETED',
},
orderBy: { completedAt: 'desc' },
@@ -777,7 +768,6 @@ export const assignmentRouter = router({
},
})
// If we have stored suggestions, return them
if (completedJob?.suggestionsJson) {
const suggestions = completedJob.suggestionsJson as Array<{
jurorId: string
@@ -789,9 +779,8 @@ export const assignmentRouter = router({
reasoning: string
}>
// Filter out suggestions for assignments that already exist
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
select: { userId: true, projectId: true },
})
const assignmentSet = new Set(
@@ -811,7 +800,6 @@ export const assignmentRouter = router({
}
}
// No completed job with suggestions - return empty
return {
success: true,
suggestions: [],
@@ -827,7 +815,7 @@ export const assignmentRouter = router({
applyAISuggestions: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
assignments: z.array(
z.object({
userId: z.string(),
@@ -845,7 +833,7 @@ export const assignmentRouter = router({
data: input.assignments.map((a) => ({
userId: a.userId,
projectId: a.projectId,
roundId: input.roundId,
stageId: input.stageId,
method: input.usedAI ? 'AI_SUGGESTED' : 'ALGORITHM',
aiConfidenceScore: a.confidenceScore,
expertiseMatchScore: a.expertiseMatchScore,
@@ -855,14 +843,13 @@ export const assignmentRouter = router({
skipDuplicates: true,
})
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS',
entityType: 'Assignment',
detailsJson: {
roundId: input.roundId,
stageId: input.stageId,
count: created.count,
usedAI: input.usedAI,
},
@@ -870,7 +857,6 @@ export const assignmentRouter = router({
userAgent: ctx.userAgent,
})
// Send notifications to assigned jury members
if (created.count > 0) {
const userAssignmentCounts = input.assignments.reduce(
(acc, a) => {
@@ -880,13 +866,13 @@ export const assignmentRouter = router({
{} as Record<string, number>
)
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, votingEndAt: true },
const stage = await ctx.prisma.stage.findUnique({
where: { id: input.stageId },
select: { name: true, windowCloseAt: true },
})
const deadline = round?.votingEndAt
? new Date(round.votingEndAt).toLocaleDateString('en-US', {
const deadline = stage?.windowCloseAt
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
@@ -894,7 +880,6 @@ export const assignmentRouter = router({
})
: undefined
// Group users by project count so we can send bulk notifications per group
const usersByProjectCount = new Map<number, string[]>()
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
const existing = usersByProjectCount.get(projectCount) || []
@@ -902,19 +887,18 @@ export const assignmentRouter = router({
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,
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`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stage?.name || 'this stage'}.`,
linkUrl: `/jury/stages`,
linkLabel: 'View Assignments',
metadata: {
projectCount,
roundName: round?.name,
stageName: stage?.name,
deadline,
},
})
@@ -930,7 +914,7 @@ export const assignmentRouter = router({
applySuggestions: adminProcedure
.input(
z.object({
roundId: z.string(),
stageId: z.string(),
assignments: z.array(
z.object({
userId: z.string(),
@@ -945,7 +929,7 @@ export const assignmentRouter = router({
data: input.assignments.map((a) => ({
userId: a.userId,
projectId: a.projectId,
roundId: input.roundId,
stageId: input.stageId,
method: 'ALGORITHM',
aiReasoning: a.reasoning,
createdBy: ctx.user.id,
@@ -953,21 +937,19 @@ export const assignmentRouter = router({
skipDuplicates: true,
})
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'APPLY_SUGGESTIONS',
entityType: 'Assignment',
detailsJson: {
roundId: input.roundId,
stageId: input.stageId,
count: created.count,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Send notifications to assigned jury members
if (created.count > 0) {
const userAssignmentCounts = input.assignments.reduce(
(acc, a) => {
@@ -977,13 +959,13 @@ export const assignmentRouter = router({
{} as Record<string, number>
)
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, votingEndAt: true },
const stage = await ctx.prisma.stage.findUnique({
where: { id: input.stageId },
select: { name: true, windowCloseAt: true },
})
const deadline = round?.votingEndAt
? new Date(round.votingEndAt).toLocaleDateString('en-US', {
const deadline = stage?.windowCloseAt
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
@@ -991,7 +973,6 @@ export const assignmentRouter = router({
})
: undefined
// Group users by project count so we can send bulk notifications per group
const usersByProjectCount = new Map<number, string[]>()
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
const existing = usersByProjectCount.get(projectCount) || []
@@ -999,19 +980,18 @@ export const assignmentRouter = router({
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,
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`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${stage?.name || 'this stage'}.`,
linkUrl: `/jury/stages`,
linkLabel: 'View Assignments',
metadata: {
projectCount,
roundName: round?.name,
stageName: stage?.name,
deadline,
},
})
@@ -1025,12 +1005,11 @@ export const assignmentRouter = router({
* Start an AI assignment job (background processing)
*/
startAIAssignmentJob: adminProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Check for existing running job
const existingJob = await ctx.prisma.assignmentJob.findFirst({
where: {
roundId: input.roundId,
stageId: input.stageId,
status: { in: ['PENDING', 'RUNNING'] },
},
})
@@ -1038,11 +1017,10 @@ export const assignmentRouter = router({
if (existingJob) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'An AI assignment job is already running for this round',
message: 'An AI assignment job is already running for this stage',
})
}
// Verify AI is available
if (!isOpenAIConfigured()) {
throw new TRPCError({
code: 'BAD_REQUEST',
@@ -1050,16 +1028,14 @@ export const assignmentRouter = router({
})
}
// Create job record
const job = await ctx.prisma.assignmentJob.create({
data: {
roundId: input.roundId,
stageId: input.stageId,
status: 'PENDING',
},
})
// Start background job (non-blocking)
runAIAssignmentJob(job.id, input.roundId, ctx.user.id).catch(console.error)
runAIAssignmentJob(job.id, input.stageId, ctx.user.id).catch(console.error)
return { jobId: job.id }
}),
@@ -1093,10 +1069,10 @@ export const assignmentRouter = router({
* Get the latest AI assignment job for a round
*/
getLatestAIAssignmentJob: adminProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
const job = await ctx.prisma.assignmentJob.findFirst({
where: { roundId: input.roundId },
where: { stageId: input.stageId },
orderBy: { createdAt: 'desc' },
})