/** * Enhanced Assignment Service (Round-Aware) * * Builds on existing smart-assignment scoring and integrates with the * Phase 2 policy engine for cap/mode/bias resolution. */ import type { PrismaClient, Prisma } from '@prisma/client' import { logAudit } from '@/server/utils/audit' import { resolveCompetitionContext, resolveMemberContext } from './competition-context' import { evaluateAssignmentPolicy } from './assignment-policy' import { calculateTagOverlapScore, calculateBioMatchScore, calculateWorkloadScore, calculateAvailabilityPenalty, calculateCategoryQuotaPenalty, type ProjectTagData, type ScoreBreakdown, } from './smart-assignment' // ─── Types ────────────────────────────────────────────────────────────────── export type AssignmentPreview = { assignments: AssignmentSuggestion[] warnings: string[] stats: { totalProjects: number totalJurors: number assignmentsGenerated: number unassignedProjects: number } } export type AssignmentSuggestion = { userId: string userName: string projectId: string projectTitle: string score: number breakdown: ScoreBreakdown reasoning: string[] matchingTags: string[] policyViolations: string[] fromIntent: boolean } export type CoverageReport = { totalProjects: number fullyAssigned: number partiallyAssigned: number unassigned: number avgReviewsPerProject: number requiredReviews: number byCategory: Record } // ─── Constants ────────────────────────────────────────────────────────────── const GEO_DIVERSITY_THRESHOLD = 2 const GEO_DIVERSITY_PENALTY = -15 const PREVIOUS_ROUND_FAMILIARITY_BONUS = 10 // ─── Preview Assignment ───────────────────────────────────────────────────── /** * Preview round assignments without committing them. * 1. Load round + juryGroup + members via competition-context * 2. Evaluate policy for each member via evaluateAssignmentPolicy() * 3. Honor pending AssignmentIntents first * 4. Run scoring algorithm * 5. Enforce hard/soft caps per member * 6. Return preview with policy violation warnings */ export async function previewRoundAssignment( roundId: string, config?: { honorIntents?: boolean; requiredReviews?: number }, prisma?: PrismaClient | any, ): Promise { const db = prisma ?? (await import('@/lib/prisma')).prisma const honorIntents = config?.honorIntents ?? true const requiredReviews = config?.requiredReviews ?? 3 const ctx = await resolveCompetitionContext(roundId) const warnings: string[] = [] if (!ctx.juryGroup) { return { assignments: [], warnings: ['Round has no linked jury group'], stats: { totalProjects: 0, totalJurors: 0, assignmentsGenerated: 0, unassignedProjects: 0 }, } } // Load jury group members const members = await db.juryGroupMember.findMany({ where: { juryGroupId: ctx.juryGroup.id }, include: { user: { select: { id: true, name: true, email: true, role: true, bio: true, expertiseTags: true, country: true, availabilityJson: true } }, }, }) // Load projects in this round (with active ProjectRoundState) const projectStates = await db.projectRoundState.findMany({ where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } }, include: { project: { select: { id: true, title: true, description: true, country: true, competitionCategory: true, projectTags: { include: { tag: true } }, }, }, }, }) const projects = projectStates.map((ps: any) => ps.project) if (projects.length === 0) { return { assignments: [], warnings: ['No active projects in this round'], stats: { totalProjects: 0, totalJurors: members.length, assignmentsGenerated: 0, unassignedProjects: 0 }, } } // Load existing assignments for this round const existingAssignments = await db.assignment.findMany({ where: { roundId }, select: { userId: true, projectId: true }, }) const assignedPairs = new Set(existingAssignments.map((a: any) => `${a.userId}:${a.projectId}`)) // Track assignment counts per juror for policy evaluation const jurorAssignmentCounts = new Map() for (const a of existingAssignments) { jurorAssignmentCounts.set(a.userId, (jurorAssignmentCounts.get(a.userId) ?? 0) + 1) } // Load pending intents let pendingIntents: any[] = [] if (honorIntents) { pendingIntents = await db.assignmentIntent.findMany({ where: { roundId, status: 'INTENT_PENDING' }, include: { juryGroupMember: { include: { user: { select: { id: true } } } }, }, }) } // Load COI records const coiRecords = await db.conflictOfInterest.findMany({ where: { assignment: { roundId }, hasConflict: true, }, select: { userId: true, projectId: true }, }) const coiPairs = new Set(coiRecords.map((c: any) => `${c.userId}:${c.projectId}`)) // Build assignment suggestions const suggestions: AssignmentSuggestion[] = [] const projectAssignmentCounts = new Map() // Count existing coverage for (const a of existingAssignments) { projectAssignmentCounts.set(a.projectId, (projectAssignmentCounts.get(a.projectId) ?? 0) + 1) } // First: honor pending intents const intentAssignments = new Set() for (const intent of pendingIntents) { const userId = intent.juryGroupMember.user.id const projectId = intent.projectId const pairKey = `${userId}:${projectId}` if (assignedPairs.has(pairKey) || coiPairs.has(pairKey)) continue const member = members.find((m: any) => m.userId === userId) if (!member) continue const project = projects.find((p: any) => p.id === projectId) if (!project) continue suggestions.push({ userId, userName: member.user.name ?? 'Unknown', projectId, projectTitle: project.title, score: 100, // Intent-based assignments get max priority breakdown: { tagOverlap: 0, bioMatch: 0, workloadBalance: 0, countryMatch: 0, geoDiversityPenalty: 0, previousRoundFamiliarity: 0, coiPenalty: 0, availabilityPenalty: 0, categoryQuotaPenalty: 0 }, reasoning: ['Honoring assignment intent'], matchingTags: [], policyViolations: [], fromIntent: true, }) intentAssignments.add(pairKey) } // Then: algorithmic matching for remaining needs for (const member of members) { const userId = member.userId const currentCount = (jurorAssignmentCounts.get(userId) ?? 0) + suggestions.filter((s) => s.userId === userId).length // Build a minimal member context for policy evaluation const memberCtx = { ...ctx, member: member as any, user: member.user, currentAssignmentCount: currentCount, assignmentsByCategory: {}, pendingIntents: [], } const policy = evaluateAssignmentPolicy(memberCtx) if (!policy.canAssignMore) continue for (const project of projects) { const pairKey = `${userId}:${project.id}` if (assignedPairs.has(pairKey) || intentAssignments.has(pairKey) || coiPairs.has(pairKey)) continue // Check project needs more reviews const currentProjectReviews = (projectAssignmentCounts.get(project.id) ?? 0) + suggestions.filter((s) => s.projectId === project.id).length if (currentProjectReviews >= requiredReviews) continue // Calculate score const projectTags: ProjectTagData[] = project.projectTags.map((pt: any) => ({ tagId: pt.tagId, tagName: pt.tag.name, confidence: pt.confidence, })) const { score: tagScore, matchingTags } = calculateTagOverlapScore( member.user.expertiseTags ?? [], projectTags, ) const { score: bioScore } = calculateBioMatchScore( member.user.bio, project.description, ) const workloadScore = calculateWorkloadScore( currentCount, policy.effectiveCap.value, ) const availabilityPenalty = calculateAvailabilityPenalty( member.user.availabilityJson, ctx.round.windowOpenAt, ctx.round.windowCloseAt, ) const categoryQuotaPenalty = policy.categoryBias.value ? calculateCategoryQuotaPenalty( Object.fromEntries( Object.entries(policy.categoryBias.value).map(([k, v]) => [k, { min: 0, max: Math.round(v * policy.effectiveCap.value) }]), ), {}, project.competitionCategory, ) : 0 const totalScore = tagScore + bioScore + workloadScore + availabilityPenalty + categoryQuotaPenalty const policyViolations: string[] = [] if (policy.isOverCap) { policyViolations.push(`Over cap by ${policy.overCapBy}`) } const reasoning: string[] = [] if (matchingTags.length > 0) reasoning.push(`${matchingTags.length} matching tag(s)`) if (bioScore > 0) reasoning.push('Bio match') if (availabilityPenalty < 0) reasoning.push('Availability concern') suggestions.push({ userId, userName: member.user.name ?? 'Unknown', projectId: project.id, projectTitle: project.title, score: totalScore, breakdown: { tagOverlap: tagScore, bioMatch: bioScore, workloadBalance: workloadScore, countryMatch: 0, geoDiversityPenalty: 0, previousRoundFamiliarity: 0, coiPenalty: 0, availabilityPenalty, categoryQuotaPenalty, }, reasoning, matchingTags, policyViolations, fromIntent: false, }) } } // Sort by score descending suggestions.sort((a, b) => b.score - a.score) // Greedy assignment: pick top suggestions respecting caps const finalAssignments: AssignmentSuggestion[] = [] const finalJurorCounts = new Map(jurorAssignmentCounts) const finalProjectCounts = new Map(projectAssignmentCounts) for (const suggestion of suggestions) { const jurorCount = finalJurorCounts.get(suggestion.userId) ?? 0 const projectCount = finalProjectCounts.get(suggestion.projectId) ?? 0 if (projectCount >= requiredReviews) continue // Re-check cap const member = members.find((m: any) => m.userId === suggestion.userId) if (member) { const memberCtx = { ...ctx, member: member as any, user: member.user, currentAssignmentCount: jurorCount, assignmentsByCategory: {}, pendingIntents: [], } const policy = evaluateAssignmentPolicy(memberCtx) if (!policy.canAssignMore && !suggestion.fromIntent) continue } finalAssignments.push(suggestion) finalJurorCounts.set(suggestion.userId, jurorCount + 1) finalProjectCounts.set(suggestion.projectId, projectCount + 1) } const unassignedProjects = projects.filter( (p: any) => (finalProjectCounts.get(p.id) ?? 0) < requiredReviews, ).length return { assignments: finalAssignments, warnings, stats: { totalProjects: projects.length, totalJurors: members.length, assignmentsGenerated: finalAssignments.length, unassignedProjects, }, } } // ─── Execute Assignment ───────────────────────────────────────────────────── /** * Execute round assignments by creating Assignment records. */ export async function executeRoundAssignment( roundId: string, assignments: Array<{ userId: string; projectId: string }>, actorId: string, prisma: PrismaClient | any, ): Promise<{ created: number; errors: string[] }> { const db = prisma ?? (await import('@/lib/prisma')).prisma const errors: string[] = [] let created = 0 for (const assignment of assignments) { try { await db.$transaction(async (tx: any) => { // Create assignment record await tx.assignment.create({ data: { projectId: assignment.projectId, userId: assignment.userId, roundId, method: 'ALGORITHM', }, }) // Create or update ProjectRoundState to IN_PROGRESS await tx.projectRoundState.upsert({ where: { projectId_roundId: { projectId: assignment.projectId, roundId, }, }, create: { projectId: assignment.projectId, roundId, state: 'IN_PROGRESS', enteredAt: new Date(), }, update: { state: 'IN_PROGRESS', }, }) // Honor matching intent if exists const intent = await tx.assignmentIntent.findFirst({ where: { roundId, projectId: assignment.projectId, juryGroupMember: { userId: assignment.userId }, status: 'INTENT_PENDING', }, }) if (intent) { await tx.assignmentIntent.update({ where: { id: intent.id }, data: { status: 'HONORED' }, }) } await logAudit({ prisma: tx, userId: actorId, action: 'ROUND_ASSIGNMENT_CREATE', entityType: 'Assignment', entityId: `${assignment.userId}:${assignment.projectId}`, detailsJson: { roundId, userId: assignment.userId, projectId: assignment.projectId }, }) created++ }) } catch (error) { errors.push( `Failed to assign ${assignment.userId} to ${assignment.projectId}: ${error instanceof Error ? error.message : 'Unknown error'}`, ) } } // Overall audit await logAudit({ prisma: db, userId: actorId, action: 'ROUND_ASSIGNMENT_BATCH', entityType: 'Round', entityId: roundId, detailsJson: { created, failed: errors.length }, }) return { created, errors } } // ─── Coverage Report ──────────────────────────────────────────────────────── /** * Get coverage report for a round's assignments. */ export async function getRoundCoverageReport( roundId: string, requiredReviews: number = 3, prisma?: PrismaClient | any, ): Promise { const db = prisma ?? (await import('@/lib/prisma')).prisma const projectStates = await db.projectRoundState.findMany({ where: { roundId }, include: { project: { select: { id: true, competitionCategory: true } }, }, }) const assignments = await db.assignment.findMany({ where: { roundId }, select: { projectId: true }, }) const projectAssignmentCounts = new Map() for (const a of assignments) { projectAssignmentCounts.set(a.projectId, (projectAssignmentCounts.get(a.projectId) ?? 0) + 1) } let fullyAssigned = 0 let partiallyAssigned = 0 let unassigned = 0 const byCategory: Record = {} for (const ps of projectStates) { const count = projectAssignmentCounts.get(ps.project.id) ?? 0 const cat = ps.project.competitionCategory ?? 'UNCATEGORIZED' if (!byCategory[cat]) { byCategory[cat] = { total: 0, assigned: 0, coverage: 0 } } byCategory[cat].total++ if (count >= requiredReviews) { fullyAssigned++ byCategory[cat].assigned++ } else if (count > 0) { partiallyAssigned++ byCategory[cat].assigned++ } else { unassigned++ } } // Calculate coverage percentages for (const cat of Object.keys(byCategory)) { byCategory[cat].coverage = byCategory[cat].total > 0 ? Math.round((byCategory[cat].assigned / byCategory[cat].total) * 100) : 0 } const totalReviews = assignments.length const avgReviews = projectStates.length > 0 ? totalReviews / projectStates.length : 0 return { totalProjects: projectStates.length, fullyAssigned, partiallyAssigned, unassigned, avgReviewsPerProject: Math.round(avgReviews * 10) / 10, requiredReviews, byCategory, } } // ─── Unassigned Queue ─────────────────────────────────────────────────────── /** * Get projects below requiredReviews threshold. */ export async function getUnassignedQueue( roundId: string, requiredReviews: number = 3, prisma?: PrismaClient | any, ) { const db = prisma ?? (await import('@/lib/prisma')).prisma const projectStates = await db.projectRoundState.findMany({ where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } }, include: { project: { select: { id: true, title: true, teamName: true, competitionCategory: true, _count: { select: { assignments: { where: { roundId } }, }, }, }, }, }, }) return projectStates .filter((ps: any) => ps.project._count.assignments < requiredReviews) .map((ps: any) => ({ projectId: ps.project.id, title: ps.project.title, teamName: ps.project.teamName, category: ps.project.competitionCategory, currentReviews: ps.project._count.assignments, needed: requiredReviews - ps.project._count.assignments, state: ps.state, })) .sort((a: any, b: any) => a.currentReviews - b.currentReviews) }