From b85a9b9a7be48cfd80cf21ea9a91ab1ca04b60e5 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 7 Mar 2026 16:18:24 +0100 Subject: [PATCH] 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 --- .../migration.sql | 20 + prisma/schema.prisma | 6 + src/app/api/auth/check-email/route.ts | 11 + src/server/routers/_app.ts | 6 - src/server/routers/analytics.ts | 2 +- src/server/routers/applicant.ts | 9 +- src/server/routers/application.ts | 3 +- src/server/routers/assignment.ts | 571 +---------------- src/server/routers/assignmentPolicy.ts | 8 +- src/server/routers/audit.ts | 3 +- src/server/routers/award.ts | 16 - src/server/routers/cohort.ts | 308 ---------- src/server/routers/decision.ts | 356 ----------- src/server/routers/deliberation.ts | 8 + src/server/routers/evaluation.ts | 19 +- src/server/routers/export.ts | 3 +- src/server/routers/file.ts | 15 +- src/server/routers/filtering.ts | 3 +- src/server/routers/learningResource.ts | 3 +- src/server/routers/live-voting.ts | 4 +- src/server/routers/mentor.ts | 11 +- src/server/routers/message.ts | 16 +- src/server/routers/project.ts | 14 +- src/server/routers/ranking.ts | 4 +- src/server/routers/settings.ts | 12 +- src/server/routers/specialAward.ts | 12 +- src/server/routers/webhook.ts | 23 +- src/server/services/juror-reassignment.ts | 578 ++++++++++++++++++ src/server/services/notification.ts | 4 +- src/server/services/round-engine.ts | 90 ++- src/server/services/round-finalization.ts | 188 ++++-- src/server/trpc.ts | 61 +- 32 files changed, 1032 insertions(+), 1355 deletions(-) create mode 100644 prisma/migrations/20260307131449_add_compound_indexes/migration.sql delete mode 100644 src/server/routers/award.ts delete mode 100644 src/server/routers/cohort.ts delete mode 100644 src/server/routers/decision.ts create mode 100644 src/server/services/juror-reassignment.ts diff --git a/prisma/migrations/20260307131449_add_compound_indexes/migration.sql b/prisma/migrations/20260307131449_add_compound_indexes/migration.sql new file mode 100644 index 0000000..313a33f --- /dev/null +++ b/prisma/migrations/20260307131449_add_compound_indexes/migration.sql @@ -0,0 +1,20 @@ +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'APPLICANT'; + +-- CreateIndex +CREATE INDEX "Assignment_roundId_isCompleted_idx" ON "Assignment"("roundId", "isCompleted"); + +-- CreateIndex +CREATE INDEX "ConflictOfInterest_projectId_idx" ON "ConflictOfInterest"("projectId"); + +-- CreateIndex +CREATE INDEX "ConflictOfInterest_userId_hasConflict_idx" ON "ConflictOfInterest"("userId", "hasConflict"); + +-- CreateIndex +CREATE INDEX "NotificationLog_type_status_idx" ON "NotificationLog"("type", "status"); + +-- CreateIndex +CREATE INDEX "ProjectRoundState_roundId_state_idx" ON "ProjectRoundState"("roundId", "state"); + +-- CreateIndex +CREATE INDEX "RankingSnapshot_roundId_createdAt_idx" ON "RankingSnapshot"("roundId", "createdAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8a8231e..db82af3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -768,6 +768,7 @@ model Assignment { @@index([isCompleted]) @@index([projectId, userId]) @@index([juryGroupId]) + @@index([roundId, isCompleted]) } model Evaluation { @@ -964,6 +965,7 @@ model NotificationLog { @@index([projectId]) @@index([batchId]) @@index([email]) + @@index([type, status]) } // ============================================================================= @@ -1494,6 +1496,7 @@ model RankingSnapshot { @@index([roundId]) @@index([triggeredById]) @@index([createdAt]) + @@index([roundId, createdAt]) } // Tracks progress of long-running AI tagging jobs @@ -1740,6 +1743,8 @@ model ConflictOfInterest { @@index([userId]) @@index([hasConflict]) + @@index([projectId]) + @@index([userId, hasConflict]) } // ============================================================================= @@ -2283,6 +2288,7 @@ model ProjectRoundState { @@index([projectId]) @@index([roundId]) @@index([state]) + @@index([roundId, state]) } model AdvancementRule { diff --git a/src/app/api/auth/check-email/route.ts b/src/app/api/auth/check-email/route.ts index 051c386..9c62a02 100644 --- a/src/app/api/auth/check-email/route.ts +++ b/src/app/api/auth/check-email/route.ts @@ -1,13 +1,24 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' +import { checkRateLimit } from '@/lib/rate-limit' /** * Pre-check whether an email exists before sending a magic link. * This is a closed platform (no self-registration) so revealing * email existence is acceptable and helps users who mistype. + * Rate-limited to 10 requests per 15 minutes per IP. */ export async function POST(req: NextRequest) { try { + const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown' + const rateResult = checkRateLimit(`check-email:${ip}`, 10, 15 * 60 * 1000) + if (!rateResult.success) { + return NextResponse.json( + { exists: false, error: 'Too many requests' }, + { status: 429 }, + ) + } + const { email } = await req.json() if (!email || typeof email !== 'string') { return NextResponse.json({ exists: false }, { status: 400 }) diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts index 9b7194a..c396514 100644 --- a/src/server/routers/_app.ts +++ b/src/server/routers/_app.ts @@ -36,10 +36,7 @@ import { projectPoolRouter } from './project-pool' import { wizardTemplateRouter } from './wizard-template' import { dashboardRouter } from './dashboard' // Legacy round routers (kept) -import { cohortRouter } from './cohort' import { liveRouter } from './live' -import { decisionRouter } from './decision' -import { awardRouter } from './award' // Competition architecture routers (Phase 0+1) import { competitionRouter } from './competition' import { roundRouter } from './round' @@ -94,10 +91,7 @@ export const appRouter = router({ wizardTemplate: wizardTemplateRouter, dashboard: dashboardRouter, // Legacy round routers (kept) - cohort: cohortRouter, live: liveRouter, - decision: decisionRouter, - award: awardRouter, // Competition architecture routers (Phase 0+1) competition: competitionRouter, round: roundRouter, diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts index 1969d28..498246c 100644 --- a/src/server/routers/analytics.ts +++ b/src/server/routers/analytics.ts @@ -2259,7 +2259,7 @@ export const analyticsRouter = router({ const config = validateRoundConfig('LIVE_FINAL', round.configJson) as LiveFinalConfig observerScoreVisibility = config.observerScoreVisibility ?? 'after_completion' } - } catch { /* use default */ } + } catch (err) { console.error('Failed to parse LIVE_FINAL round config for observer score visibility:', err) /* use default */ } const session = await ctx.prisma.liveVotingSession.findUnique({ where: { roundId: input.roundId }, diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index ecc9422..9c66894 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -1008,7 +1008,8 @@ export const applicantRouter = router({ errorMsg: error instanceof Error ? error.message : 'Unknown error', }, }) - } catch { + } catch (err) { + console.error('Failed to log failed team invitation notification:', err) // Never fail on notification logging } @@ -1043,7 +1044,8 @@ export const applicantRouter = router({ status: 'SENT', }, }) - } catch { + } catch (err) { + console.error('Failed to log sent team invitation notification:', err) // Never fail on notification logging } @@ -1061,7 +1063,8 @@ export const applicantRouter = router({ projectName: project.title, }, }) - } catch { + } catch (err) { + console.error('Failed to create in-app team invitation notification:', err) // Never fail invitation flow on in-app notification issues } diff --git a/src/server/routers/application.ts b/src/server/routers/application.ts index 4c61516..99e8e13 100644 --- a/src/server/routers/application.ts +++ b/src/server/routers/application.ts @@ -842,7 +842,8 @@ export const applicationRouter = router({ ipAddress: ctx.ip, userAgent: ctx.userAgent, }) - } catch { + } catch (err) { + console.error('Failed to write audit log for draft submission:', err) // Never throw on audit failure } diff --git a/src/server/routers/assignment.ts b/src/server/routers/assignment.ts index c9412c3..e26776d 100644 --- a/src/server/routers/assignment.ts +++ b/src/server/routers/assignment.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { TRPCError } from '@trpc/server' -import { router, protectedProcedure, adminProcedure, userHasRole } from '../trpc' +import { router, protectedProcedure, adminProcedure, userHasRole, withAIRateLimit } from '../trpc' import { getUserAvatarUrl } from '../utils/avatar-url' import { generateAIAssignments, @@ -16,577 +16,13 @@ import { NotificationTypes, } from '../services/in-app-notification' import { logAudit } from '@/server/utils/audit' +import { reassignAfterCOI, reassignDroppedJurorAssignments } from '../services/juror-reassignment' -/** - * 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 - 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 | 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>) { - 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() - 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, - } -} +export { reassignAfterCOI, reassignDroppedJurorAssignments } /** Evaluation statuses that are safe to move (not yet finalized). */ const MOVABLE_EVAL_STATUSES = ['NOT_STARTED', 'DRAFT'] as const -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 - 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, - } - } - - 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() - 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() - 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 = {} - 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 = {} - 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, - } -} - async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) { try { await prisma.assignmentJob.update({ @@ -1894,6 +1330,7 @@ export const assignmentRouter = router({ * Start an AI assignment job (background processing) */ startAIAssignmentJob: adminProcedure + .use(withAIRateLimit) .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const existingJob = await ctx.prisma.assignmentJob.findFirst({ diff --git a/src/server/routers/assignmentPolicy.ts b/src/server/routers/assignmentPolicy.ts index 1c286b8..425b392 100644 --- a/src/server/routers/assignmentPolicy.ts +++ b/src/server/routers/assignmentPolicy.ts @@ -41,8 +41,8 @@ export const assignmentPolicyRouter = router({ role: member.role, policy, } - } catch { - // Member may not be linked to this round's jury group + } catch (err) { + console.error('[AssignmentPolicy] Failed to resolve member context:', err) return null } }), @@ -103,8 +103,8 @@ export const assignmentPolicyRouter = router({ remaining: policy.remainingCapacity, }) } - } catch { - // Skip members that can't be resolved + } catch (err) { + console.error('[AssignmentPolicy] Failed to evaluate policy for member:', err) } } diff --git a/src/server/routers/audit.ts b/src/server/routers/audit.ts index 8d3c541..603bbd3 100644 --- a/src/server/routers/audit.ts +++ b/src/server/routers/audit.ts @@ -330,7 +330,8 @@ export const auditRouter = router({ ipAddress: ctx.ip, userAgent: ctx.userAgent, }) - } catch { + } catch (err) { + console.error('Failed to write audit log for retention config update:', err) // Never throw on audit failure } diff --git a/src/server/routers/award.ts b/src/server/routers/award.ts deleted file mode 100644 index e06e6c3..0000000 --- a/src/server/routers/award.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { router } from '../trpc' - -// NOTE: All award procedures have been temporarily disabled because they depended on -// deleted models: Pipeline, Track (AWARD kind), SpecialAward linked via Track. -// This router will need complete reimplementation with the new Competition/Round/Award architecture. -// The SpecialAward model still exists and is linked directly to Competition (competitionId FK). - -export const awardRouter = router({ - // TODO: Reimplement award procedures with new Competition/Round architecture - // Procedures to reimplement: - // - createAwardTrack → createAward (link SpecialAward to Competition directly) - // - configureGovernance → configureAwardGovernance - // - routeProjects → setAwardEligibility - // - finalizeWinners → finalizeAwardWinner - // - getTrackProjects → getAwardProjects -}) diff --git a/src/server/routers/cohort.ts b/src/server/routers/cohort.ts deleted file mode 100644 index eee14c8..0000000 --- a/src/server/routers/cohort.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { z } from 'zod' -import { TRPCError } from '@trpc/server' -import { router, protectedProcedure, adminProcedure } from '../trpc' -import { logAudit } from '@/server/utils/audit' - -export const cohortRouter = router({ - /** - * Create a new cohort within a round - */ - create: adminProcedure - .input( - z.object({ - roundId: z.string(), - name: z.string().min(1).max(255), - votingMode: z.enum(['simple', 'criteria', 'ranked']).default('simple'), - windowOpenAt: z.date().optional(), - windowCloseAt: z.date().optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - // Verify round exists - const round = await ctx.prisma.round.findUniqueOrThrow({ - where: { id: input.roundId }, - }) - - // Validate window dates - if (input.windowOpenAt && input.windowCloseAt) { - if (input.windowCloseAt <= input.windowOpenAt) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'Window close date must be after open date', - }) - } - } - - const cohort = await ctx.prisma.cohort.create({ - data: { - roundId: input.roundId, - name: input.name, - votingMode: input.votingMode, - windowOpenAt: input.windowOpenAt ?? null, - windowCloseAt: input.windowCloseAt ?? null, - }, - }) - - // Audit outside transaction so failures don't roll back the create - await logAudit({ - prisma: ctx.prisma, - userId: ctx.user.id, - action: 'CREATE', - entityType: 'Cohort', - entityId: cohort.id, - detailsJson: { - roundId: input.roundId, - name: input.name, - votingMode: input.votingMode, - }, - ipAddress: ctx.ip, - userAgent: ctx.userAgent, - }) - - return cohort - }), - - /** - * Assign projects to a cohort - */ - assignProjects: adminProcedure - .input( - z.object({ - cohortId: z.string(), - projectIds: z.array(z.string()).min(1).max(200), - }) - ) - .mutation(async ({ ctx, input }) => { - // Verify cohort exists - const cohort = await ctx.prisma.cohort.findUniqueOrThrow({ - where: { id: input.cohortId }, - }) - - if (cohort.isOpen) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: 'Cannot modify projects while voting is open', - }) - } - - // Get current max sortOrder - const maxOrder = await ctx.prisma.cohortProject.aggregate({ - where: { cohortId: input.cohortId }, - _max: { sortOrder: true }, - }) - let nextOrder = (maxOrder._max.sortOrder ?? -1) + 1 - - // Create cohort project entries (skip duplicates) - const created = await ctx.prisma.cohortProject.createMany({ - data: input.projectIds.map((projectId) => ({ - cohortId: input.cohortId, - projectId, - sortOrder: nextOrder++, - })), - skipDuplicates: true, - }) - - await logAudit({ - prisma: ctx.prisma, - userId: ctx.user.id, - action: 'COHORT_PROJECTS_ASSIGNED', - entityType: 'Cohort', - entityId: input.cohortId, - detailsJson: { - projectCount: created.count, - requested: input.projectIds.length, - }, - ipAddress: ctx.ip, - userAgent: ctx.userAgent, - }) - - return { assigned: created.count, requested: input.projectIds.length } - }), - - /** - * Open voting for a cohort - */ - openVoting: adminProcedure - .input( - z.object({ - cohortId: z.string(), - durationMinutes: z.number().int().min(1).max(1440).optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const cohort = await ctx.prisma.cohort.findUniqueOrThrow({ - where: { id: input.cohortId }, - include: { _count: { select: { projects: true } } }, - }) - - if (cohort.isOpen) { - throw new TRPCError({ - code: 'CONFLICT', - message: 'Voting is already open for this cohort', - }) - } - - if (cohort._count.projects === 0) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: 'Cohort must have at least one project before opening voting', - }) - } - - const now = new Date() - const closeAt = input.durationMinutes - ? new Date(now.getTime() + input.durationMinutes * 60 * 1000) - : cohort.windowCloseAt - - const updated = await ctx.prisma.cohort.update({ - where: { id: input.cohortId }, - data: { - isOpen: true, - windowOpenAt: now, - windowCloseAt: closeAt, - }, - }) - - // Audit outside transaction so failures don't roll back the voting open - await logAudit({ - prisma: ctx.prisma, - userId: ctx.user.id, - action: 'COHORT_VOTING_OPENED', - entityType: 'Cohort', - entityId: input.cohortId, - detailsJson: { - openedAt: now.toISOString(), - closesAt: closeAt?.toISOString() ?? null, - projectCount: cohort._count.projects, - }, - ipAddress: ctx.ip, - userAgent: ctx.userAgent, - }) - - return updated - }), - - /** - * Close voting for a cohort - */ - closeVoting: adminProcedure - .input(z.object({ cohortId: z.string() })) - .mutation(async ({ ctx, input }) => { - const cohort = await ctx.prisma.cohort.findUniqueOrThrow({ - where: { id: input.cohortId }, - }) - - if (!cohort.isOpen) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: 'Voting is not currently open for this cohort', - }) - } - - const now = new Date() - - const updated = await ctx.prisma.cohort.update({ - where: { id: input.cohortId }, - data: { - isOpen: false, - windowCloseAt: now, - }, - }) - - // Audit outside transaction so failures don't roll back the voting close - await logAudit({ - prisma: ctx.prisma, - userId: ctx.user.id, - action: 'COHORT_VOTING_CLOSED', - entityType: 'Cohort', - entityId: input.cohortId, - detailsJson: { - closedAt: now.toISOString(), - wasOpenSince: cohort.windowOpenAt?.toISOString(), - }, - ipAddress: ctx.ip, - userAgent: ctx.userAgent, - }) - - return updated - }), - - /** - * List cohorts for a round - */ - list: protectedProcedure - .input(z.object({ roundId: z.string() })) - .query(async ({ ctx, input }) => { - return ctx.prisma.cohort.findMany({ - where: { roundId: input.roundId }, - orderBy: { createdAt: 'asc' }, - include: { - _count: { select: { projects: true } }, - }, - }) - }), - - /** - * Get cohort with projects and vote summary - */ - get: protectedProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - const cohort = await ctx.prisma.cohort.findUniqueOrThrow({ - where: { id: input.id }, - include: { - round: { - select: { - id: true, - name: true, - competition: { select: { id: true, name: true } }, - }, - }, - projects: { - orderBy: { sortOrder: 'asc' }, - include: { - project: { - select: { - id: true, - title: true, - teamName: true, - tags: true, - description: true, - }, - }, - }, - }, - }, - }) - - // Get vote counts per project in the cohort's round session - const projectIds = cohort.projects.map((p) => p.projectId) - const voteSummary = - projectIds.length > 0 - ? await ctx.prisma.liveVote.groupBy({ - by: ['projectId'], - where: { - projectId: { in: projectIds }, - session: { roundId: cohort.round.id }, - }, - _count: true, - _avg: { score: true }, - }) - : [] - - const voteMap = new Map( - voteSummary.map((v) => [ - v.projectId, - { voteCount: v._count, avgScore: v._avg?.score ?? 0 }, - ]) - ) - - return { - ...cohort, - projects: cohort.projects.map((cp) => ({ - ...cp, - votes: voteMap.get(cp.projectId) ?? { voteCount: 0, avgScore: 0 }, - })), - } - }), -}) diff --git a/src/server/routers/decision.ts b/src/server/routers/decision.ts deleted file mode 100644 index dddbe52..0000000 --- a/src/server/routers/decision.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { z } from 'zod' -import { TRPCError } from '@trpc/server' -import { Prisma, FilteringOutcome, ProjectRoundStateValue } from '@prisma/client' -import { router, protectedProcedure, adminProcedure } from '../trpc' -import { logAudit } from '@/server/utils/audit' - -export const decisionRouter = router({ - /** - * Override a project's stage state or filtering result - */ - override: adminProcedure - .input( - z.object({ - entityType: z.enum([ - 'ProjectRoundState', - 'FilteringResult', - 'AwardEligibility', - ]), - entityId: z.string(), - newValue: z.record(z.unknown()), - reasonCode: z.enum([ - 'DATA_CORRECTION', - 'POLICY_EXCEPTION', - 'JURY_CONFLICT', - 'SPONSOR_DECISION', - 'ADMIN_DISCRETION', - ]), - reasonText: z.string().max(2000).optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - let previousValue: Record = {} - - // Fetch current value based on entity type - switch (input.entityType) { - case 'ProjectRoundState': { - const prs = await ctx.prisma.projectRoundState.findUniqueOrThrow({ - where: { id: input.entityId }, - }) - previousValue = { - state: prs.state, - metadataJson: prs.metadataJson, - } - - // Validate the new state - const newState = input.newValue.state as string | undefined - if ( - newState && - !['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'ROUTED', 'COMPLETED', 'WITHDRAWN'].includes(newState) - ) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `Invalid state: ${newState}`, - }) - } - - await ctx.prisma.$transaction(async (tx) => { - await tx.projectRoundState.update({ - where: { id: input.entityId }, - data: { - state: newState ? (newState as ProjectRoundStateValue) : prs.state, - metadataJson: { - ...(prs.metadataJson as Record ?? {}), - lastOverride: { - by: ctx.user.id, - at: new Date().toISOString(), - reason: input.reasonCode, - }, - } as Prisma.InputJsonValue, - }, - }) - - await tx.overrideAction.create({ - data: { - entityType: input.entityType, - entityId: input.entityId, - previousValue: previousValue as Prisma.InputJsonValue, - newValueJson: input.newValue as Prisma.InputJsonValue, - reasonCode: input.reasonCode, - reasonText: input.reasonText ?? null, - actorId: ctx.user.id, - }, - }) - - await tx.decisionAuditLog.create({ - data: { - eventType: 'override.applied', - entityType: input.entityType, - entityId: input.entityId, - actorId: ctx.user.id, - detailsJson: { - previousValue, - newValue: input.newValue, - reasonCode: input.reasonCode, - reasonText: input.reasonText, - } as Prisma.InputJsonValue, - snapshotJson: previousValue as Prisma.InputJsonValue, - }, - }) - }) - - // Audit outside transaction so failures don't roll back the override - await logAudit({ - prisma: ctx.prisma, - userId: ctx.user.id, - action: 'DECISION_OVERRIDE', - entityType: input.entityType, - entityId: input.entityId, - detailsJson: { - reasonCode: input.reasonCode, - reasonText: input.reasonText, - previousState: previousValue.state, - newState: input.newValue.state, - }, - ipAddress: ctx.ip, - userAgent: ctx.userAgent, - }) - break - } - - case 'FilteringResult': { - const fr = await ctx.prisma.filteringResult.findUniqueOrThrow({ - where: { id: input.entityId }, - }) - previousValue = { - outcome: fr.outcome, - aiScreeningJson: fr.aiScreeningJson, - } - - const newOutcome = input.newValue.outcome as string | undefined - - await ctx.prisma.$transaction(async (tx) => { - if (newOutcome) { - await tx.filteringResult.update({ - where: { id: input.entityId }, - data: { finalOutcome: newOutcome as FilteringOutcome }, - }) - } - - await tx.overrideAction.create({ - data: { - entityType: input.entityType, - entityId: input.entityId, - previousValue: previousValue as Prisma.InputJsonValue, - newValueJson: input.newValue as Prisma.InputJsonValue, - reasonCode: input.reasonCode, - reasonText: input.reasonText ?? null, - actorId: ctx.user.id, - }, - }) - - await tx.decisionAuditLog.create({ - data: { - eventType: 'override.applied', - entityType: input.entityType, - entityId: input.entityId, - actorId: ctx.user.id, - detailsJson: { - previousValue, - newValue: input.newValue, - reasonCode: input.reasonCode, - } as Prisma.InputJsonValue, - }, - }) - }) - - // Audit outside transaction so failures don't roll back the override - await logAudit({ - prisma: ctx.prisma, - userId: ctx.user.id, - action: 'DECISION_OVERRIDE', - entityType: input.entityType, - entityId: input.entityId, - detailsJson: { - reasonCode: input.reasonCode, - previousOutcome: (previousValue as Record).outcome, - newOutcome, - }, - ipAddress: ctx.ip, - userAgent: ctx.userAgent, - }) - break - } - - case 'AwardEligibility': { - const ae = await ctx.prisma.awardEligibility.findUniqueOrThrow({ - where: { id: input.entityId }, - }) - previousValue = { - eligible: ae.eligible, - method: ae.method, - } - - const newEligible = input.newValue.eligible as boolean | undefined - - await ctx.prisma.$transaction(async (tx) => { - if (newEligible !== undefined) { - await tx.awardEligibility.update({ - where: { id: input.entityId }, - data: { - eligible: newEligible, - method: 'MANUAL', - overriddenBy: ctx.user.id, - overriddenAt: new Date(), - }, - }) - } - - await tx.overrideAction.create({ - data: { - entityType: input.entityType, - entityId: input.entityId, - previousValue: previousValue as Prisma.InputJsonValue, - newValueJson: input.newValue as Prisma.InputJsonValue, - reasonCode: input.reasonCode, - reasonText: input.reasonText ?? null, - actorId: ctx.user.id, - }, - }) - - await tx.decisionAuditLog.create({ - data: { - eventType: 'override.applied', - entityType: input.entityType, - entityId: input.entityId, - actorId: ctx.user.id, - detailsJson: { - previousValue, - newValue: input.newValue, - reasonCode: input.reasonCode, - } as Prisma.InputJsonValue, - }, - }) - }) - - // Audit outside transaction so failures don't roll back the override - await logAudit({ - prisma: ctx.prisma, - userId: ctx.user.id, - action: 'DECISION_OVERRIDE', - entityType: input.entityType, - entityId: input.entityId, - detailsJson: { - reasonCode: input.reasonCode, - previousEligible: previousValue.eligible, - newEligible, - }, - ipAddress: ctx.ip, - userAgent: ctx.userAgent, - }) - break - } - } - - return { success: true, entityType: input.entityType, entityId: input.entityId } - }), - - /** - * Get the full decision audit timeline for an entity - */ - auditTimeline: protectedProcedure - .input( - z.object({ - entityType: z.string(), - entityId: z.string(), - }) - ) - .query(async ({ ctx, input }) => { - const [decisionLogs, overrideActions] = await Promise.all([ - ctx.prisma.decisionAuditLog.findMany({ - where: { - entityType: input.entityType, - entityId: input.entityId, - }, - orderBy: { createdAt: 'desc' }, - }), - ctx.prisma.overrideAction.findMany({ - where: { - entityType: input.entityType, - entityId: input.entityId, - }, - orderBy: { createdAt: 'desc' }, - }), - ]) - - // Merge and sort by timestamp - const timeline = [ - ...decisionLogs.map((dl) => ({ - type: 'decision' as const, - id: dl.id, - eventType: dl.eventType, - actorId: dl.actorId, - details: dl.detailsJson, - snapshot: dl.snapshotJson, - createdAt: dl.createdAt, - })), - ...overrideActions.map((oa) => ({ - type: 'override' as const, - id: oa.id, - eventType: `override.${oa.reasonCode}`, - actorId: oa.actorId, - details: { - previousValue: oa.previousValue, - newValue: oa.newValueJson, - reasonCode: oa.reasonCode, - reasonText: oa.reasonText, - }, - snapshot: null, - createdAt: oa.createdAt, - })), - ].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) - - return { entityType: input.entityType, entityId: input.entityId, timeline } - }), - - /** - * Get override actions (paginated, admin only) - */ - getOverrides: adminProcedure - .input( - z.object({ - entityType: z.string().optional(), - reasonCode: z - .enum([ - 'DATA_CORRECTION', - 'POLICY_EXCEPTION', - 'JURY_CONFLICT', - 'SPONSOR_DECISION', - 'ADMIN_DISCRETION', - ]) - .optional(), - cursor: z.string().optional(), - limit: z.number().int().min(1).max(100).default(50), - }) - ) - .query(async ({ ctx, input }) => { - const where: Prisma.OverrideActionWhereInput = {} - if (input.entityType) where.entityType = input.entityType - if (input.reasonCode) where.reasonCode = input.reasonCode - - const items = await ctx.prisma.overrideAction.findMany({ - where, - take: input.limit + 1, - cursor: input.cursor ? { id: input.cursor } : undefined, - orderBy: { createdAt: 'desc' }, - }) - - let nextCursor: string | undefined - if (items.length > input.limit) { - const next = items.pop() - nextCursor = next?.id - } - - return { items, nextCursor } - }), -}) diff --git a/src/server/routers/deliberation.ts b/src/server/routers/deliberation.ts index 03c611c..f37c967 100644 --- a/src/server/routers/deliberation.ts +++ b/src/server/routers/deliberation.ts @@ -118,6 +118,14 @@ export const deliberationRouter = router({ }) ) .mutation(async ({ ctx, input }) => { + // Enforce that jury members can only vote as themselves + if (input.juryMemberId !== ctx.user.id) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You can only submit votes as yourself', + }) + } + const vote = await submitVote(input, ctx.prisma) await logAudit({ diff --git a/src/server/routers/evaluation.ts b/src/server/routers/evaluation.ts index e18e531..828e119 100644 --- a/src/server/routers/evaluation.ts +++ b/src/server/routers/evaluation.ts @@ -1,9 +1,9 @@ import { z } from 'zod' import { TRPCError } from '@trpc/server' -import { router, protectedProcedure, adminProcedure, juryProcedure, userHasRole } from '../trpc' +import { router, protectedProcedure, adminProcedure, juryProcedure, userHasRole, withAIRateLimit } from '../trpc' import { logAudit } from '@/server/utils/audit' import { notifyAdmins, NotificationTypes } from '../services/in-app-notification' -import { reassignAfterCOI } from './assignment' +import { reassignAfterCOI } from '../services/juror-reassignment' import { sendManualReminders } from '../services/evaluation-reminders' import { generateSummary } from '@/server/services/ai-evaluation-summary' import { quickRank as aiQuickRank } from '../services/ai-ranking' @@ -94,7 +94,8 @@ async function triggerAutoRankIfComplete( message: `Auto-ranking failed for round (ID: ${roundId}). Please trigger manually.`, priority: 'high', }) - } catch { + } catch (err) { + console.error('Failed to send AI ranking failure notification to admins:', err) // Even notification failure must not propagate } console.error('[auto-rank] triggerAutoRankIfComplete failed:', error) @@ -377,7 +378,9 @@ export const evaluationRouter = router({ ]) // Auto-trigger ranking if all assignments complete (fire-and-forget, never awaited) - void triggerAutoRankIfComplete(evaluation.assignment.roundId, ctx.prisma, ctx.user.id) + triggerAutoRankIfComplete(evaluation.assignment.roundId, ctx.prisma, ctx.user.id).catch((err) => { + console.error('[Evaluation] triggerAutoRankIfComplete failed:', err) + }) // Auto-transition: mark project IN_PROGRESS and check if all evaluations are done const projectId = evaluation.assignment.projectId @@ -794,6 +797,7 @@ export const evaluationRouter = router({ * Generate an AI-powered evaluation summary for a project (admin only) */ generateSummary: adminProcedure + .use(withAIRateLimit) .input( z.object({ projectId: z.string(), @@ -834,6 +838,7 @@ export const evaluationRouter = router({ * Generate summaries for all projects in a stage with submitted evaluations (admin only) */ generateBulkSummaries: adminProcedure + .use(withAIRateLimit) .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { // Find all projects with at least 1 submitted evaluation in this stage @@ -1239,7 +1244,8 @@ export const evaluationRouter = router({ ipAddress: ctx.ip, userAgent: ctx.userAgent, }) - } catch { + } catch (err) { + console.error('Failed to write audit log for discussion comment creation:', err) // Never throw on audit failure } @@ -1276,7 +1282,8 @@ export const evaluationRouter = router({ ipAddress: ctx.ip, userAgent: ctx.userAgent, }) - } catch { + } catch (err) { + console.error('Failed to write audit log for discussion close:', err) // Never throw on audit failure } diff --git a/src/server/routers/export.ts b/src/server/routers/export.ts index 1d368a7..54d83bc 100644 --- a/src/server/routers/export.ts +++ b/src/server/routers/export.ts @@ -679,7 +679,8 @@ export const exportRouter = router({ ipAddress: ctx.ip, userAgent: ctx.userAgent, }) - } catch { + } catch (err) { + console.error('Failed to write audit log for round export:', err) // Never throw on audit failure } diff --git a/src/server/routers/file.ts b/src/server/routers/file.ts index 374575c..98b02cf 100644 --- a/src/server/routers/file.ts +++ b/src/server/routers/file.ts @@ -902,7 +902,9 @@ export const fileRouter = router({ entityId: requirement.id, detailsJson: { name: input.name, roundId: input.roundId }, }) - } catch {} + } catch (err) { + console.error('[File] Audit log failed:', err) + } return requirement }), @@ -938,7 +940,9 @@ export const fileRouter = router({ entityId: id, detailsJson: data, }) - } catch {} + } catch (err) { + console.error('[File] Audit log failed:', err) + } return requirement }), @@ -961,7 +965,9 @@ export const fileRouter = router({ entityType: 'FileRequirement', entityId: input.id, }) - } catch {} + } catch (err) { + console.error('[File] Audit log failed:', err) + } return { success: true } }), @@ -1594,7 +1600,8 @@ export const fileRouter = router({ try { await client.statObject(bucket, objectKey) results[objectKey] = true - } catch { + } catch (err) { + console.error('Failed to stat MinIO object during existence check:', err) results[objectKey] = false } }) diff --git a/src/server/routers/filtering.ts b/src/server/routers/filtering.ts index 75d614a..1b87862 100644 --- a/src/server/routers/filtering.ts +++ b/src/server/routers/filtering.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import { TRPCError } from '@trpc/server' import { Prisma, PrismaClient } from '@prisma/client' -import { router, adminProcedure } from '../trpc' +import { router, adminProcedure, withAIRateLimit } from '../trpc' import { executeFilteringRules, type ProgressCallback, type AwardCriteriaInput, type AwardMatchResult } from '../services/ai-filtering' import { sanitizeUserInput } from '../services/ai-prompt-guard' import { logAudit } from '../utils/audit' @@ -652,6 +652,7 @@ export const filteringRouter = router({ * Start a filtering job (runs in background) */ startJob: adminProcedure + .use(withAIRateLimit) .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const existingJob = await ctx.prisma.filteringJob.findFirst({ diff --git a/src/server/routers/learningResource.ts b/src/server/routers/learningResource.ts index 8ecfbf6..5ce546a 100644 --- a/src/server/routers/learningResource.ts +++ b/src/server/routers/learningResource.ts @@ -40,7 +40,8 @@ async function canUserAccessResource( const parsed = accessJson as unknown[] if (!Array.isArray(parsed) || parsed.length === 0) return true rules = parsed as AccessRule[] - } catch { + } catch (err) { + console.error('Failed to parse learning resource access rules JSON:', err) return true } diff --git a/src/server/routers/live-voting.ts b/src/server/routers/live-voting.ts index 2efd50d..adda44a 100644 --- a/src/server/routers/live-voting.ts +++ b/src/server/routers/live-voting.ts @@ -739,8 +739,8 @@ export const liveVotingRouter = router({ ipAddress: ctx.ip, userAgent: ctx.userAgent, }) - } catch { - // Audit log errors should never break the operation + } catch (err) { + console.error('[LiveVoting] Audit log failed:', err) } return session diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index 605f216..9cfca8d 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -538,7 +538,8 @@ export const mentorRouter = router({ } else { failed++ } - } catch { + } catch (err) { + console.error('Failed to send mentor assignment notifications:', err) failed++ } } @@ -866,8 +867,8 @@ export const mentorRouter = router({ ipAddress: ctx.ip, userAgent: ctx.userAgent, }) - } catch { - // Audit log errors should never break the operation + } catch (err) { + console.error('[Mentor] Audit log failed:', err) } return note @@ -1081,8 +1082,8 @@ export const mentorRouter = router({ ipAddress: ctx.ip, userAgent: ctx.userAgent, }) - } catch { - // Audit log errors should never break the operation + } catch (err) { + console.error('[Mentor] Audit log failed:', err) } return { completion, allRequiredDone } diff --git a/src/server/routers/message.ts b/src/server/routers/message.ts index 0da3290..f41ece5 100644 --- a/src/server/routers/message.ts +++ b/src/server/routers/message.ts @@ -145,7 +145,9 @@ export const messageRouter = router({ scheduled: isScheduled, }, }) - } catch {} + } catch (err) { + console.error('[Message] Audit log failed:', err) + } return { ...message, @@ -334,7 +336,9 @@ export const messageRouter = router({ entityId: template.id, detailsJson: { name: input.name, category: input.category }, }) - } catch {} + } catch (err) { + console.error('[Message] Audit log failed:', err) + } return template }), @@ -378,7 +382,9 @@ export const messageRouter = router({ entityId: id, detailsJson: { updatedFields: Object.keys(data) }, }) - } catch {} + } catch (err) { + console.error('[Message] Audit log failed:', err) + } return template }), @@ -402,7 +408,9 @@ export const messageRouter = router({ entityType: 'MessageTemplate', entityId: input.id, }) - } catch {} + } catch (err) { + console.error('[Message] Audit log failed:', err) + } return template }), diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index 8575750..b156379 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -505,7 +505,8 @@ export const projectRouter = router({ include: { tag: { select: { id: true, name: true, category: true, color: true } } }, orderBy: { confidence: 'desc' }, }) - } catch { + } catch (err) { + console.error('Failed to fetch project tags:', err) // ProjectTag table may not exist yet } @@ -746,12 +747,13 @@ export const projectRouter = router({ status: 'SENT', }, }) - } catch { + } catch (err) { + console.error('Failed to log invitation notification for project team member:', err) // Never fail on notification logging } - } catch { + } catch (err) { // Email sending failure should not break project creation - console.error(`Failed to send invite to ${member.email}`) + console.error(`Failed to send invite to ${member.email}:`, err) } } } @@ -1568,9 +1570,9 @@ export const projectRouter = router({ const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com' const inviteUrl = `${baseUrl}/accept-invite?token=${token}` await sendInvitationEmail(email.toLowerCase(), name, inviteUrl, 'APPLICANT') - } catch { + } catch (err) { // Email sending failure should not block member creation - console.error(`Failed to send invite to ${email}`) + console.error(`Failed to send invite to ${email}:`, err) } } diff --git a/src/server/routers/ranking.ts b/src/server/routers/ranking.ts index d68c32a..ef61fce 100644 --- a/src/server/routers/ranking.ts +++ b/src/server/routers/ranking.ts @@ -1,4 +1,4 @@ -import { router, adminProcedure } from '../trpc' +import { router, adminProcedure, withAIRateLimit } from '../trpc' import { z } from 'zod' import { TRPCError } from '@trpc/server' import type { Prisma } from '@prisma/client' @@ -69,6 +69,7 @@ export const rankingRouter = router({ * RANK-05, RANK-06, RANK-08. */ executeRanking: adminProcedure + .use(withAIRateLimit) .input( z.object({ roundId: z.string(), @@ -260,6 +261,7 @@ export const rankingRouter = router({ * Reads ranking criteria from round configJson and executes quickRank. */ triggerAutoRank: adminProcedure + .use(withAIRateLimit) .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const { roundId } = input diff --git a/src/server/routers/settings.ts b/src/server/routers/settings.ts index 292652c..5c6e80b 100644 --- a/src/server/routers/settings.ts +++ b/src/server/routers/settings.ts @@ -329,7 +329,8 @@ export const settingsRouter = router({ const success = await sendTestEmail(input.testEmail) return { success, error: success ? null : 'Failed to send test email' } } catch (error) { - return { success: false, error: 'Email configuration error' } + const message = error instanceof Error ? error.message : 'Unknown error' + return { success: false, error: `Email configuration error: ${message}` } } }), @@ -642,7 +643,8 @@ export const settingsRouter = router({ ipAddress: ctx.ip, userAgent: ctx.userAgent, }) - } catch { + } catch (err) { + console.error('Failed to write audit log for digest settings update:', err) // Never throw on audit failure } @@ -701,7 +703,8 @@ export const settingsRouter = router({ ipAddress: ctx.ip, userAgent: ctx.userAgent, }) - } catch { + } catch (err) { + console.error('Failed to write audit log for analytics settings update:', err) // Never throw on audit failure } @@ -760,7 +763,8 @@ export const settingsRouter = router({ ipAddress: ctx.ip, userAgent: ctx.userAgent, }) - } catch { + } catch (err) { + console.error('Failed to write audit log for audit settings update:', err) // Never throw on audit failure } diff --git a/src/server/routers/specialAward.ts b/src/server/routers/specialAward.ts index a98a918..34bdd1b 100644 --- a/src/server/routers/specialAward.ts +++ b/src/server/routers/specialAward.ts @@ -365,11 +365,13 @@ export const specialAwardRouter = router({ }) // Fire and forget - process in background - void processEligibilityJob( + processEligibilityJob( input.awardId, input.includeSubmitted ?? false, ctx.user.id - ) + ).catch((err) => { + console.error('[SpecialAward] processEligibilityJob failed:', err) + }) return { started: true } }), @@ -913,12 +915,14 @@ export const specialAwardRouter = router({ }) // Fire and forget - process in background with round scoping - void processEligibilityJob( + processEligibilityJob( input.awardId, true, // include submitted ctx.user.id, input.roundId - ) + ).catch((err) => { + console.error('[SpecialAward] processEligibilityJob (round) failed:', err) + }) return { started: true } }), diff --git a/src/server/routers/webhook.ts b/src/server/routers/webhook.ts index ae3d96c..0b9b77d 100644 --- a/src/server/routers/webhook.ts +++ b/src/server/routers/webhook.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { TRPCError } from '@trpc/server' import { router, superAdminProcedure } from '../trpc' import { logAudit } from '@/server/utils/audit' import { @@ -108,7 +109,9 @@ export const webhookRouter = router({ entityId: webhook.id, detailsJson: { name: input.name, url: input.url, events: input.events }, }) - } catch {} + } catch (err) { + console.error('[Webhook] Audit log failed:', err) + } return webhook }), @@ -152,7 +155,9 @@ export const webhookRouter = router({ entityId: id, detailsJson: { updatedFields: Object.keys(data) }, }) - } catch {} + } catch (err) { + console.error('[Webhook] Audit log failed:', err) + } return webhook }), @@ -176,7 +181,9 @@ export const webhookRouter = router({ entityType: 'Webhook', entityId: input.id, }) - } catch {} + } catch (err) { + console.error('[Webhook] Audit log failed:', err) + } return { success: true } }), @@ -192,7 +199,7 @@ export const webhookRouter = router({ }) if (!webhook) { - throw new Error('Webhook not found') + throw new TRPCError({ code: 'NOT_FOUND', message: 'Webhook not found' }) } const testPayload = { @@ -231,7 +238,9 @@ export const webhookRouter = router({ entityId: input.id, detailsJson: { deliveryStatus: result?.status }, }) - } catch {} + } catch (err) { + console.error('[Webhook] Audit log failed:', err) + } return result }), @@ -292,7 +301,9 @@ export const webhookRouter = router({ entityType: 'Webhook', entityId: input.id, }) - } catch {} + } catch (err) { + console.error('[Webhook] Audit log failed:', err) + } return webhook }), diff --git a/src/server/services/juror-reassignment.ts b/src/server/services/juror-reassignment.ts new file mode 100644 index 0000000..97c20b1 --- /dev/null +++ b/src/server/services/juror-reassignment.ts @@ -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 + 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 | 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>) { + 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() + 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 + 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, + } + } + + 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() + 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() + 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 = {} + 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 = {} + 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, + } +} diff --git a/src/server/services/notification.ts b/src/server/services/notification.ts index 4985b55..2db08bc 100644 --- a/src/server/services/notification.ts +++ b/src/server/services/notification.ts @@ -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 } diff --git a/src/server/services/round-engine.ts b/src/server/services/round-engine.ts index 259388a..57377b9 100644 --- a/src/server/services/round-engine.ts +++ b/src/server/services/round-engine.ts @@ -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, + prisma.projectRoundState.findMany({ + where: { roundId, projectId: { in: projectIds } }, + select: { projectId: true, state: true }, + }) as Promise, + ]) + + // Build per-project lookup maps + const filesByProject = new Map() + 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) } } diff --git a/src/server/services/round-finalization.ts b/src/server/services/round-finalization.ts index 8eac7e5..bd2496e 100644 --- a/src/server/services/round-finalization.ts +++ b/src/server/services/round-finalization.ts @@ -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() + 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() // 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() const rejectedUserIds = new Set() @@ -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) { diff --git a/src/server/trpc.ts b/src/server/trpc.ts index 41e0915..94f7523 100644 --- a/src/server/trpc.ts +++ b/src/server/trpc.ts @@ -4,6 +4,7 @@ import { ZodError } from 'zod' import type { Prisma } from '@prisma/client' import type { Context } from './context' import type { UserRole } from '@prisma/client' +import { checkRateLimit } from '@/lib/rate-limit' /** * Initialize tRPC with context type and configuration @@ -298,16 +299,62 @@ const withErrorAudit = middleware(async ({ ctx, next, path, type, getRawInput }) } }) +// ============================================================================= +// Rate Limiting +// ============================================================================= + +/** + * General rate limiter: 100 mutations per minute per user. + * Applied to all authenticated mutation procedures. + */ +const withRateLimit = middleware(async ({ ctx, next, type }) => { + // Only rate-limit mutations — queries are read-only + if (type !== 'mutation') return next() + + const userId = ctx.session?.user?.id + if (!userId) return next() + + const result = checkRateLimit(`trpc:${userId}`, 100, 60_000) + if (!result.success) { + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + message: 'Too many requests. Please try again shortly.', + }) + } + + return next() +}) + +/** + * Strict rate limiter for AI-triggering procedures: 5 per hour per user. + * Protects against runaway OpenAI API costs. + */ +const withAIRateLimit = middleware(async ({ ctx, next }) => { + const userId = ctx.session?.user?.id + if (!userId) return next() + + const result = checkRateLimit(`ai:${userId}`, 5, 60 * 60 * 1000) + if (!result.success) { + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + message: 'AI operation rate limit exceeded. Maximum 5 per hour.', + }) + } + + return next() +}) + // ============================================================================= // Procedure Types // ============================================================================= /** * Protected procedure - requires authenticated user. - * Mutations auto-audited, errors (FORBIDDEN/UNAUTHORIZED/NOT_FOUND) tracked. + * Mutations rate-limited (100/min), auto-audited, errors tracked. */ export const protectedProcedure = t.procedure .use(isAuthenticated) + .use(withRateLimit) .use(withErrorAudit) .use(withMutationAudit) @@ -318,6 +365,7 @@ export const protectedProcedure = t.procedure */ export const adminProcedure = t.procedure .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN')) + .use(withRateLimit) .use(withErrorAudit) .use(withMutationAudit) @@ -335,6 +383,7 @@ export const superAdminProcedure = t.procedure */ export const juryProcedure = t.procedure .use(hasRole('JURY_MEMBER')) + .use(withRateLimit) .use(withErrorAudit) .use(withMutationAudit) @@ -344,6 +393,7 @@ export const juryProcedure = t.procedure */ export const mentorProcedure = t.procedure .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR')) + .use(withRateLimit) .use(withErrorAudit) .use(withMutationAudit) @@ -353,6 +403,7 @@ export const mentorProcedure = t.procedure */ export const observerProcedure = t.procedure .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER')) + .use(withRateLimit) .use(withErrorAudit) .use(withMutationAudit) @@ -362,6 +413,7 @@ export const observerProcedure = t.procedure */ export const awardMasterProcedure = t.procedure .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER')) + .use(withRateLimit) .use(withErrorAudit) .use(withMutationAudit) @@ -371,5 +423,12 @@ export const awardMasterProcedure = t.procedure */ export const audienceProcedure = t.procedure .use(isAuthenticated) + .use(withRateLimit) .use(withErrorAudit) .use(withMutationAudit) + +/** + * AI rate limit middleware - apply to individual AI-triggering procedures. + * 5 operations per hour per user to protect OpenAI API costs. + */ +export { withAIRateLimit }