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:
@@ -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' },
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user