fix: security hardening + performance refactoring (code review batch 1)

- IDOR fix: deliberation vote now verifies juryMemberId === ctx.user.id
- Rate limiting: tRPC middleware (100/min), AI endpoints (5/hr), auth IP-based (10/15min)
- 6 compound indexes added to Prisma schema
- N+1 eliminated in processRoundClose (batch updateMany/createMany)
- N+1 eliminated in batchCheckRequirementsAndTransition (3 batch queries)
- Service extraction: juror-reassignment.ts (578 lines)
- Dead code removed: award.ts, cohort.ts, decision.ts (680 lines)
- 35 bare catch blocks replaced across 16 files
- Fire-and-forget async calls fixed
- Notification false positive bug fixed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 16:18:24 +01:00
parent a8b8643936
commit b85a9b9a7b
32 changed files with 1032 additions and 1355 deletions

View File

@@ -0,0 +1,578 @@
import { TRPCError } from '@trpc/server'
import { prisma } from '@/lib/prisma'
import {
createNotification,
notifyAdmins,
NotificationTypes,
} from './in-app-notification'
import { logAudit } from '@/server/utils/audit'
/**
* Reassign a project after a juror declares COI.
* Deletes the old assignment, finds an eligible replacement juror, and creates a new assignment.
* Returns the new juror info or null if no eligible juror found.
*/
export async function reassignAfterCOI(params: {
assignmentId: string
auditUserId?: string
auditIp?: string
auditUserAgent?: string
}): Promise<{ newJurorId: string; newJurorName: string; newAssignmentId: string } | null> {
const assignment = await prisma.assignment.findUnique({
where: { id: params.assignmentId },
include: {
round: { select: { id: true, name: true, configJson: true, juryGroupId: true } },
project: { select: { id: true, title: true } },
user: { select: { id: true, name: true, email: true } },
},
})
if (!assignment) return null
const { roundId, projectId } = assignment
const config = (assignment.round.configJson ?? {}) as Record<string, unknown>
const maxAssignmentsPerJuror =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
// ── Build exclusion set: jurors who must NEVER get this project ──────────
// 1. Currently assigned to this project in ANY round (not just current)
const allProjectAssignments = await prisma.assignment.findMany({
where: { projectId },
select: { userId: true },
})
const excludedUserIds = new Set(allProjectAssignments.map((a) => a.userId))
// 2. COI records for this project (any juror who declared conflict, ever)
const coiRecords = await prisma.conflictOfInterest.findMany({
where: { projectId, hasConflict: true },
select: { userId: true },
})
for (const c of coiRecords) excludedUserIds.add(c.userId)
// 3. Historical: jurors who previously had this project but were removed
// (via COI reassignment or admin transfer — tracked in audit logs)
const historicalAuditLogs = await prisma.decisionAuditLog.findMany({
where: {
eventType: { in: ['COI_REASSIGNMENT', 'ASSIGNMENT_TRANSFER'] },
detailsJson: { path: ['projectId'], equals: projectId },
},
select: { detailsJson: true },
})
for (const log of historicalAuditLogs) {
const details = log.detailsJson as Record<string, unknown> | null
if (!details) continue
// COI_REASSIGNMENT logs: oldJurorId had the project, newJurorId got it
if (details.oldJurorId) excludedUserIds.add(details.oldJurorId as string)
// ASSIGNMENT_TRANSFER logs: sourceJurorId lost the project
if (details.sourceJurorId) excludedUserIds.add(details.sourceJurorId as string)
// Transfer logs may have a moves array with per-project details
if (Array.isArray(details.moves)) {
for (const move of details.moves as Array<Record<string, unknown>>) {
if (move.projectId === projectId && move.newJurorId) {
// The juror who received via past transfer also had it
excludedUserIds.add(move.newJurorId as string)
}
}
}
}
// ── Find candidate jurors ───────────────────────────────────────────────
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
if (assignment.round.juryGroupId) {
const members = await prisma.juryGroupMember.findMany({
where: { juryGroupId: assignment.round.juryGroupId },
include: { user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } } },
})
candidateJurors = members
.filter((m) => m.user.status === 'ACTIVE')
.map((m) => m.user)
} else {
// No jury group — scope to jurors already assigned to this round
const roundJurorIds = await prisma.assignment.findMany({
where: { roundId },
select: { userId: true },
distinct: ['userId'],
})
const activeRoundJurorIds = roundJurorIds.map((a) => a.userId)
candidateJurors = activeRoundJurorIds.length > 0
? await prisma.user.findMany({
where: {
id: { in: activeRoundJurorIds },
roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE',
},
select: { id: true, name: true, email: true, maxAssignments: true },
})
: []
}
// Filter out all excluded jurors (current assignments, COI, historical)
const eligible = candidateJurors.filter((j) => !excludedUserIds.has(j.id))
if (eligible.length === 0) return null
// ── Score eligible jurors: prefer those with incomplete evaluations ──────
const eligibleIds = eligible.map((j) => j.id)
// Get assignment counts and evaluation completion for eligible jurors in this round
const roundAssignments = await prisma.assignment.findMany({
where: { roundId, userId: { in: eligibleIds } },
select: { userId: true, evaluation: { select: { status: true } } },
})
// Build per-juror stats: total assignments, completed evaluations
const jurorStats = new Map<string, { total: number; completed: number }>()
for (const a of roundAssignments) {
const stats = jurorStats.get(a.userId) || { total: 0, completed: 0 }
stats.total++
if (a.evaluation?.status === 'SUBMITTED' || a.evaluation?.status === 'LOCKED') {
stats.completed++
}
jurorStats.set(a.userId, stats)
}
// Rank jurors: under cap, then prefer those still working (completed < total)
const ranked = eligible
.map((j) => {
const stats = jurorStats.get(j.id) || { total: 0, completed: 0 }
const effectiveMax = j.maxAssignments ?? maxAssignmentsPerJuror
const hasIncomplete = stats.completed < stats.total
return { ...j, currentCount: stats.total, effectiveMax, hasIncomplete }
})
.filter((j) => j.currentCount < j.effectiveMax)
.sort((a, b) => {
// 1. Prefer jurors with incomplete evaluations (still active)
if (a.hasIncomplete !== b.hasIncomplete) return a.hasIncomplete ? -1 : 1
// 2. Then fewest current assignments (load balancing)
return a.currentCount - b.currentCount
})
if (ranked.length === 0) return null
const replacement = ranked[0]
// Delete old assignment and create replacement atomically.
// Cascade deletes COI record and any draft evaluation.
const newAssignment = await prisma.$transaction(async (tx) => {
await tx.assignment.delete({ where: { id: params.assignmentId } })
return tx.assignment.create({
data: {
userId: replacement.id,
projectId,
roundId,
juryGroupId: assignment.juryGroupId ?? assignment.round.juryGroupId ?? undefined,
isRequired: assignment.isRequired,
method: 'MANUAL',
},
})
})
// Notify the replacement juror (COI-specific notification)
await createNotification({
userId: replacement.id,
type: NotificationTypes.COI_REASSIGNED,
title: 'Project Reassigned to You (COI)',
message: `The project "${assignment.project.title}" has been reassigned to you for ${assignment.round.name} because the previously assigned juror declared a conflict of interest.`,
linkUrl: `/jury/competitions`,
linkLabel: 'View Assignment',
metadata: { projectId, projectName: assignment.project.title, roundName: assignment.round.name },
})
// Notify admins of the reassignment
await notifyAdmins({
type: NotificationTypes.EVALUATION_MILESTONE,
title: 'COI Auto-Reassignment',
message: `Project "${assignment.project.title}" was reassigned from ${assignment.user.name || assignment.user.email} to ${replacement.name || replacement.email} due to conflict of interest.`,
linkUrl: `/admin/rounds/${roundId}`,
linkLabel: 'View Round',
metadata: {
projectId,
oldJurorId: assignment.userId,
newJurorId: replacement.id,
reason: 'COI',
},
})
// Audit
if (params.auditUserId) {
await logAudit({
prisma,
userId: params.auditUserId,
action: 'COI_REASSIGNMENT',
entityType: 'Assignment',
entityId: newAssignment.id,
detailsJson: {
oldAssignmentId: params.assignmentId,
oldJurorId: assignment.userId,
newJurorId: replacement.id,
projectId,
roundId,
},
ipAddress: params.auditIp,
userAgent: params.auditUserAgent,
})
}
return {
newJurorId: replacement.id,
newJurorName: replacement.name || replacement.email,
newAssignmentId: newAssignment.id,
}
}
/** Evaluation statuses that are safe to move (not yet finalized). */
const MOVABLE_EVAL_STATUSES = ['NOT_STARTED', 'DRAFT'] as const
export async function reassignDroppedJurorAssignments(params: {
roundId: string
droppedJurorId: string
auditUserId?: string
auditIp?: string
auditUserAgent?: string
}) {
const round = await prisma.round.findUnique({
where: { id: params.roundId },
select: { id: true, name: true, configJson: true, juryGroupId: true },
})
if (!round) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' })
}
const droppedJuror = await prisma.user.findUnique({
where: { id: params.droppedJurorId },
select: { id: true, name: true, email: true },
})
if (!droppedJuror) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Juror not found' })
}
const config = (round.configJson ?? {}) as Record<string, unknown>
const fallbackCap =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
// Only pick assignments with no evaluation or evaluation still in draft/not-started.
// Explicitly enumerate movable statuses so SUBMITTED and LOCKED are never touched.
const assignmentsToMove = await prisma.assignment.findMany({
where: {
roundId: params.roundId,
userId: params.droppedJurorId,
OR: [
{ evaluation: null },
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
],
},
select: {
id: true,
projectId: true,
juryGroupId: true,
isRequired: true,
createdAt: true,
project: { select: { title: true } },
},
orderBy: { createdAt: 'asc' },
})
if (assignmentsToMove.length === 0) {
return {
movedCount: 0,
failedCount: 0,
failedProjects: [] as string[],
reassignedTo: {} as Record<string, number>,
}
}
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
if (round.juryGroupId) {
const members = await prisma.juryGroupMember.findMany({
where: { juryGroupId: round.juryGroupId },
include: {
user: {
select: {
id: true,
name: true,
email: true,
maxAssignments: true,
status: true,
},
},
},
})
candidateJurors = members
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== params.droppedJurorId)
.map((m) => m.user)
} else {
// No jury group configured — scope to jurors already assigned to this round
// (the de facto jury pool). This prevents assigning to random JURY_MEMBER
// accounts that aren't part of this round's jury.
const roundJurorIds = await prisma.assignment.findMany({
where: { roundId: params.roundId },
select: { userId: true },
distinct: ['userId'],
})
const activeRoundJurorIds = roundJurorIds
.map((a) => a.userId)
.filter((id) => id !== params.droppedJurorId)
candidateJurors = activeRoundJurorIds.length > 0
? await prisma.user.findMany({
where: {
id: { in: activeRoundJurorIds },
roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE',
},
select: { id: true, name: true, email: true, maxAssignments: true },
})
: []
}
if (candidateJurors.length === 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active replacement jurors available' })
}
const candidateIds = candidateJurors.map((j) => j.id)
const existingAssignments = await prisma.assignment.findMany({
where: { roundId: params.roundId },
select: { userId: true, projectId: true },
})
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
const currentLoads = new Map<string, number>()
for (const a of existingAssignments) {
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
}
const coiRecords = await prisma.conflictOfInterest.findMany({
where: {
roundId: params.roundId,
hasConflict: true,
userId: { in: candidateIds },
},
select: { userId: true, projectId: true },
})
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
const caps = new Map<string, number>()
for (const juror of candidateJurors) {
caps.set(juror.id, juror.maxAssignments ?? fallbackCap)
}
const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j]))
const plannedMoves: {
assignmentId: string
projectId: string
projectTitle: string
newJurorId: string
juryGroupId: string | null
isRequired: boolean
}[] = []
const failedProjects: string[] = []
for (const assignment of assignmentsToMove) {
const eligible = candidateIds
.filter((jurorId) => !alreadyAssigned.has(`${jurorId}:${assignment.projectId}`))
.filter((jurorId) => !coiPairs.has(`${jurorId}:${assignment.projectId}`))
.filter((jurorId) => (currentLoads.get(jurorId) ?? 0) < (caps.get(jurorId) ?? fallbackCap))
.sort((a, b) => {
const loadDiff = (currentLoads.get(a) ?? 0) - (currentLoads.get(b) ?? 0)
if (loadDiff !== 0) return loadDiff
return a.localeCompare(b)
})
if (eligible.length === 0) {
failedProjects.push(assignment.project.title)
continue
}
const selectedJurorId = eligible[0]
plannedMoves.push({
assignmentId: assignment.id,
projectId: assignment.projectId,
projectTitle: assignment.project.title,
newJurorId: selectedJurorId,
juryGroupId: assignment.juryGroupId ?? round.juryGroupId,
isRequired: assignment.isRequired,
})
alreadyAssigned.add(`${selectedJurorId}:${assignment.projectId}`)
currentLoads.set(selectedJurorId, (currentLoads.get(selectedJurorId) ?? 0) + 1)
}
// Execute moves inside a transaction with per-move TOCTOU guard.
// Uses conditional deleteMany so a concurrent evaluation submission
// (which sets status to SUBMITTED) causes the delete to return count=0
// instead of cascade-destroying the submitted evaluation.
const actualMoves: typeof plannedMoves = []
const skippedProjects: string[] = []
if (plannedMoves.length > 0) {
await prisma.$transaction(async (tx) => {
for (const move of plannedMoves) {
// Guard: only delete if the assignment still belongs to the dropped juror
// AND its evaluation (if any) is still in a movable state.
// If a juror submitted between our read and now, count will be 0.
const deleted = await tx.assignment.deleteMany({
where: {
id: move.assignmentId,
userId: params.droppedJurorId,
OR: [
{ evaluation: null },
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
],
},
})
if (deleted.count === 0) {
// Assignment was already moved, deleted, or its evaluation was submitted
skippedProjects.push(move.projectTitle)
continue
}
await tx.assignment.create({
data: {
roundId: params.roundId,
projectId: move.projectId,
userId: move.newJurorId,
juryGroupId: move.juryGroupId ?? undefined,
isRequired: move.isRequired,
method: 'MANUAL',
createdBy: params.auditUserId ?? undefined,
},
})
actualMoves.push(move)
}
})
}
// Add skipped projects to the failed list
failedProjects.push(...skippedProjects)
const reassignedTo: Record<string, number> = {}
for (const move of actualMoves) {
reassignedTo[move.newJurorId] = (reassignedTo[move.newJurorId] ?? 0) + 1
}
if (actualMoves.length > 0) {
// Build per-juror project name lists for proper emails
const destProjectNames: Record<string, string[]> = {}
for (const move of actualMoves) {
if (!destProjectNames[move.newJurorId]) destProjectNames[move.newJurorId] = []
destProjectNames[move.newJurorId].push(move.projectTitle)
}
const droppedName = droppedJuror.name || droppedJuror.email
// Fetch round deadline for email
const roundFull = await prisma.round.findUnique({
where: { id: params.roundId },
select: { windowCloseAt: true },
})
const deadline = roundFull?.windowCloseAt
? new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short', timeZone: 'Europe/Paris' }).format(roundFull.windowCloseAt)
: undefined
for (const [jurorId, projectNames] of Object.entries(destProjectNames)) {
const count = projectNames.length
await createNotification({
userId: jurorId,
type: NotificationTypes.DROPOUT_REASSIGNED,
title: count === 1 ? 'Project Reassigned to You' : `${count} Projects Reassigned to You`,
message: count === 1
? `The project "${projectNames[0]}" has been reassigned to you because ${droppedName} is no longer available in ${round.name}.`
: `${count} projects have been reassigned to you because ${droppedName} is no longer available in ${round.name}: ${projectNames.join(', ')}.`,
linkUrl: `/jury/competitions`,
linkLabel: 'View Assignments',
metadata: { roundId: round.id, roundName: round.name, projectNames, droppedJurorName: droppedName, deadline, reason: 'juror_drop_reshuffle' },
})
}
const topReceivers = Object.entries(reassignedTo)
.map(([jurorId, count]) => {
const juror = candidateMeta.get(jurorId)
return `${juror?.name || juror?.email || jurorId} (${count})`
})
.join(', ')
await notifyAdmins({
type: NotificationTypes.EVALUATION_MILESTONE,
title: 'Juror Dropout Reshuffle',
message: `Reassigned ${actualMoves.length} project(s) from ${droppedName} to: ${topReceivers}. ${failedProjects.length > 0 ? `${failedProjects.length} project(s) could not be reassigned.` : 'All projects were reassigned successfully.'}`,
linkUrl: `/admin/rounds/${round.id}`,
linkLabel: 'View Round',
metadata: {
roundId: round.id,
droppedJurorId: droppedJuror.id,
movedCount: actualMoves.length,
failedCount: failedProjects.length,
topReceivers,
},
})
}
// Remove the dropped juror from the jury group so they can't be re-assigned
// in future assignment runs for this round's competition.
let removedFromGroup = false
if (round.juryGroupId) {
const deleted = await prisma.juryGroupMember.deleteMany({
where: {
juryGroupId: round.juryGroupId,
userId: params.droppedJurorId,
},
})
removedFromGroup = deleted.count > 0
}
if (params.auditUserId) {
// Build per-project move detail for audit trail
const moveDetails = actualMoves.map((move) => {
const juror = candidateMeta.get(move.newJurorId)
return {
projectId: move.projectId,
projectTitle: move.projectTitle,
newJurorId: move.newJurorId,
newJurorName: juror?.name || juror?.email || move.newJurorId,
}
})
await logAudit({
prisma,
userId: params.auditUserId,
action: 'JUROR_DROPOUT_RESHUFFLE',
entityType: 'Round',
entityId: round.id,
detailsJson: {
droppedJurorId: droppedJuror.id,
droppedJurorName: droppedJuror.name || droppedJuror.email,
movedCount: actualMoves.length,
failedCount: failedProjects.length,
failedProjects,
skippedProjects,
reassignedTo,
removedFromGroup,
moves: moveDetails,
},
ipAddress: params.auditIp,
userAgent: params.auditUserAgent,
})
}
return {
movedCount: actualMoves.length,
failedCount: failedProjects.length,
failedProjects,
reassignedTo,
}
}

View File

@@ -104,8 +104,8 @@ export async function sendNotification(
// Overall success if at least one channel succeeded
result.success =
(result.channels.email?.success ?? true) ||
(result.channels.whatsapp?.success ?? true)
(result.channels.email?.success ?? false) ||
(result.channels.whatsapp?.success ?? false)
return result
}

View File

@@ -941,11 +941,95 @@ export async function batchCheckRequirementsAndTransition(
actorId: string,
prisma: PrismaClient | any,
): Promise<{ transitionedCount: number; projectIds: string[] }> {
const transitioned: string[] = []
if (projectIds.length === 0) return { transitionedCount: 0, projectIds: [] }
// Pre-load all requirements for this round in batch (avoids per-project queries)
const [requirements, round] = await Promise.all([
prisma.fileRequirement.findMany({
where: { roundId, isRequired: true },
select: { id: true },
}),
prisma.round.findUnique({
where: { id: roundId },
select: { submissionWindowId: true },
}),
])
let submissionRequirements: Array<{ id: string }> = []
if (round?.submissionWindowId) {
submissionRequirements = await prisma.submissionFileRequirement.findMany({
where: { submissionWindowId: round.submissionWindowId, required: true },
select: { id: true },
})
}
// If no requirements at all, nothing to check
if (requirements.length === 0 && submissionRequirements.length === 0) {
return { transitionedCount: 0, projectIds: [] }
}
// Pre-load all project files and current states in batch
type FileRow = { projectId: string; requirementId: string | null; submissionFileRequirementId: string | null }
type StateRow = { projectId: string; state: string }
const [allFiles, allStates] = await Promise.all([
prisma.projectFile.findMany({
where: {
projectId: { in: projectIds },
roundId,
},
select: { projectId: true, requirementId: true, submissionFileRequirementId: true },
}) as Promise<FileRow[]>,
prisma.projectRoundState.findMany({
where: { roundId, projectId: { in: projectIds } },
select: { projectId: true, state: true },
}) as Promise<StateRow[]>,
])
// Build per-project lookup maps
const filesByProject = new Map<string, FileRow[]>()
for (const f of allFiles) {
const arr = filesByProject.get(f.projectId) ?? []
arr.push(f)
filesByProject.set(f.projectId, arr)
}
const stateByProject = new Map(allStates.map((s) => [s.projectId, s.state]))
// Determine which projects have all requirements met and are eligible for transition
const eligibleStates = ['PENDING', 'IN_PROGRESS']
const toTransition: string[] = []
for (const projectId of projectIds) {
const result = await checkRequirementsAndTransition(projectId, roundId, actorId, prisma)
if (result.transitioned) {
const currentState = stateByProject.get(projectId)
if (!currentState || !eligibleStates.includes(currentState)) continue
const files = filesByProject.get(projectId) ?? []
// Check legacy requirements
if (requirements.length > 0) {
const fulfilledIds = new Set(files.map((f) => f.requirementId).filter(Boolean))
if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) continue
}
// Check submission requirements
if (submissionRequirements.length > 0) {
const fulfilledSubIds = new Set(files.map((f) => f.submissionFileRequirementId).filter(Boolean))
if (!submissionRequirements.every((r: { id: string }) => fulfilledSubIds.has(r.id))) continue
}
toTransition.push(projectId)
}
// Transition eligible projects (still uses transitionProject for state machine correctness)
const transitioned: string[] = []
for (const projectId of toTransition) {
const currentState = stateByProject.get(projectId)
// If PENDING, first move to IN_PROGRESS
if (currentState === 'PENDING') {
await triggerInProgressOnActivity(projectId, roundId, actorId, prisma)
}
const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
if (result.success) {
transitioned.push(projectId)
}
}

View File

@@ -170,14 +170,29 @@ export async function processRoundClose(
}
}
// ── Phase 1: Compute target states and proposed outcomes in-memory ──
type StateUpdate = {
prsId: string
projectId: string
currentState: string
targetState: ProjectRoundStateValue
proposedOutcome: ProjectRoundStateValue
needsTransition: boolean
}
const updates: StateUpdate[] = []
for (const prs of projectStates) {
// Skip already-terminal states
if (isTerminalState(prs.state)) {
// Set proposed outcome to match current state for display
if (!prs.proposedOutcome) {
await prisma.projectRoundState.update({
where: { id: prs.id },
data: { proposedOutcome: prs.state },
updates.push({
prsId: prs.id,
projectId: prs.projectId,
currentState: prs.state,
targetState: prs.state as ProjectRoundStateValue,
proposedOutcome: prs.state as ProjectRoundStateValue,
needsTransition: false,
})
}
processed++
@@ -190,7 +205,6 @@ export async function processRoundClose(
switch (round.roundType as RoundType) {
case 'INTAKE':
case 'SUBMISSION': {
// Projects with activity → COMPLETED, purely PENDING → REJECTED
if (prs.state === 'PENDING') {
targetState = 'REJECTED' as ProjectRoundStateValue
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
@@ -202,7 +216,6 @@ export async function processRoundClose(
}
case 'EVALUATION': {
// Use ranking scores to determine pass/reject
const hasEvals = prs.project.assignments.some((a: { isCompleted: boolean }) => a.isCompleted)
const shouldPass = evaluationPassSet?.has(prs.projectId) ?? false
if (prs.state === 'IN_PROGRESS' || (prs.state === 'PENDING' && hasEvals)) {
@@ -218,7 +231,6 @@ export async function processRoundClose(
}
case 'FILTERING': {
// Use FilteringResult to determine outcome for each project
const fr = prs.project.filteringResults?.[0] as { outcome: string; finalOutcome: string | null } | undefined
const effectiveOutcome = fr?.finalOutcome || fr?.outcome
const filterPassed = effectiveOutcome !== 'FILTERED_OUT'
@@ -229,12 +241,10 @@ export async function processRoundClose(
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
} else if (prs.state === 'PENDING') {
// PENDING projects in filtering: check FilteringResult
if (fr) {
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
} else {
// No filtering result at all → reject
targetState = 'REJECTED' as ProjectRoundStateValue
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
}
@@ -243,7 +253,6 @@ export async function processRoundClose(
}
case 'MENTORING': {
// Projects already PASSED (pass-through) stay PASSED
if (prs.state === 'PASSED') {
proposedOutcome = 'PASSED' as ProjectRoundStateValue
} else if (prs.state === 'IN_PROGRESS') {
@@ -252,7 +261,6 @@ export async function processRoundClose(
} else if (prs.state === 'COMPLETED') {
proposedOutcome = 'PASSED' as ProjectRoundStateValue
} else if (prs.state === 'PENDING') {
// Pending = never requested mentoring, pass through
proposedOutcome = 'PASSED' as ProjectRoundStateValue
targetState = 'COMPLETED' as ProjectRoundStateValue
}
@@ -260,7 +268,6 @@ export async function processRoundClose(
}
case 'LIVE_FINAL': {
// All presented projects → COMPLETED
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = 'PASSED' as ProjectRoundStateValue
@@ -271,7 +278,6 @@ export async function processRoundClose(
}
case 'DELIBERATION': {
// All voted projects → COMPLETED
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = 'PASSED' as ProjectRoundStateValue
@@ -282,28 +288,113 @@ export async function processRoundClose(
}
}
// Transition project if needed (admin override for non-standard paths)
if (targetState !== prs.state && !isTerminalState(prs.state)) {
// Need to handle multi-step transitions
if (prs.state === 'PENDING' && targetState === 'COMPLETED') {
await transitionProject(prs.projectId, roundId, 'IN_PROGRESS' as ProjectRoundStateValue, actorId, prisma, { adminOverride: true })
await transitionProject(prs.projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma, { adminOverride: true })
} else if (prs.state === 'PENDING' && targetState === 'REJECTED') {
await transitionProject(prs.projectId, roundId, targetState, actorId, prisma, { adminOverride: true })
} else {
await transitionProject(prs.projectId, roundId, targetState, actorId, prisma, { adminOverride: true })
}
}
// Set proposed outcome
await prisma.projectRoundState.update({
where: { id: prs.id },
data: { proposedOutcome },
const needsTransition = targetState !== prs.state && !isTerminalState(prs.state)
updates.push({
prsId: prs.id,
projectId: prs.projectId,
currentState: prs.state,
targetState,
proposedOutcome,
needsTransition,
})
processed++
}
// ── Phase 2: Batch state transitions in a single transaction ──
const transitionUpdates = updates.filter((u) => u.needsTransition)
const now = new Date()
if (transitionUpdates.length > 0) {
await prisma.$transaction(async (tx: any) => {
// Step through intermediate states in bulk
// PENDING → IN_PROGRESS for projects going to COMPLETED
const pendingToCompleted = transitionUpdates.filter(
(u) => u.currentState === 'PENDING' && u.targetState === ('COMPLETED' as string),
)
if (pendingToCompleted.length > 0) {
await tx.projectRoundState.updateMany({
where: { id: { in: pendingToCompleted.map((u) => u.prsId) } },
data: { state: 'IN_PROGRESS' },
})
}
// IN_PROGRESS → COMPLETED (includes those just moved from PENDING)
const toCompleted = transitionUpdates.filter(
(u) => u.targetState === ('COMPLETED' as string) &&
(u.currentState === 'PENDING' || u.currentState === 'IN_PROGRESS'),
)
if (toCompleted.length > 0) {
await tx.projectRoundState.updateMany({
where: { id: { in: toCompleted.map((u) => u.prsId) } },
data: { state: 'COMPLETED' },
})
}
// PENDING → REJECTED (direct terminal transition)
const pendingToRejected = transitionUpdates.filter(
(u) => u.currentState === 'PENDING' && u.targetState === ('REJECTED' as string),
)
if (pendingToRejected.length > 0) {
await tx.projectRoundState.updateMany({
where: { id: { in: pendingToRejected.map((u) => u.prsId) } },
data: { state: 'REJECTED', exitedAt: now },
})
}
// Other single-step transitions (e.g., IN_PROGRESS → COMPLETED already handled)
const otherTransitions = transitionUpdates.filter(
(u) =>
!(u.currentState === 'PENDING' && (u.targetState === ('COMPLETED' as string) || u.targetState === ('REJECTED' as string))) &&
!(u.currentState === 'IN_PROGRESS' && u.targetState === ('COMPLETED' as string)),
)
if (otherTransitions.length > 0) {
await tx.projectRoundState.updateMany({
where: { id: { in: otherTransitions.map((u) => u.prsId) } },
data: { state: otherTransitions[0].targetState },
})
}
// Batch create audit logs for all transitions
await tx.decisionAuditLog.createMany({
data: transitionUpdates.map((u) => ({
eventType: 'project_round.transitioned',
entityType: 'ProjectRoundState',
entityId: u.prsId,
actorId,
detailsJson: {
projectId: u.projectId,
roundId,
previousState: u.currentState,
newState: u.targetState,
batchProcessed: true,
} as Prisma.InputJsonValue,
snapshotJson: {
timestamp: now.toISOString(),
emittedBy: 'round-finalization',
},
})),
})
})
}
// ── Phase 3: Batch update proposed outcomes ──
const outcomeUpdates = updates.filter((u) => u.proposedOutcome)
// Group by proposed outcome for efficient updateMany calls
const outcomeGroups = new Map<ProjectRoundStateValue, string[]>()
for (const u of outcomeUpdates) {
const ids = outcomeGroups.get(u.proposedOutcome) ?? []
ids.push(u.prsId)
outcomeGroups.set(u.proposedOutcome, ids)
}
await Promise.all(
Array.from(outcomeGroups.entries()).map(([outcome, ids]) =>
prisma.projectRoundState.updateMany({
where: { id: { in: ids } },
data: { proposedOutcome: outcome },
}),
),
)
return { processed }
}
@@ -701,6 +792,8 @@ export async function confirmFinalization(
const inviteTokenMap = new Map<string, string>() // userId → token
const expiryMs = await getInviteExpiryMs(prisma)
// Collect all passwordless users needing invite tokens, then batch update
const tokenUpdates: Array<{ userId: string; token: string }> = []
for (const prs of finalizedStates) {
if (prs.state !== 'PASSED') continue
const users = prs.project.teamMembers.length > 0
@@ -710,17 +803,26 @@ export async function confirmFinalization(
if (user && !user.passwordHash && !inviteTokenMap.has(user.id)) {
const token = generateInviteToken()
inviteTokenMap.set(user.id, token)
await prisma.user.update({
where: { id: user.id },
data: {
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
status: 'INVITED',
},
})
tokenUpdates.push({ userId: user.id, token })
}
}
}
// Batch update all invite tokens concurrently
if (tokenUpdates.length > 0) {
const tokenExpiry = new Date(Date.now() + expiryMs)
await Promise.all(
tokenUpdates.map((t) =>
prisma.user.update({
where: { id: t.userId },
data: {
inviteToken: t.token,
inviteTokenExpiresAt: tokenExpiry,
status: 'INVITED',
},
}),
),
)
}
const advancedUserIds = new Set<string>()
const rejectedUserIds = new Set<string>()
@@ -801,7 +903,7 @@ export async function confirmFinalization(
// Create in-app notifications
if (advancedUserIds.size > 0) {
void createBulkNotifications({
createBulkNotifications({
userIds: [...advancedUserIds],
type: 'project_advanced',
title: 'Your project has advanced!',
@@ -810,11 +912,13 @@ export async function confirmFinalization(
linkLabel: 'View Dashboard',
icon: 'Trophy',
priority: 'high',
}).catch((err) => {
console.error('[Finalization] createBulkNotifications (advanced) failed:', err)
})
}
if (rejectedUserIds.size > 0) {
void createBulkNotifications({
createBulkNotifications({
userIds: [...rejectedUserIds],
type: 'project_rejected',
title: 'Competition Update',
@@ -823,6 +927,8 @@ export async function confirmFinalization(
linkLabel: 'View Dashboard',
icon: 'Info',
priority: 'normal',
}).catch((err) => {
console.error('[Finalization] createBulkNotifications (rejected) failed:', err)
})
}
} catch (emailError) {