fix: security hardening + performance refactoring (code review batch 1)
- IDOR fix: deliberation vote now verifies juryMemberId === ctx.user.id - Rate limiting: tRPC middleware (100/min), AI endpoints (5/hr), auth IP-based (10/15min) - 6 compound indexes added to Prisma schema - N+1 eliminated in processRoundClose (batch updateMany/createMany) - N+1 eliminated in batchCheckRequirementsAndTransition (3 batch queries) - Service extraction: juror-reassignment.ts (578 lines) - Dead code removed: award.ts, cohort.ts, decision.ts (680 lines) - 35 bare catch blocks replaced across 16 files - Fire-and-forget async calls fixed - Notification false positive bug fixed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||||
@@ -768,6 +768,7 @@ model Assignment {
|
|||||||
@@index([isCompleted])
|
@@index([isCompleted])
|
||||||
@@index([projectId, userId])
|
@@index([projectId, userId])
|
||||||
@@index([juryGroupId])
|
@@index([juryGroupId])
|
||||||
|
@@index([roundId, isCompleted])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Evaluation {
|
model Evaluation {
|
||||||
@@ -964,6 +965,7 @@ model NotificationLog {
|
|||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
@@index([batchId])
|
@@index([batchId])
|
||||||
@@index([email])
|
@@index([email])
|
||||||
|
@@index([type, status])
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -1494,6 +1496,7 @@ model RankingSnapshot {
|
|||||||
@@index([roundId])
|
@@index([roundId])
|
||||||
@@index([triggeredById])
|
@@index([triggeredById])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
@@index([roundId, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tracks progress of long-running AI tagging jobs
|
// Tracks progress of long-running AI tagging jobs
|
||||||
@@ -1740,6 +1743,8 @@ model ConflictOfInterest {
|
|||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([hasConflict])
|
@@index([hasConflict])
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([userId, hasConflict])
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -2283,6 +2288,7 @@ model ProjectRoundState {
|
|||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
@@index([roundId])
|
@@index([roundId])
|
||||||
@@index([state])
|
@@index([state])
|
||||||
|
@@index([roundId, state])
|
||||||
}
|
}
|
||||||
|
|
||||||
model AdvancementRule {
|
model AdvancementRule {
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { checkRateLimit } from '@/lib/rate-limit'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-check whether an email exists before sending a magic link.
|
* Pre-check whether an email exists before sending a magic link.
|
||||||
* This is a closed platform (no self-registration) so revealing
|
* This is a closed platform (no self-registration) so revealing
|
||||||
* email existence is acceptable and helps users who mistype.
|
* 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) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
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()
|
const { email } = await req.json()
|
||||||
if (!email || typeof email !== 'string') {
|
if (!email || typeof email !== 'string') {
|
||||||
return NextResponse.json({ exists: false }, { status: 400 })
|
return NextResponse.json({ exists: false }, { status: 400 })
|
||||||
|
|||||||
@@ -36,10 +36,7 @@ import { projectPoolRouter } from './project-pool'
|
|||||||
import { wizardTemplateRouter } from './wizard-template'
|
import { wizardTemplateRouter } from './wizard-template'
|
||||||
import { dashboardRouter } from './dashboard'
|
import { dashboardRouter } from './dashboard'
|
||||||
// Legacy round routers (kept)
|
// Legacy round routers (kept)
|
||||||
import { cohortRouter } from './cohort'
|
|
||||||
import { liveRouter } from './live'
|
import { liveRouter } from './live'
|
||||||
import { decisionRouter } from './decision'
|
|
||||||
import { awardRouter } from './award'
|
|
||||||
// Competition architecture routers (Phase 0+1)
|
// Competition architecture routers (Phase 0+1)
|
||||||
import { competitionRouter } from './competition'
|
import { competitionRouter } from './competition'
|
||||||
import { roundRouter } from './round'
|
import { roundRouter } from './round'
|
||||||
@@ -94,10 +91,7 @@ export const appRouter = router({
|
|||||||
wizardTemplate: wizardTemplateRouter,
|
wizardTemplate: wizardTemplateRouter,
|
||||||
dashboard: dashboardRouter,
|
dashboard: dashboardRouter,
|
||||||
// Legacy round routers (kept)
|
// Legacy round routers (kept)
|
||||||
cohort: cohortRouter,
|
|
||||||
live: liveRouter,
|
live: liveRouter,
|
||||||
decision: decisionRouter,
|
|
||||||
award: awardRouter,
|
|
||||||
// Competition architecture routers (Phase 0+1)
|
// Competition architecture routers (Phase 0+1)
|
||||||
competition: competitionRouter,
|
competition: competitionRouter,
|
||||||
round: roundRouter,
|
round: roundRouter,
|
||||||
|
|||||||
@@ -2259,7 +2259,7 @@ export const analyticsRouter = router({
|
|||||||
const config = validateRoundConfig('LIVE_FINAL', round.configJson) as LiveFinalConfig
|
const config = validateRoundConfig('LIVE_FINAL', round.configJson) as LiveFinalConfig
|
||||||
observerScoreVisibility = config.observerScoreVisibility ?? 'after_completion'
|
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({
|
const session = await ctx.prisma.liveVotingSession.findUnique({
|
||||||
where: { roundId: input.roundId },
|
where: { roundId: input.roundId },
|
||||||
|
|||||||
@@ -1008,7 +1008,8 @@ export const applicantRouter = router({
|
|||||||
errorMsg: error instanceof Error ? error.message : 'Unknown error',
|
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
|
// Never fail on notification logging
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1043,7 +1044,8 @@ export const applicantRouter = router({
|
|||||||
status: 'SENT',
|
status: 'SENT',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to log sent team invitation notification:', err)
|
||||||
// Never fail on notification logging
|
// Never fail on notification logging
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1061,7 +1063,8 @@ export const applicantRouter = router({
|
|||||||
projectName: project.title,
|
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
|
// Never fail invitation flow on in-app notification issues
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -842,7 +842,8 @@ export const applicationRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to write audit log for draft submission:', err)
|
||||||
// Never throw on audit failure
|
// Never throw on audit failure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
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 { getUserAvatarUrl } from '../utils/avatar-url'
|
||||||
import {
|
import {
|
||||||
generateAIAssignments,
|
generateAIAssignments,
|
||||||
@@ -16,577 +16,13 @@ import {
|
|||||||
NotificationTypes,
|
NotificationTypes,
|
||||||
} from '../services/in-app-notification'
|
} from '../services/in-app-notification'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
|
import { reassignAfterCOI, reassignDroppedJurorAssignments } from '../services/juror-reassignment'
|
||||||
|
|
||||||
/**
|
export { reassignAfterCOI, reassignDroppedJurorAssignments }
|
||||||
* Reassign a project after a juror declares COI.
|
|
||||||
* Deletes the old assignment, finds an eligible replacement juror, and creates a new assignment.
|
|
||||||
* Returns the new juror info or null if no eligible juror found.
|
|
||||||
*/
|
|
||||||
export async function reassignAfterCOI(params: {
|
|
||||||
assignmentId: string
|
|
||||||
auditUserId?: string
|
|
||||||
auditIp?: string
|
|
||||||
auditUserAgent?: string
|
|
||||||
}): Promise<{ newJurorId: string; newJurorName: string; newAssignmentId: string } | null> {
|
|
||||||
const assignment = await prisma.assignment.findUnique({
|
|
||||||
where: { id: params.assignmentId },
|
|
||||||
include: {
|
|
||||||
round: { select: { id: true, name: true, configJson: true, juryGroupId: true } },
|
|
||||||
project: { select: { id: true, title: true } },
|
|
||||||
user: { select: { id: true, name: true, email: true } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!assignment) return null
|
|
||||||
|
|
||||||
const { roundId, projectId } = assignment
|
|
||||||
const config = (assignment.round.configJson ?? {}) as Record<string, unknown>
|
|
||||||
const maxAssignmentsPerJuror =
|
|
||||||
(config.maxLoadPerJuror as number) ??
|
|
||||||
(config.maxAssignmentsPerJuror as number) ??
|
|
||||||
20
|
|
||||||
|
|
||||||
// ── Build exclusion set: jurors who must NEVER get this project ──────────
|
|
||||||
|
|
||||||
// 1. Currently assigned to this project in ANY round (not just current)
|
|
||||||
const allProjectAssignments = await prisma.assignment.findMany({
|
|
||||||
where: { projectId },
|
|
||||||
select: { userId: true },
|
|
||||||
})
|
|
||||||
const excludedUserIds = new Set(allProjectAssignments.map((a) => a.userId))
|
|
||||||
|
|
||||||
// 2. COI records for this project (any juror who declared conflict, ever)
|
|
||||||
const coiRecords = await prisma.conflictOfInterest.findMany({
|
|
||||||
where: { projectId, hasConflict: true },
|
|
||||||
select: { userId: true },
|
|
||||||
})
|
|
||||||
for (const c of coiRecords) excludedUserIds.add(c.userId)
|
|
||||||
|
|
||||||
// 3. Historical: jurors who previously had this project but were removed
|
|
||||||
// (via COI reassignment or admin transfer — tracked in audit logs)
|
|
||||||
const historicalAuditLogs = await prisma.decisionAuditLog.findMany({
|
|
||||||
where: {
|
|
||||||
eventType: { in: ['COI_REASSIGNMENT', 'ASSIGNMENT_TRANSFER'] },
|
|
||||||
detailsJson: { path: ['projectId'], equals: projectId },
|
|
||||||
},
|
|
||||||
select: { detailsJson: true },
|
|
||||||
})
|
|
||||||
for (const log of historicalAuditLogs) {
|
|
||||||
const details = log.detailsJson as Record<string, unknown> | null
|
|
||||||
if (!details) continue
|
|
||||||
// COI_REASSIGNMENT logs: oldJurorId had the project, newJurorId got it
|
|
||||||
if (details.oldJurorId) excludedUserIds.add(details.oldJurorId as string)
|
|
||||||
// ASSIGNMENT_TRANSFER logs: sourceJurorId lost the project
|
|
||||||
if (details.sourceJurorId) excludedUserIds.add(details.sourceJurorId as string)
|
|
||||||
// Transfer logs may have a moves array with per-project details
|
|
||||||
if (Array.isArray(details.moves)) {
|
|
||||||
for (const move of details.moves as Array<Record<string, unknown>>) {
|
|
||||||
if (move.projectId === projectId && move.newJurorId) {
|
|
||||||
// The juror who received via past transfer also had it
|
|
||||||
excludedUserIds.add(move.newJurorId as string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Find candidate jurors ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
|
|
||||||
|
|
||||||
if (assignment.round.juryGroupId) {
|
|
||||||
const members = await prisma.juryGroupMember.findMany({
|
|
||||||
where: { juryGroupId: assignment.round.juryGroupId },
|
|
||||||
include: { user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } } },
|
|
||||||
})
|
|
||||||
candidateJurors = members
|
|
||||||
.filter((m) => m.user.status === 'ACTIVE')
|
|
||||||
.map((m) => m.user)
|
|
||||||
} else {
|
|
||||||
// No jury group — scope to jurors already assigned to this round
|
|
||||||
const roundJurorIds = await prisma.assignment.findMany({
|
|
||||||
where: { roundId },
|
|
||||||
select: { userId: true },
|
|
||||||
distinct: ['userId'],
|
|
||||||
})
|
|
||||||
const activeRoundJurorIds = roundJurorIds.map((a) => a.userId)
|
|
||||||
|
|
||||||
candidateJurors = activeRoundJurorIds.length > 0
|
|
||||||
? await prisma.user.findMany({
|
|
||||||
where: {
|
|
||||||
id: { in: activeRoundJurorIds },
|
|
||||||
roles: { has: 'JURY_MEMBER' },
|
|
||||||
status: 'ACTIVE',
|
|
||||||
},
|
|
||||||
select: { id: true, name: true, email: true, maxAssignments: true },
|
|
||||||
})
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out all excluded jurors (current assignments, COI, historical)
|
|
||||||
const eligible = candidateJurors.filter((j) => !excludedUserIds.has(j.id))
|
|
||||||
|
|
||||||
if (eligible.length === 0) return null
|
|
||||||
|
|
||||||
// ── Score eligible jurors: prefer those with incomplete evaluations ──────
|
|
||||||
|
|
||||||
const eligibleIds = eligible.map((j) => j.id)
|
|
||||||
|
|
||||||
// Get assignment counts and evaluation completion for eligible jurors in this round
|
|
||||||
const roundAssignments = await prisma.assignment.findMany({
|
|
||||||
where: { roundId, userId: { in: eligibleIds } },
|
|
||||||
select: { userId: true, evaluation: { select: { status: true } } },
|
|
||||||
})
|
|
||||||
|
|
||||||
// Build per-juror stats: total assignments, completed evaluations
|
|
||||||
const jurorStats = new Map<string, { total: number; completed: number }>()
|
|
||||||
for (const a of roundAssignments) {
|
|
||||||
const stats = jurorStats.get(a.userId) || { total: 0, completed: 0 }
|
|
||||||
stats.total++
|
|
||||||
if (a.evaluation?.status === 'SUBMITTED' || a.evaluation?.status === 'LOCKED') {
|
|
||||||
stats.completed++
|
|
||||||
}
|
|
||||||
jurorStats.set(a.userId, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rank jurors: under cap, then prefer those still working (completed < total)
|
|
||||||
const ranked = eligible
|
|
||||||
.map((j) => {
|
|
||||||
const stats = jurorStats.get(j.id) || { total: 0, completed: 0 }
|
|
||||||
const effectiveMax = j.maxAssignments ?? maxAssignmentsPerJuror
|
|
||||||
const hasIncomplete = stats.completed < stats.total
|
|
||||||
return { ...j, currentCount: stats.total, effectiveMax, hasIncomplete }
|
|
||||||
})
|
|
||||||
.filter((j) => j.currentCount < j.effectiveMax)
|
|
||||||
.sort((a, b) => {
|
|
||||||
// 1. Prefer jurors with incomplete evaluations (still active)
|
|
||||||
if (a.hasIncomplete !== b.hasIncomplete) return a.hasIncomplete ? -1 : 1
|
|
||||||
// 2. Then fewest current assignments (load balancing)
|
|
||||||
return a.currentCount - b.currentCount
|
|
||||||
})
|
|
||||||
|
|
||||||
if (ranked.length === 0) return null
|
|
||||||
|
|
||||||
const replacement = ranked[0]
|
|
||||||
|
|
||||||
// Delete old assignment and create replacement atomically.
|
|
||||||
// Cascade deletes COI record and any draft evaluation.
|
|
||||||
const newAssignment = await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.assignment.delete({ where: { id: params.assignmentId } })
|
|
||||||
return tx.assignment.create({
|
|
||||||
data: {
|
|
||||||
userId: replacement.id,
|
|
||||||
projectId,
|
|
||||||
roundId,
|
|
||||||
juryGroupId: assignment.juryGroupId ?? assignment.round.juryGroupId ?? undefined,
|
|
||||||
isRequired: assignment.isRequired,
|
|
||||||
method: 'MANUAL',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Notify the replacement juror (COI-specific notification)
|
|
||||||
await createNotification({
|
|
||||||
userId: replacement.id,
|
|
||||||
type: NotificationTypes.COI_REASSIGNED,
|
|
||||||
title: 'Project Reassigned to You (COI)',
|
|
||||||
message: `The project "${assignment.project.title}" has been reassigned to you for ${assignment.round.name} because the previously assigned juror declared a conflict of interest.`,
|
|
||||||
linkUrl: `/jury/competitions`,
|
|
||||||
linkLabel: 'View Assignment',
|
|
||||||
metadata: { projectId, projectName: assignment.project.title, roundName: assignment.round.name },
|
|
||||||
})
|
|
||||||
|
|
||||||
// Notify admins of the reassignment
|
|
||||||
await notifyAdmins({
|
|
||||||
type: NotificationTypes.EVALUATION_MILESTONE,
|
|
||||||
title: 'COI Auto-Reassignment',
|
|
||||||
message: `Project "${assignment.project.title}" was reassigned from ${assignment.user.name || assignment.user.email} to ${replacement.name || replacement.email} due to conflict of interest.`,
|
|
||||||
linkUrl: `/admin/rounds/${roundId}`,
|
|
||||||
linkLabel: 'View Round',
|
|
||||||
metadata: {
|
|
||||||
projectId,
|
|
||||||
oldJurorId: assignment.userId,
|
|
||||||
newJurorId: replacement.id,
|
|
||||||
reason: 'COI',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Audit
|
|
||||||
if (params.auditUserId) {
|
|
||||||
await logAudit({
|
|
||||||
prisma,
|
|
||||||
userId: params.auditUserId,
|
|
||||||
action: 'COI_REASSIGNMENT',
|
|
||||||
entityType: 'Assignment',
|
|
||||||
entityId: newAssignment.id,
|
|
||||||
detailsJson: {
|
|
||||||
oldAssignmentId: params.assignmentId,
|
|
||||||
oldJurorId: assignment.userId,
|
|
||||||
newJurorId: replacement.id,
|
|
||||||
projectId,
|
|
||||||
roundId,
|
|
||||||
},
|
|
||||||
ipAddress: params.auditIp,
|
|
||||||
userAgent: params.auditUserAgent,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
newJurorId: replacement.id,
|
|
||||||
newJurorName: replacement.name || replacement.email,
|
|
||||||
newAssignmentId: newAssignment.id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Evaluation statuses that are safe to move (not yet finalized). */
|
/** Evaluation statuses that are safe to move (not yet finalized). */
|
||||||
const MOVABLE_EVAL_STATUSES = ['NOT_STARTED', 'DRAFT'] as const
|
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<string, unknown>
|
|
||||||
const fallbackCap =
|
|
||||||
(config.maxLoadPerJuror as number) ??
|
|
||||||
(config.maxAssignmentsPerJuror as number) ??
|
|
||||||
20
|
|
||||||
|
|
||||||
// Only pick assignments with no evaluation or evaluation still in draft/not-started.
|
|
||||||
// Explicitly enumerate movable statuses so SUBMITTED and LOCKED are never touched.
|
|
||||||
const assignmentsToMove = await prisma.assignment.findMany({
|
|
||||||
where: {
|
|
||||||
roundId: params.roundId,
|
|
||||||
userId: params.droppedJurorId,
|
|
||||||
OR: [
|
|
||||||
{ evaluation: null },
|
|
||||||
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
projectId: true,
|
|
||||||
juryGroupId: true,
|
|
||||||
isRequired: true,
|
|
||||||
createdAt: true,
|
|
||||||
project: { select: { title: true } },
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (assignmentsToMove.length === 0) {
|
|
||||||
return {
|
|
||||||
movedCount: 0,
|
|
||||||
failedCount: 0,
|
|
||||||
failedProjects: [] as string[],
|
|
||||||
reassignedTo: {} as Record<string, number>,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
|
|
||||||
|
|
||||||
if (round.juryGroupId) {
|
|
||||||
const members = await prisma.juryGroupMember.findMany({
|
|
||||||
where: { juryGroupId: round.juryGroupId },
|
|
||||||
include: {
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
email: true,
|
|
||||||
maxAssignments: true,
|
|
||||||
status: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
candidateJurors = members
|
|
||||||
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== params.droppedJurorId)
|
|
||||||
.map((m) => m.user)
|
|
||||||
} else {
|
|
||||||
// No jury group configured — scope to jurors already assigned to this round
|
|
||||||
// (the de facto jury pool). This prevents assigning to random JURY_MEMBER
|
|
||||||
// accounts that aren't part of this round's jury.
|
|
||||||
const roundJurorIds = await prisma.assignment.findMany({
|
|
||||||
where: { roundId: params.roundId },
|
|
||||||
select: { userId: true },
|
|
||||||
distinct: ['userId'],
|
|
||||||
})
|
|
||||||
const activeRoundJurorIds = roundJurorIds
|
|
||||||
.map((a) => a.userId)
|
|
||||||
.filter((id) => id !== params.droppedJurorId)
|
|
||||||
|
|
||||||
candidateJurors = activeRoundJurorIds.length > 0
|
|
||||||
? await prisma.user.findMany({
|
|
||||||
where: {
|
|
||||||
id: { in: activeRoundJurorIds },
|
|
||||||
roles: { has: 'JURY_MEMBER' },
|
|
||||||
status: 'ACTIVE',
|
|
||||||
},
|
|
||||||
select: { id: true, name: true, email: true, maxAssignments: true },
|
|
||||||
})
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidateJurors.length === 0) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active replacement jurors available' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidateIds = candidateJurors.map((j) => j.id)
|
|
||||||
|
|
||||||
const existingAssignments = await prisma.assignment.findMany({
|
|
||||||
where: { roundId: params.roundId },
|
|
||||||
select: { userId: true, projectId: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
|
|
||||||
const currentLoads = new Map<string, number>()
|
|
||||||
for (const a of existingAssignments) {
|
|
||||||
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const coiRecords = await prisma.conflictOfInterest.findMany({
|
|
||||||
where: {
|
|
||||||
roundId: params.roundId,
|
|
||||||
hasConflict: true,
|
|
||||||
userId: { in: candidateIds },
|
|
||||||
},
|
|
||||||
select: { userId: true, projectId: true },
|
|
||||||
})
|
|
||||||
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
|
|
||||||
|
|
||||||
const caps = new Map<string, number>()
|
|
||||||
for (const juror of candidateJurors) {
|
|
||||||
caps.set(juror.id, juror.maxAssignments ?? fallbackCap)
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j]))
|
|
||||||
const plannedMoves: {
|
|
||||||
assignmentId: string
|
|
||||||
projectId: string
|
|
||||||
projectTitle: string
|
|
||||||
newJurorId: string
|
|
||||||
juryGroupId: string | null
|
|
||||||
isRequired: boolean
|
|
||||||
}[] = []
|
|
||||||
const failedProjects: string[] = []
|
|
||||||
|
|
||||||
for (const assignment of assignmentsToMove) {
|
|
||||||
const eligible = candidateIds
|
|
||||||
.filter((jurorId) => !alreadyAssigned.has(`${jurorId}:${assignment.projectId}`))
|
|
||||||
.filter((jurorId) => !coiPairs.has(`${jurorId}:${assignment.projectId}`))
|
|
||||||
.filter((jurorId) => (currentLoads.get(jurorId) ?? 0) < (caps.get(jurorId) ?? fallbackCap))
|
|
||||||
.sort((a, b) => {
|
|
||||||
const loadDiff = (currentLoads.get(a) ?? 0) - (currentLoads.get(b) ?? 0)
|
|
||||||
if (loadDiff !== 0) return loadDiff
|
|
||||||
return a.localeCompare(b)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (eligible.length === 0) {
|
|
||||||
failedProjects.push(assignment.project.title)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedJurorId = eligible[0]
|
|
||||||
plannedMoves.push({
|
|
||||||
assignmentId: assignment.id,
|
|
||||||
projectId: assignment.projectId,
|
|
||||||
projectTitle: assignment.project.title,
|
|
||||||
newJurorId: selectedJurorId,
|
|
||||||
juryGroupId: assignment.juryGroupId ?? round.juryGroupId,
|
|
||||||
isRequired: assignment.isRequired,
|
|
||||||
})
|
|
||||||
|
|
||||||
alreadyAssigned.add(`${selectedJurorId}:${assignment.projectId}`)
|
|
||||||
currentLoads.set(selectedJurorId, (currentLoads.get(selectedJurorId) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute moves inside a transaction with per-move TOCTOU guard.
|
|
||||||
// Uses conditional deleteMany so a concurrent evaluation submission
|
|
||||||
// (which sets status to SUBMITTED) causes the delete to return count=0
|
|
||||||
// instead of cascade-destroying the submitted evaluation.
|
|
||||||
const actualMoves: typeof plannedMoves = []
|
|
||||||
const skippedProjects: string[] = []
|
|
||||||
|
|
||||||
if (plannedMoves.length > 0) {
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
for (const move of plannedMoves) {
|
|
||||||
// Guard: only delete if the assignment still belongs to the dropped juror
|
|
||||||
// AND its evaluation (if any) is still in a movable state.
|
|
||||||
// If a juror submitted between our read and now, count will be 0.
|
|
||||||
const deleted = await tx.assignment.deleteMany({
|
|
||||||
where: {
|
|
||||||
id: move.assignmentId,
|
|
||||||
userId: params.droppedJurorId,
|
|
||||||
OR: [
|
|
||||||
{ evaluation: null },
|
|
||||||
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (deleted.count === 0) {
|
|
||||||
// Assignment was already moved, deleted, or its evaluation was submitted
|
|
||||||
skippedProjects.push(move.projectTitle)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.assignment.create({
|
|
||||||
data: {
|
|
||||||
roundId: params.roundId,
|
|
||||||
projectId: move.projectId,
|
|
||||||
userId: move.newJurorId,
|
|
||||||
juryGroupId: move.juryGroupId ?? undefined,
|
|
||||||
isRequired: move.isRequired,
|
|
||||||
method: 'MANUAL',
|
|
||||||
createdBy: params.auditUserId ?? undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
actualMoves.push(move)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add skipped projects to the failed list
|
|
||||||
failedProjects.push(...skippedProjects)
|
|
||||||
|
|
||||||
const reassignedTo: Record<string, number> = {}
|
|
||||||
for (const move of actualMoves) {
|
|
||||||
reassignedTo[move.newJurorId] = (reassignedTo[move.newJurorId] ?? 0) + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actualMoves.length > 0) {
|
|
||||||
// Build per-juror project name lists for proper emails
|
|
||||||
const destProjectNames: Record<string, string[]> = {}
|
|
||||||
for (const move of actualMoves) {
|
|
||||||
if (!destProjectNames[move.newJurorId]) destProjectNames[move.newJurorId] = []
|
|
||||||
destProjectNames[move.newJurorId].push(move.projectTitle)
|
|
||||||
}
|
|
||||||
|
|
||||||
const droppedName = droppedJuror.name || droppedJuror.email
|
|
||||||
|
|
||||||
// Fetch round deadline for email
|
|
||||||
const roundFull = await prisma.round.findUnique({
|
|
||||||
where: { id: params.roundId },
|
|
||||||
select: { windowCloseAt: true },
|
|
||||||
})
|
|
||||||
const deadline = roundFull?.windowCloseAt
|
|
||||||
? new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short', timeZone: 'Europe/Paris' }).format(roundFull.windowCloseAt)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
for (const [jurorId, projectNames] of Object.entries(destProjectNames)) {
|
|
||||||
const count = projectNames.length
|
|
||||||
await createNotification({
|
|
||||||
userId: jurorId,
|
|
||||||
type: NotificationTypes.DROPOUT_REASSIGNED,
|
|
||||||
title: count === 1 ? 'Project Reassigned to You' : `${count} Projects Reassigned to You`,
|
|
||||||
message: count === 1
|
|
||||||
? `The project "${projectNames[0]}" has been reassigned to you because ${droppedName} is no longer available in ${round.name}.`
|
|
||||||
: `${count} projects have been reassigned to you because ${droppedName} is no longer available in ${round.name}: ${projectNames.join(', ')}.`,
|
|
||||||
linkUrl: `/jury/competitions`,
|
|
||||||
linkLabel: 'View Assignments',
|
|
||||||
metadata: { roundId: round.id, roundName: round.name, projectNames, droppedJurorName: droppedName, deadline, reason: 'juror_drop_reshuffle' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const topReceivers = Object.entries(reassignedTo)
|
|
||||||
.map(([jurorId, count]) => {
|
|
||||||
const juror = candidateMeta.get(jurorId)
|
|
||||||
return `${juror?.name || juror?.email || jurorId} (${count})`
|
|
||||||
})
|
|
||||||
.join(', ')
|
|
||||||
|
|
||||||
await notifyAdmins({
|
|
||||||
type: NotificationTypes.EVALUATION_MILESTONE,
|
|
||||||
title: 'Juror Dropout Reshuffle',
|
|
||||||
message: `Reassigned ${actualMoves.length} project(s) from ${droppedName} to: ${topReceivers}. ${failedProjects.length > 0 ? `${failedProjects.length} project(s) could not be reassigned.` : 'All projects were reassigned successfully.'}`,
|
|
||||||
linkUrl: `/admin/rounds/${round.id}`,
|
|
||||||
linkLabel: 'View Round',
|
|
||||||
metadata: {
|
|
||||||
roundId: round.id,
|
|
||||||
droppedJurorId: droppedJuror.id,
|
|
||||||
movedCount: actualMoves.length,
|
|
||||||
failedCount: failedProjects.length,
|
|
||||||
topReceivers,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the dropped juror from the jury group so they can't be re-assigned
|
|
||||||
// in future assignment runs for this round's competition.
|
|
||||||
let removedFromGroup = false
|
|
||||||
if (round.juryGroupId) {
|
|
||||||
const deleted = await prisma.juryGroupMember.deleteMany({
|
|
||||||
where: {
|
|
||||||
juryGroupId: round.juryGroupId,
|
|
||||||
userId: params.droppedJurorId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
removedFromGroup = deleted.count > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.auditUserId) {
|
|
||||||
// Build per-project move detail for audit trail
|
|
||||||
const moveDetails = actualMoves.map((move) => {
|
|
||||||
const juror = candidateMeta.get(move.newJurorId)
|
|
||||||
return {
|
|
||||||
projectId: move.projectId,
|
|
||||||
projectTitle: move.projectTitle,
|
|
||||||
newJurorId: move.newJurorId,
|
|
||||||
newJurorName: juror?.name || juror?.email || move.newJurorId,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await logAudit({
|
|
||||||
prisma,
|
|
||||||
userId: params.auditUserId,
|
|
||||||
action: 'JUROR_DROPOUT_RESHUFFLE',
|
|
||||||
entityType: 'Round',
|
|
||||||
entityId: round.id,
|
|
||||||
detailsJson: {
|
|
||||||
droppedJurorId: droppedJuror.id,
|
|
||||||
droppedJurorName: droppedJuror.name || droppedJuror.email,
|
|
||||||
movedCount: actualMoves.length,
|
|
||||||
failedCount: failedProjects.length,
|
|
||||||
failedProjects,
|
|
||||||
skippedProjects,
|
|
||||||
reassignedTo,
|
|
||||||
removedFromGroup,
|
|
||||||
moves: moveDetails,
|
|
||||||
},
|
|
||||||
ipAddress: params.auditIp,
|
|
||||||
userAgent: params.auditUserAgent,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
movedCount: actualMoves.length,
|
|
||||||
failedCount: failedProjects.length,
|
|
||||||
failedProjects,
|
|
||||||
reassignedTo,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
|
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
|
||||||
try {
|
try {
|
||||||
await prisma.assignmentJob.update({
|
await prisma.assignmentJob.update({
|
||||||
@@ -1894,6 +1330,7 @@ export const assignmentRouter = router({
|
|||||||
* Start an AI assignment job (background processing)
|
* Start an AI assignment job (background processing)
|
||||||
*/
|
*/
|
||||||
startAIAssignmentJob: adminProcedure
|
startAIAssignmentJob: adminProcedure
|
||||||
|
.use(withAIRateLimit)
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existingJob = await ctx.prisma.assignmentJob.findFirst({
|
const existingJob = await ctx.prisma.assignmentJob.findFirst({
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export const assignmentPolicyRouter = router({
|
|||||||
role: member.role,
|
role: member.role,
|
||||||
policy,
|
policy,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Member may not be linked to this round's jury group
|
console.error('[AssignmentPolicy] Failed to resolve member context:', err)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -103,8 +103,8 @@ export const assignmentPolicyRouter = router({
|
|||||||
remaining: policy.remainingCapacity,
|
remaining: policy.remainingCapacity,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Skip members that can't be resolved
|
console.error('[AssignmentPolicy] Failed to evaluate policy for member:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -330,7 +330,8 @@ export const auditRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to write audit log for retention config update:', err)
|
||||||
// Never throw on audit failure
|
// Never throw on audit failure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
})
|
|
||||||
@@ -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 },
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
@@ -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<string, unknown> = {}
|
|
||||||
|
|
||||||
// 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<string, unknown> ?? {}),
|
|
||||||
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<string, unknown>).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 }
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
@@ -118,6 +118,14 @@ export const deliberationRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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)
|
const vote = await submitVote(input, ctx.prisma)
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
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 { logAudit } from '@/server/utils/audit'
|
||||||
import { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
|
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 { sendManualReminders } from '../services/evaluation-reminders'
|
||||||
import { generateSummary } from '@/server/services/ai-evaluation-summary'
|
import { generateSummary } from '@/server/services/ai-evaluation-summary'
|
||||||
import { quickRank as aiQuickRank } from '../services/ai-ranking'
|
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.`,
|
message: `Auto-ranking failed for round (ID: ${roundId}). Please trigger manually.`,
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to send AI ranking failure notification to admins:', err)
|
||||||
// Even notification failure must not propagate
|
// Even notification failure must not propagate
|
||||||
}
|
}
|
||||||
console.error('[auto-rank] triggerAutoRankIfComplete failed:', error)
|
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)
|
// 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
|
// Auto-transition: mark project IN_PROGRESS and check if all evaluations are done
|
||||||
const projectId = evaluation.assignment.projectId
|
const projectId = evaluation.assignment.projectId
|
||||||
@@ -794,6 +797,7 @@ export const evaluationRouter = router({
|
|||||||
* Generate an AI-powered evaluation summary for a project (admin only)
|
* Generate an AI-powered evaluation summary for a project (admin only)
|
||||||
*/
|
*/
|
||||||
generateSummary: adminProcedure
|
generateSummary: adminProcedure
|
||||||
|
.use(withAIRateLimit)
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
@@ -834,6 +838,7 @@ export const evaluationRouter = router({
|
|||||||
* Generate summaries for all projects in a stage with submitted evaluations (admin only)
|
* Generate summaries for all projects in a stage with submitted evaluations (admin only)
|
||||||
*/
|
*/
|
||||||
generateBulkSummaries: adminProcedure
|
generateBulkSummaries: adminProcedure
|
||||||
|
.use(withAIRateLimit)
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
// Find all projects with at least 1 submitted evaluation in this stage
|
// Find all projects with at least 1 submitted evaluation in this stage
|
||||||
@@ -1239,7 +1244,8 @@ export const evaluationRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to write audit log for discussion comment creation:', err)
|
||||||
// Never throw on audit failure
|
// Never throw on audit failure
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1276,7 +1282,8 @@ export const evaluationRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to write audit log for discussion close:', err)
|
||||||
// Never throw on audit failure
|
// Never throw on audit failure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -679,7 +679,8 @@ export const exportRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to write audit log for round export:', err)
|
||||||
// Never throw on audit failure
|
// Never throw on audit failure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -902,7 +902,9 @@ export const fileRouter = router({
|
|||||||
entityId: requirement.id,
|
entityId: requirement.id,
|
||||||
detailsJson: { name: input.name, roundId: input.roundId },
|
detailsJson: { name: input.name, roundId: input.roundId },
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[File] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return requirement
|
return requirement
|
||||||
}),
|
}),
|
||||||
@@ -938,7 +940,9 @@ export const fileRouter = router({
|
|||||||
entityId: id,
|
entityId: id,
|
||||||
detailsJson: data,
|
detailsJson: data,
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[File] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return requirement
|
return requirement
|
||||||
}),
|
}),
|
||||||
@@ -961,7 +965,9 @@ export const fileRouter = router({
|
|||||||
entityType: 'FileRequirement',
|
entityType: 'FileRequirement',
|
||||||
entityId: input.id,
|
entityId: input.id,
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[File] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}),
|
}),
|
||||||
@@ -1594,7 +1600,8 @@ export const fileRouter = router({
|
|||||||
try {
|
try {
|
||||||
await client.statObject(bucket, objectKey)
|
await client.statObject(bucket, objectKey)
|
||||||
results[objectKey] = true
|
results[objectKey] = true
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to stat MinIO object during existence check:', err)
|
||||||
results[objectKey] = false
|
results[objectKey] = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { Prisma, PrismaClient } from '@prisma/client'
|
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 { executeFilteringRules, type ProgressCallback, type AwardCriteriaInput, type AwardMatchResult } from '../services/ai-filtering'
|
||||||
import { sanitizeUserInput } from '../services/ai-prompt-guard'
|
import { sanitizeUserInput } from '../services/ai-prompt-guard'
|
||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
@@ -652,6 +652,7 @@ export const filteringRouter = router({
|
|||||||
* Start a filtering job (runs in background)
|
* Start a filtering job (runs in background)
|
||||||
*/
|
*/
|
||||||
startJob: adminProcedure
|
startJob: adminProcedure
|
||||||
|
.use(withAIRateLimit)
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existingJob = await ctx.prisma.filteringJob.findFirst({
|
const existingJob = await ctx.prisma.filteringJob.findFirst({
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ async function canUserAccessResource(
|
|||||||
const parsed = accessJson as unknown[]
|
const parsed = accessJson as unknown[]
|
||||||
if (!Array.isArray(parsed) || parsed.length === 0) return true
|
if (!Array.isArray(parsed) || parsed.length === 0) return true
|
||||||
rules = parsed as AccessRule[]
|
rules = parsed as AccessRule[]
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to parse learning resource access rules JSON:', err)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -739,8 +739,8 @@ export const liveVotingRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Audit log errors should never break the operation
|
console.error('[LiveVoting] Audit log failed:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return session
|
return session
|
||||||
|
|||||||
@@ -538,7 +538,8 @@ export const mentorRouter = router({
|
|||||||
} else {
|
} else {
|
||||||
failed++
|
failed++
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to send mentor assignment notifications:', err)
|
||||||
failed++
|
failed++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -866,8 +867,8 @@ export const mentorRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Audit log errors should never break the operation
|
console.error('[Mentor] Audit log failed:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return note
|
return note
|
||||||
@@ -1081,8 +1082,8 @@ export const mentorRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Audit log errors should never break the operation
|
console.error('[Mentor] Audit log failed:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { completion, allRequiredDone }
|
return { completion, allRequiredDone }
|
||||||
|
|||||||
@@ -145,7 +145,9 @@ export const messageRouter = router({
|
|||||||
scheduled: isScheduled,
|
scheduled: isScheduled,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[Message] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
@@ -334,7 +336,9 @@ export const messageRouter = router({
|
|||||||
entityId: template.id,
|
entityId: template.id,
|
||||||
detailsJson: { name: input.name, category: input.category },
|
detailsJson: { name: input.name, category: input.category },
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[Message] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return template
|
return template
|
||||||
}),
|
}),
|
||||||
@@ -378,7 +382,9 @@ export const messageRouter = router({
|
|||||||
entityId: id,
|
entityId: id,
|
||||||
detailsJson: { updatedFields: Object.keys(data) },
|
detailsJson: { updatedFields: Object.keys(data) },
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[Message] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return template
|
return template
|
||||||
}),
|
}),
|
||||||
@@ -402,7 +408,9 @@ export const messageRouter = router({
|
|||||||
entityType: 'MessageTemplate',
|
entityType: 'MessageTemplate',
|
||||||
entityId: input.id,
|
entityId: input.id,
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[Message] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return template
|
return template
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -505,7 +505,8 @@ export const projectRouter = router({
|
|||||||
include: { tag: { select: { id: true, name: true, category: true, color: true } } },
|
include: { tag: { select: { id: true, name: true, category: true, color: true } } },
|
||||||
orderBy: { confidence: 'desc' },
|
orderBy: { confidence: 'desc' },
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch project tags:', err)
|
||||||
// ProjectTag table may not exist yet
|
// ProjectTag table may not exist yet
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,12 +747,13 @@ export const projectRouter = router({
|
|||||||
status: 'SENT',
|
status: 'SENT',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to log invitation notification for project team member:', err)
|
||||||
// Never fail on notification logging
|
// Never fail on notification logging
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Email sending failure should not break project creation
|
// 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 baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
|
||||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
||||||
await sendInvitationEmail(email.toLowerCase(), name, inviteUrl, 'APPLICANT')
|
await sendInvitationEmail(email.toLowerCase(), name, inviteUrl, 'APPLICANT')
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Email sending failure should not block member creation
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { router, adminProcedure } from '../trpc'
|
import { router, adminProcedure, withAIRateLimit } from '../trpc'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
@@ -69,6 +69,7 @@ export const rankingRouter = router({
|
|||||||
* RANK-05, RANK-06, RANK-08.
|
* RANK-05, RANK-06, RANK-08.
|
||||||
*/
|
*/
|
||||||
executeRanking: adminProcedure
|
executeRanking: adminProcedure
|
||||||
|
.use(withAIRateLimit)
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
roundId: z.string(),
|
roundId: z.string(),
|
||||||
@@ -260,6 +261,7 @@ export const rankingRouter = router({
|
|||||||
* Reads ranking criteria from round configJson and executes quickRank.
|
* Reads ranking criteria from round configJson and executes quickRank.
|
||||||
*/
|
*/
|
||||||
triggerAutoRank: adminProcedure
|
triggerAutoRank: adminProcedure
|
||||||
|
.use(withAIRateLimit)
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { roundId } = input
|
const { roundId } = input
|
||||||
|
|||||||
@@ -329,7 +329,8 @@ export const settingsRouter = router({
|
|||||||
const success = await sendTestEmail(input.testEmail)
|
const success = await sendTestEmail(input.testEmail)
|
||||||
return { success, error: success ? null : 'Failed to send test email' }
|
return { success, error: success ? null : 'Failed to send test email' }
|
||||||
} catch (error) {
|
} 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,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to write audit log for digest settings update:', err)
|
||||||
// Never throw on audit failure
|
// Never throw on audit failure
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,7 +703,8 @@ export const settingsRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to write audit log for analytics settings update:', err)
|
||||||
// Never throw on audit failure
|
// Never throw on audit failure
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,7 +763,8 @@ export const settingsRouter = router({
|
|||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Failed to write audit log for audit settings update:', err)
|
||||||
// Never throw on audit failure
|
// Never throw on audit failure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -365,11 +365,13 @@ export const specialAwardRouter = router({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Fire and forget - process in background
|
// Fire and forget - process in background
|
||||||
void processEligibilityJob(
|
processEligibilityJob(
|
||||||
input.awardId,
|
input.awardId,
|
||||||
input.includeSubmitted ?? false,
|
input.includeSubmitted ?? false,
|
||||||
ctx.user.id
|
ctx.user.id
|
||||||
)
|
).catch((err) => {
|
||||||
|
console.error('[SpecialAward] processEligibilityJob failed:', err)
|
||||||
|
})
|
||||||
|
|
||||||
return { started: true }
|
return { started: true }
|
||||||
}),
|
}),
|
||||||
@@ -913,12 +915,14 @@ export const specialAwardRouter = router({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Fire and forget - process in background with round scoping
|
// Fire and forget - process in background with round scoping
|
||||||
void processEligibilityJob(
|
processEligibilityJob(
|
||||||
input.awardId,
|
input.awardId,
|
||||||
true, // include submitted
|
true, // include submitted
|
||||||
ctx.user.id,
|
ctx.user.id,
|
||||||
input.roundId
|
input.roundId
|
||||||
)
|
).catch((err) => {
|
||||||
|
console.error('[SpecialAward] processEligibilityJob (round) failed:', err)
|
||||||
|
})
|
||||||
|
|
||||||
return { started: true }
|
return { started: true }
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
import { router, superAdminProcedure } from '../trpc'
|
import { router, superAdminProcedure } from '../trpc'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
import {
|
import {
|
||||||
@@ -108,7 +109,9 @@ export const webhookRouter = router({
|
|||||||
entityId: webhook.id,
|
entityId: webhook.id,
|
||||||
detailsJson: { name: input.name, url: input.url, events: input.events },
|
detailsJson: { name: input.name, url: input.url, events: input.events },
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[Webhook] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return webhook
|
return webhook
|
||||||
}),
|
}),
|
||||||
@@ -152,7 +155,9 @@ export const webhookRouter = router({
|
|||||||
entityId: id,
|
entityId: id,
|
||||||
detailsJson: { updatedFields: Object.keys(data) },
|
detailsJson: { updatedFields: Object.keys(data) },
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[Webhook] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return webhook
|
return webhook
|
||||||
}),
|
}),
|
||||||
@@ -176,7 +181,9 @@ export const webhookRouter = router({
|
|||||||
entityType: 'Webhook',
|
entityType: 'Webhook',
|
||||||
entityId: input.id,
|
entityId: input.id,
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[Webhook] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}),
|
}),
|
||||||
@@ -192,7 +199,7 @@ export const webhookRouter = router({
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!webhook) {
|
if (!webhook) {
|
||||||
throw new Error('Webhook not found')
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Webhook not found' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const testPayload = {
|
const testPayload = {
|
||||||
@@ -231,7 +238,9 @@ export const webhookRouter = router({
|
|||||||
entityId: input.id,
|
entityId: input.id,
|
||||||
detailsJson: { deliveryStatus: result?.status },
|
detailsJson: { deliveryStatus: result?.status },
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[Webhook] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}),
|
}),
|
||||||
@@ -292,7 +301,9 @@ export const webhookRouter = router({
|
|||||||
entityType: 'Webhook',
|
entityType: 'Webhook',
|
||||||
entityId: input.id,
|
entityId: input.id,
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error('[Webhook] Audit log failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
return webhook
|
return webhook
|
||||||
}),
|
}),
|
||||||
|
|||||||
578
src/server/services/juror-reassignment.ts
Normal file
578
src/server/services/juror-reassignment.ts
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import {
|
||||||
|
createNotification,
|
||||||
|
notifyAdmins,
|
||||||
|
NotificationTypes,
|
||||||
|
} from './in-app-notification'
|
||||||
|
import { logAudit } from '@/server/utils/audit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reassign a project after a juror declares COI.
|
||||||
|
* Deletes the old assignment, finds an eligible replacement juror, and creates a new assignment.
|
||||||
|
* Returns the new juror info or null if no eligible juror found.
|
||||||
|
*/
|
||||||
|
export async function reassignAfterCOI(params: {
|
||||||
|
assignmentId: string
|
||||||
|
auditUserId?: string
|
||||||
|
auditIp?: string
|
||||||
|
auditUserAgent?: string
|
||||||
|
}): Promise<{ newJurorId: string; newJurorName: string; newAssignmentId: string } | null> {
|
||||||
|
const assignment = await prisma.assignment.findUnique({
|
||||||
|
where: { id: params.assignmentId },
|
||||||
|
include: {
|
||||||
|
round: { select: { id: true, name: true, configJson: true, juryGroupId: true } },
|
||||||
|
project: { select: { id: true, title: true } },
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!assignment) return null
|
||||||
|
|
||||||
|
const { roundId, projectId } = assignment
|
||||||
|
const config = (assignment.round.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const maxAssignmentsPerJuror =
|
||||||
|
(config.maxLoadPerJuror as number) ??
|
||||||
|
(config.maxAssignmentsPerJuror as number) ??
|
||||||
|
20
|
||||||
|
|
||||||
|
// ── Build exclusion set: jurors who must NEVER get this project ──────────
|
||||||
|
|
||||||
|
// 1. Currently assigned to this project in ANY round (not just current)
|
||||||
|
const allProjectAssignments = await prisma.assignment.findMany({
|
||||||
|
where: { projectId },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
const excludedUserIds = new Set(allProjectAssignments.map((a) => a.userId))
|
||||||
|
|
||||||
|
// 2. COI records for this project (any juror who declared conflict, ever)
|
||||||
|
const coiRecords = await prisma.conflictOfInterest.findMany({
|
||||||
|
where: { projectId, hasConflict: true },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
for (const c of coiRecords) excludedUserIds.add(c.userId)
|
||||||
|
|
||||||
|
// 3. Historical: jurors who previously had this project but were removed
|
||||||
|
// (via COI reassignment or admin transfer — tracked in audit logs)
|
||||||
|
const historicalAuditLogs = await prisma.decisionAuditLog.findMany({
|
||||||
|
where: {
|
||||||
|
eventType: { in: ['COI_REASSIGNMENT', 'ASSIGNMENT_TRANSFER'] },
|
||||||
|
detailsJson: { path: ['projectId'], equals: projectId },
|
||||||
|
},
|
||||||
|
select: { detailsJson: true },
|
||||||
|
})
|
||||||
|
for (const log of historicalAuditLogs) {
|
||||||
|
const details = log.detailsJson as Record<string, unknown> | null
|
||||||
|
if (!details) continue
|
||||||
|
// COI_REASSIGNMENT logs: oldJurorId had the project, newJurorId got it
|
||||||
|
if (details.oldJurorId) excludedUserIds.add(details.oldJurorId as string)
|
||||||
|
// ASSIGNMENT_TRANSFER logs: sourceJurorId lost the project
|
||||||
|
if (details.sourceJurorId) excludedUserIds.add(details.sourceJurorId as string)
|
||||||
|
// Transfer logs may have a moves array with per-project details
|
||||||
|
if (Array.isArray(details.moves)) {
|
||||||
|
for (const move of details.moves as Array<Record<string, unknown>>) {
|
||||||
|
if (move.projectId === projectId && move.newJurorId) {
|
||||||
|
// The juror who received via past transfer also had it
|
||||||
|
excludedUserIds.add(move.newJurorId as string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Find candidate jurors ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
|
||||||
|
|
||||||
|
if (assignment.round.juryGroupId) {
|
||||||
|
const members = await prisma.juryGroupMember.findMany({
|
||||||
|
where: { juryGroupId: assignment.round.juryGroupId },
|
||||||
|
include: { user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } } },
|
||||||
|
})
|
||||||
|
candidateJurors = members
|
||||||
|
.filter((m) => m.user.status === 'ACTIVE')
|
||||||
|
.map((m) => m.user)
|
||||||
|
} else {
|
||||||
|
// No jury group — scope to jurors already assigned to this round
|
||||||
|
const roundJurorIds = await prisma.assignment.findMany({
|
||||||
|
where: { roundId },
|
||||||
|
select: { userId: true },
|
||||||
|
distinct: ['userId'],
|
||||||
|
})
|
||||||
|
const activeRoundJurorIds = roundJurorIds.map((a) => a.userId)
|
||||||
|
|
||||||
|
candidateJurors = activeRoundJurorIds.length > 0
|
||||||
|
? await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: activeRoundJurorIds },
|
||||||
|
roles: { has: 'JURY_MEMBER' },
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out all excluded jurors (current assignments, COI, historical)
|
||||||
|
const eligible = candidateJurors.filter((j) => !excludedUserIds.has(j.id))
|
||||||
|
|
||||||
|
if (eligible.length === 0) return null
|
||||||
|
|
||||||
|
// ── Score eligible jurors: prefer those with incomplete evaluations ──────
|
||||||
|
|
||||||
|
const eligibleIds = eligible.map((j) => j.id)
|
||||||
|
|
||||||
|
// Get assignment counts and evaluation completion for eligible jurors in this round
|
||||||
|
const roundAssignments = await prisma.assignment.findMany({
|
||||||
|
where: { roundId, userId: { in: eligibleIds } },
|
||||||
|
select: { userId: true, evaluation: { select: { status: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build per-juror stats: total assignments, completed evaluations
|
||||||
|
const jurorStats = new Map<string, { total: number; completed: number }>()
|
||||||
|
for (const a of roundAssignments) {
|
||||||
|
const stats = jurorStats.get(a.userId) || { total: 0, completed: 0 }
|
||||||
|
stats.total++
|
||||||
|
if (a.evaluation?.status === 'SUBMITTED' || a.evaluation?.status === 'LOCKED') {
|
||||||
|
stats.completed++
|
||||||
|
}
|
||||||
|
jurorStats.set(a.userId, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rank jurors: under cap, then prefer those still working (completed < total)
|
||||||
|
const ranked = eligible
|
||||||
|
.map((j) => {
|
||||||
|
const stats = jurorStats.get(j.id) || { total: 0, completed: 0 }
|
||||||
|
const effectiveMax = j.maxAssignments ?? maxAssignmentsPerJuror
|
||||||
|
const hasIncomplete = stats.completed < stats.total
|
||||||
|
return { ...j, currentCount: stats.total, effectiveMax, hasIncomplete }
|
||||||
|
})
|
||||||
|
.filter((j) => j.currentCount < j.effectiveMax)
|
||||||
|
.sort((a, b) => {
|
||||||
|
// 1. Prefer jurors with incomplete evaluations (still active)
|
||||||
|
if (a.hasIncomplete !== b.hasIncomplete) return a.hasIncomplete ? -1 : 1
|
||||||
|
// 2. Then fewest current assignments (load balancing)
|
||||||
|
return a.currentCount - b.currentCount
|
||||||
|
})
|
||||||
|
|
||||||
|
if (ranked.length === 0) return null
|
||||||
|
|
||||||
|
const replacement = ranked[0]
|
||||||
|
|
||||||
|
// Delete old assignment and create replacement atomically.
|
||||||
|
// Cascade deletes COI record and any draft evaluation.
|
||||||
|
const newAssignment = await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.assignment.delete({ where: { id: params.assignmentId } })
|
||||||
|
return tx.assignment.create({
|
||||||
|
data: {
|
||||||
|
userId: replacement.id,
|
||||||
|
projectId,
|
||||||
|
roundId,
|
||||||
|
juryGroupId: assignment.juryGroupId ?? assignment.round.juryGroupId ?? undefined,
|
||||||
|
isRequired: assignment.isRequired,
|
||||||
|
method: 'MANUAL',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notify the replacement juror (COI-specific notification)
|
||||||
|
await createNotification({
|
||||||
|
userId: replacement.id,
|
||||||
|
type: NotificationTypes.COI_REASSIGNED,
|
||||||
|
title: 'Project Reassigned to You (COI)',
|
||||||
|
message: `The project "${assignment.project.title}" has been reassigned to you for ${assignment.round.name} because the previously assigned juror declared a conflict of interest.`,
|
||||||
|
linkUrl: `/jury/competitions`,
|
||||||
|
linkLabel: 'View Assignment',
|
||||||
|
metadata: { projectId, projectName: assignment.project.title, roundName: assignment.round.name },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notify admins of the reassignment
|
||||||
|
await notifyAdmins({
|
||||||
|
type: NotificationTypes.EVALUATION_MILESTONE,
|
||||||
|
title: 'COI Auto-Reassignment',
|
||||||
|
message: `Project "${assignment.project.title}" was reassigned from ${assignment.user.name || assignment.user.email} to ${replacement.name || replacement.email} due to conflict of interest.`,
|
||||||
|
linkUrl: `/admin/rounds/${roundId}`,
|
||||||
|
linkLabel: 'View Round',
|
||||||
|
metadata: {
|
||||||
|
projectId,
|
||||||
|
oldJurorId: assignment.userId,
|
||||||
|
newJurorId: replacement.id,
|
||||||
|
reason: 'COI',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
if (params.auditUserId) {
|
||||||
|
await logAudit({
|
||||||
|
prisma,
|
||||||
|
userId: params.auditUserId,
|
||||||
|
action: 'COI_REASSIGNMENT',
|
||||||
|
entityType: 'Assignment',
|
||||||
|
entityId: newAssignment.id,
|
||||||
|
detailsJson: {
|
||||||
|
oldAssignmentId: params.assignmentId,
|
||||||
|
oldJurorId: assignment.userId,
|
||||||
|
newJurorId: replacement.id,
|
||||||
|
projectId,
|
||||||
|
roundId,
|
||||||
|
},
|
||||||
|
ipAddress: params.auditIp,
|
||||||
|
userAgent: params.auditUserAgent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
newJurorId: replacement.id,
|
||||||
|
newJurorName: replacement.name || replacement.email,
|
||||||
|
newAssignmentId: newAssignment.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evaluation statuses that are safe to move (not yet finalized). */
|
||||||
|
const MOVABLE_EVAL_STATUSES = ['NOT_STARTED', 'DRAFT'] as const
|
||||||
|
|
||||||
|
export async function reassignDroppedJurorAssignments(params: {
|
||||||
|
roundId: string
|
||||||
|
droppedJurorId: string
|
||||||
|
auditUserId?: string
|
||||||
|
auditIp?: string
|
||||||
|
auditUserAgent?: string
|
||||||
|
}) {
|
||||||
|
const round = await prisma.round.findUnique({
|
||||||
|
where: { id: params.roundId },
|
||||||
|
select: { id: true, name: true, configJson: true, juryGroupId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!round) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const droppedJuror = await prisma.user.findUnique({
|
||||||
|
where: { id: params.droppedJurorId },
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!droppedJuror) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Juror not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const fallbackCap =
|
||||||
|
(config.maxLoadPerJuror as number) ??
|
||||||
|
(config.maxAssignmentsPerJuror as number) ??
|
||||||
|
20
|
||||||
|
|
||||||
|
// Only pick assignments with no evaluation or evaluation still in draft/not-started.
|
||||||
|
// Explicitly enumerate movable statuses so SUBMITTED and LOCKED are never touched.
|
||||||
|
const assignmentsToMove = await prisma.assignment.findMany({
|
||||||
|
where: {
|
||||||
|
roundId: params.roundId,
|
||||||
|
userId: params.droppedJurorId,
|
||||||
|
OR: [
|
||||||
|
{ evaluation: null },
|
||||||
|
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
projectId: true,
|
||||||
|
juryGroupId: true,
|
||||||
|
isRequired: true,
|
||||||
|
createdAt: true,
|
||||||
|
project: { select: { title: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (assignmentsToMove.length === 0) {
|
||||||
|
return {
|
||||||
|
movedCount: 0,
|
||||||
|
failedCount: 0,
|
||||||
|
failedProjects: [] as string[],
|
||||||
|
reassignedTo: {} as Record<string, number>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
|
||||||
|
|
||||||
|
if (round.juryGroupId) {
|
||||||
|
const members = await prisma.juryGroupMember.findMany({
|
||||||
|
where: { juryGroupId: round.juryGroupId },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
maxAssignments: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
candidateJurors = members
|
||||||
|
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== params.droppedJurorId)
|
||||||
|
.map((m) => m.user)
|
||||||
|
} else {
|
||||||
|
// No jury group configured — scope to jurors already assigned to this round
|
||||||
|
// (the de facto jury pool). This prevents assigning to random JURY_MEMBER
|
||||||
|
// accounts that aren't part of this round's jury.
|
||||||
|
const roundJurorIds = await prisma.assignment.findMany({
|
||||||
|
where: { roundId: params.roundId },
|
||||||
|
select: { userId: true },
|
||||||
|
distinct: ['userId'],
|
||||||
|
})
|
||||||
|
const activeRoundJurorIds = roundJurorIds
|
||||||
|
.map((a) => a.userId)
|
||||||
|
.filter((id) => id !== params.droppedJurorId)
|
||||||
|
|
||||||
|
candidateJurors = activeRoundJurorIds.length > 0
|
||||||
|
? await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: activeRoundJurorIds },
|
||||||
|
roles: { has: 'JURY_MEMBER' },
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidateJurors.length === 0) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active replacement jurors available' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateIds = candidateJurors.map((j) => j.id)
|
||||||
|
|
||||||
|
const existingAssignments = await prisma.assignment.findMany({
|
||||||
|
where: { roundId: params.roundId },
|
||||||
|
select: { userId: true, projectId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
|
||||||
|
const currentLoads = new Map<string, number>()
|
||||||
|
for (const a of existingAssignments) {
|
||||||
|
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const coiRecords = await prisma.conflictOfInterest.findMany({
|
||||||
|
where: {
|
||||||
|
roundId: params.roundId,
|
||||||
|
hasConflict: true,
|
||||||
|
userId: { in: candidateIds },
|
||||||
|
},
|
||||||
|
select: { userId: true, projectId: true },
|
||||||
|
})
|
||||||
|
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
|
||||||
|
|
||||||
|
const caps = new Map<string, number>()
|
||||||
|
for (const juror of candidateJurors) {
|
||||||
|
caps.set(juror.id, juror.maxAssignments ?? fallbackCap)
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j]))
|
||||||
|
const plannedMoves: {
|
||||||
|
assignmentId: string
|
||||||
|
projectId: string
|
||||||
|
projectTitle: string
|
||||||
|
newJurorId: string
|
||||||
|
juryGroupId: string | null
|
||||||
|
isRequired: boolean
|
||||||
|
}[] = []
|
||||||
|
const failedProjects: string[] = []
|
||||||
|
|
||||||
|
for (const assignment of assignmentsToMove) {
|
||||||
|
const eligible = candidateIds
|
||||||
|
.filter((jurorId) => !alreadyAssigned.has(`${jurorId}:${assignment.projectId}`))
|
||||||
|
.filter((jurorId) => !coiPairs.has(`${jurorId}:${assignment.projectId}`))
|
||||||
|
.filter((jurorId) => (currentLoads.get(jurorId) ?? 0) < (caps.get(jurorId) ?? fallbackCap))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const loadDiff = (currentLoads.get(a) ?? 0) - (currentLoads.get(b) ?? 0)
|
||||||
|
if (loadDiff !== 0) return loadDiff
|
||||||
|
return a.localeCompare(b)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (eligible.length === 0) {
|
||||||
|
failedProjects.push(assignment.project.title)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedJurorId = eligible[0]
|
||||||
|
plannedMoves.push({
|
||||||
|
assignmentId: assignment.id,
|
||||||
|
projectId: assignment.projectId,
|
||||||
|
projectTitle: assignment.project.title,
|
||||||
|
newJurorId: selectedJurorId,
|
||||||
|
juryGroupId: assignment.juryGroupId ?? round.juryGroupId,
|
||||||
|
isRequired: assignment.isRequired,
|
||||||
|
})
|
||||||
|
|
||||||
|
alreadyAssigned.add(`${selectedJurorId}:${assignment.projectId}`)
|
||||||
|
currentLoads.set(selectedJurorId, (currentLoads.get(selectedJurorId) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute moves inside a transaction with per-move TOCTOU guard.
|
||||||
|
// Uses conditional deleteMany so a concurrent evaluation submission
|
||||||
|
// (which sets status to SUBMITTED) causes the delete to return count=0
|
||||||
|
// instead of cascade-destroying the submitted evaluation.
|
||||||
|
const actualMoves: typeof plannedMoves = []
|
||||||
|
const skippedProjects: string[] = []
|
||||||
|
|
||||||
|
if (plannedMoves.length > 0) {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
for (const move of plannedMoves) {
|
||||||
|
// Guard: only delete if the assignment still belongs to the dropped juror
|
||||||
|
// AND its evaluation (if any) is still in a movable state.
|
||||||
|
// If a juror submitted between our read and now, count will be 0.
|
||||||
|
const deleted = await tx.assignment.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: move.assignmentId,
|
||||||
|
userId: params.droppedJurorId,
|
||||||
|
OR: [
|
||||||
|
{ evaluation: null },
|
||||||
|
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (deleted.count === 0) {
|
||||||
|
// Assignment was already moved, deleted, or its evaluation was submitted
|
||||||
|
skippedProjects.push(move.projectTitle)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.assignment.create({
|
||||||
|
data: {
|
||||||
|
roundId: params.roundId,
|
||||||
|
projectId: move.projectId,
|
||||||
|
userId: move.newJurorId,
|
||||||
|
juryGroupId: move.juryGroupId ?? undefined,
|
||||||
|
isRequired: move.isRequired,
|
||||||
|
method: 'MANUAL',
|
||||||
|
createdBy: params.auditUserId ?? undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
actualMoves.push(move)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add skipped projects to the failed list
|
||||||
|
failedProjects.push(...skippedProjects)
|
||||||
|
|
||||||
|
const reassignedTo: Record<string, number> = {}
|
||||||
|
for (const move of actualMoves) {
|
||||||
|
reassignedTo[move.newJurorId] = (reassignedTo[move.newJurorId] ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actualMoves.length > 0) {
|
||||||
|
// Build per-juror project name lists for proper emails
|
||||||
|
const destProjectNames: Record<string, string[]> = {}
|
||||||
|
for (const move of actualMoves) {
|
||||||
|
if (!destProjectNames[move.newJurorId]) destProjectNames[move.newJurorId] = []
|
||||||
|
destProjectNames[move.newJurorId].push(move.projectTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
const droppedName = droppedJuror.name || droppedJuror.email
|
||||||
|
|
||||||
|
// Fetch round deadline for email
|
||||||
|
const roundFull = await prisma.round.findUnique({
|
||||||
|
where: { id: params.roundId },
|
||||||
|
select: { windowCloseAt: true },
|
||||||
|
})
|
||||||
|
const deadline = roundFull?.windowCloseAt
|
||||||
|
? new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short', timeZone: 'Europe/Paris' }).format(roundFull.windowCloseAt)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
for (const [jurorId, projectNames] of Object.entries(destProjectNames)) {
|
||||||
|
const count = projectNames.length
|
||||||
|
await createNotification({
|
||||||
|
userId: jurorId,
|
||||||
|
type: NotificationTypes.DROPOUT_REASSIGNED,
|
||||||
|
title: count === 1 ? 'Project Reassigned to You' : `${count} Projects Reassigned to You`,
|
||||||
|
message: count === 1
|
||||||
|
? `The project "${projectNames[0]}" has been reassigned to you because ${droppedName} is no longer available in ${round.name}.`
|
||||||
|
: `${count} projects have been reassigned to you because ${droppedName} is no longer available in ${round.name}: ${projectNames.join(', ')}.`,
|
||||||
|
linkUrl: `/jury/competitions`,
|
||||||
|
linkLabel: 'View Assignments',
|
||||||
|
metadata: { roundId: round.id, roundName: round.name, projectNames, droppedJurorName: droppedName, deadline, reason: 'juror_drop_reshuffle' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const topReceivers = Object.entries(reassignedTo)
|
||||||
|
.map(([jurorId, count]) => {
|
||||||
|
const juror = candidateMeta.get(jurorId)
|
||||||
|
return `${juror?.name || juror?.email || jurorId} (${count})`
|
||||||
|
})
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
await notifyAdmins({
|
||||||
|
type: NotificationTypes.EVALUATION_MILESTONE,
|
||||||
|
title: 'Juror Dropout Reshuffle',
|
||||||
|
message: `Reassigned ${actualMoves.length} project(s) from ${droppedName} to: ${topReceivers}. ${failedProjects.length > 0 ? `${failedProjects.length} project(s) could not be reassigned.` : 'All projects were reassigned successfully.'}`,
|
||||||
|
linkUrl: `/admin/rounds/${round.id}`,
|
||||||
|
linkLabel: 'View Round',
|
||||||
|
metadata: {
|
||||||
|
roundId: round.id,
|
||||||
|
droppedJurorId: droppedJuror.id,
|
||||||
|
movedCount: actualMoves.length,
|
||||||
|
failedCount: failedProjects.length,
|
||||||
|
topReceivers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the dropped juror from the jury group so they can't be re-assigned
|
||||||
|
// in future assignment runs for this round's competition.
|
||||||
|
let removedFromGroup = false
|
||||||
|
if (round.juryGroupId) {
|
||||||
|
const deleted = await prisma.juryGroupMember.deleteMany({
|
||||||
|
where: {
|
||||||
|
juryGroupId: round.juryGroupId,
|
||||||
|
userId: params.droppedJurorId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
removedFromGroup = deleted.count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.auditUserId) {
|
||||||
|
// Build per-project move detail for audit trail
|
||||||
|
const moveDetails = actualMoves.map((move) => {
|
||||||
|
const juror = candidateMeta.get(move.newJurorId)
|
||||||
|
return {
|
||||||
|
projectId: move.projectId,
|
||||||
|
projectTitle: move.projectTitle,
|
||||||
|
newJurorId: move.newJurorId,
|
||||||
|
newJurorName: juror?.name || juror?.email || move.newJurorId,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma,
|
||||||
|
userId: params.auditUserId,
|
||||||
|
action: 'JUROR_DROPOUT_RESHUFFLE',
|
||||||
|
entityType: 'Round',
|
||||||
|
entityId: round.id,
|
||||||
|
detailsJson: {
|
||||||
|
droppedJurorId: droppedJuror.id,
|
||||||
|
droppedJurorName: droppedJuror.name || droppedJuror.email,
|
||||||
|
movedCount: actualMoves.length,
|
||||||
|
failedCount: failedProjects.length,
|
||||||
|
failedProjects,
|
||||||
|
skippedProjects,
|
||||||
|
reassignedTo,
|
||||||
|
removedFromGroup,
|
||||||
|
moves: moveDetails,
|
||||||
|
},
|
||||||
|
ipAddress: params.auditIp,
|
||||||
|
userAgent: params.auditUserAgent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
movedCount: actualMoves.length,
|
||||||
|
failedCount: failedProjects.length,
|
||||||
|
failedProjects,
|
||||||
|
reassignedTo,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,8 +104,8 @@ export async function sendNotification(
|
|||||||
|
|
||||||
// Overall success if at least one channel succeeded
|
// Overall success if at least one channel succeeded
|
||||||
result.success =
|
result.success =
|
||||||
(result.channels.email?.success ?? true) ||
|
(result.channels.email?.success ?? false) ||
|
||||||
(result.channels.whatsapp?.success ?? true)
|
(result.channels.whatsapp?.success ?? false)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -941,11 +941,95 @@ export async function batchCheckRequirementsAndTransition(
|
|||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient | any,
|
||||||
): Promise<{ transitionedCount: number; projectIds: string[] }> {
|
): Promise<{ transitionedCount: number; projectIds: string[] }> {
|
||||||
const transitioned: string[] = []
|
if (projectIds.length === 0) return { transitionedCount: 0, projectIds: [] }
|
||||||
|
|
||||||
|
// Pre-load all requirements for this round in batch (avoids per-project queries)
|
||||||
|
const [requirements, round] = await Promise.all([
|
||||||
|
prisma.fileRequirement.findMany({
|
||||||
|
where: { roundId, isRequired: true },
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
prisma.round.findUnique({
|
||||||
|
where: { id: roundId },
|
||||||
|
select: { submissionWindowId: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
let submissionRequirements: Array<{ id: string }> = []
|
||||||
|
if (round?.submissionWindowId) {
|
||||||
|
submissionRequirements = await prisma.submissionFileRequirement.findMany({
|
||||||
|
where: { submissionWindowId: round.submissionWindowId, required: true },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no requirements at all, nothing to check
|
||||||
|
if (requirements.length === 0 && submissionRequirements.length === 0) {
|
||||||
|
return { transitionedCount: 0, projectIds: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-load all project files and current states in batch
|
||||||
|
type FileRow = { projectId: string; requirementId: string | null; submissionFileRequirementId: string | null }
|
||||||
|
type StateRow = { projectId: string; state: string }
|
||||||
|
|
||||||
|
const [allFiles, allStates] = await Promise.all([
|
||||||
|
prisma.projectFile.findMany({
|
||||||
|
where: {
|
||||||
|
projectId: { in: projectIds },
|
||||||
|
roundId,
|
||||||
|
},
|
||||||
|
select: { projectId: true, requirementId: true, submissionFileRequirementId: true },
|
||||||
|
}) as Promise<FileRow[]>,
|
||||||
|
prisma.projectRoundState.findMany({
|
||||||
|
where: { roundId, projectId: { in: projectIds } },
|
||||||
|
select: { projectId: true, state: true },
|
||||||
|
}) as Promise<StateRow[]>,
|
||||||
|
])
|
||||||
|
|
||||||
|
// Build per-project lookup maps
|
||||||
|
const filesByProject = new Map<string, FileRow[]>()
|
||||||
|
for (const f of allFiles) {
|
||||||
|
const arr = filesByProject.get(f.projectId) ?? []
|
||||||
|
arr.push(f)
|
||||||
|
filesByProject.set(f.projectId, arr)
|
||||||
|
}
|
||||||
|
const stateByProject = new Map(allStates.map((s) => [s.projectId, s.state]))
|
||||||
|
|
||||||
|
// Determine which projects have all requirements met and are eligible for transition
|
||||||
|
const eligibleStates = ['PENDING', 'IN_PROGRESS']
|
||||||
|
const toTransition: string[] = []
|
||||||
|
|
||||||
for (const projectId of projectIds) {
|
for (const projectId of projectIds) {
|
||||||
const result = await checkRequirementsAndTransition(projectId, roundId, actorId, prisma)
|
const currentState = stateByProject.get(projectId)
|
||||||
if (result.transitioned) {
|
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)
|
transitioned.push(projectId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
for (const prs of projectStates) {
|
||||||
// Skip already-terminal states
|
// Skip already-terminal states
|
||||||
if (isTerminalState(prs.state)) {
|
if (isTerminalState(prs.state)) {
|
||||||
// Set proposed outcome to match current state for display
|
|
||||||
if (!prs.proposedOutcome) {
|
if (!prs.proposedOutcome) {
|
||||||
await prisma.projectRoundState.update({
|
updates.push({
|
||||||
where: { id: prs.id },
|
prsId: prs.id,
|
||||||
data: { proposedOutcome: prs.state },
|
projectId: prs.projectId,
|
||||||
|
currentState: prs.state,
|
||||||
|
targetState: prs.state as ProjectRoundStateValue,
|
||||||
|
proposedOutcome: prs.state as ProjectRoundStateValue,
|
||||||
|
needsTransition: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
processed++
|
processed++
|
||||||
@@ -190,7 +205,6 @@ export async function processRoundClose(
|
|||||||
switch (round.roundType as RoundType) {
|
switch (round.roundType as RoundType) {
|
||||||
case 'INTAKE':
|
case 'INTAKE':
|
||||||
case 'SUBMISSION': {
|
case 'SUBMISSION': {
|
||||||
// Projects with activity → COMPLETED, purely PENDING → REJECTED
|
|
||||||
if (prs.state === 'PENDING') {
|
if (prs.state === 'PENDING') {
|
||||||
targetState = 'REJECTED' as ProjectRoundStateValue
|
targetState = 'REJECTED' as ProjectRoundStateValue
|
||||||
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
|
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
|
||||||
@@ -202,7 +216,6 @@ export async function processRoundClose(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'EVALUATION': {
|
case 'EVALUATION': {
|
||||||
// Use ranking scores to determine pass/reject
|
|
||||||
const hasEvals = prs.project.assignments.some((a: { isCompleted: boolean }) => a.isCompleted)
|
const hasEvals = prs.project.assignments.some((a: { isCompleted: boolean }) => a.isCompleted)
|
||||||
const shouldPass = evaluationPassSet?.has(prs.projectId) ?? false
|
const shouldPass = evaluationPassSet?.has(prs.projectId) ?? false
|
||||||
if (prs.state === 'IN_PROGRESS' || (prs.state === 'PENDING' && hasEvals)) {
|
if (prs.state === 'IN_PROGRESS' || (prs.state === 'PENDING' && hasEvals)) {
|
||||||
@@ -218,7 +231,6 @@ export async function processRoundClose(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'FILTERING': {
|
case 'FILTERING': {
|
||||||
// Use FilteringResult to determine outcome for each project
|
|
||||||
const fr = prs.project.filteringResults?.[0] as { outcome: string; finalOutcome: string | null } | undefined
|
const fr = prs.project.filteringResults?.[0] as { outcome: string; finalOutcome: string | null } | undefined
|
||||||
const effectiveOutcome = fr?.finalOutcome || fr?.outcome
|
const effectiveOutcome = fr?.finalOutcome || fr?.outcome
|
||||||
const filterPassed = effectiveOutcome !== 'FILTERED_OUT'
|
const filterPassed = effectiveOutcome !== 'FILTERED_OUT'
|
||||||
@@ -229,12 +241,10 @@ export async function processRoundClose(
|
|||||||
targetState = 'COMPLETED' as ProjectRoundStateValue
|
targetState = 'COMPLETED' as ProjectRoundStateValue
|
||||||
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
|
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
|
||||||
} else if (prs.state === 'PENDING') {
|
} else if (prs.state === 'PENDING') {
|
||||||
// PENDING projects in filtering: check FilteringResult
|
|
||||||
if (fr) {
|
if (fr) {
|
||||||
targetState = 'COMPLETED' as ProjectRoundStateValue
|
targetState = 'COMPLETED' as ProjectRoundStateValue
|
||||||
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
|
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
|
||||||
} else {
|
} else {
|
||||||
// No filtering result at all → reject
|
|
||||||
targetState = 'REJECTED' as ProjectRoundStateValue
|
targetState = 'REJECTED' as ProjectRoundStateValue
|
||||||
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
|
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
|
||||||
}
|
}
|
||||||
@@ -243,7 +253,6 @@ export async function processRoundClose(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'MENTORING': {
|
case 'MENTORING': {
|
||||||
// Projects already PASSED (pass-through) stay PASSED
|
|
||||||
if (prs.state === 'PASSED') {
|
if (prs.state === 'PASSED') {
|
||||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||||
} else if (prs.state === 'IN_PROGRESS') {
|
} else if (prs.state === 'IN_PROGRESS') {
|
||||||
@@ -252,7 +261,6 @@ export async function processRoundClose(
|
|||||||
} else if (prs.state === 'COMPLETED') {
|
} else if (prs.state === 'COMPLETED') {
|
||||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||||
} else if (prs.state === 'PENDING') {
|
} else if (prs.state === 'PENDING') {
|
||||||
// Pending = never requested mentoring, pass through
|
|
||||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||||
targetState = 'COMPLETED' as ProjectRoundStateValue
|
targetState = 'COMPLETED' as ProjectRoundStateValue
|
||||||
}
|
}
|
||||||
@@ -260,7 +268,6 @@ export async function processRoundClose(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'LIVE_FINAL': {
|
case 'LIVE_FINAL': {
|
||||||
// All presented projects → COMPLETED
|
|
||||||
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
|
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
|
||||||
targetState = 'COMPLETED' as ProjectRoundStateValue
|
targetState = 'COMPLETED' as ProjectRoundStateValue
|
||||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||||
@@ -271,7 +278,6 @@ export async function processRoundClose(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'DELIBERATION': {
|
case 'DELIBERATION': {
|
||||||
// All voted projects → COMPLETED
|
|
||||||
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
|
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
|
||||||
targetState = 'COMPLETED' as ProjectRoundStateValue
|
targetState = 'COMPLETED' as ProjectRoundStateValue
|
||||||
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
||||||
@@ -282,28 +288,113 @@ export async function processRoundClose(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transition project if needed (admin override for non-standard paths)
|
const needsTransition = targetState !== prs.state && !isTerminalState(prs.state)
|
||||||
if (targetState !== prs.state && !isTerminalState(prs.state)) {
|
updates.push({
|
||||||
// Need to handle multi-step transitions
|
prsId: prs.id,
|
||||||
if (prs.state === 'PENDING' && targetState === 'COMPLETED') {
|
projectId: prs.projectId,
|
||||||
await transitionProject(prs.projectId, roundId, 'IN_PROGRESS' as ProjectRoundStateValue, actorId, prisma, { adminOverride: true })
|
currentState: prs.state,
|
||||||
await transitionProject(prs.projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma, { adminOverride: true })
|
targetState,
|
||||||
} else if (prs.state === 'PENDING' && targetState === 'REJECTED') {
|
proposedOutcome,
|
||||||
await transitionProject(prs.projectId, roundId, targetState, actorId, prisma, { adminOverride: true })
|
needsTransition,
|
||||||
} else {
|
|
||||||
await transitionProject(prs.projectId, roundId, targetState, actorId, prisma, { adminOverride: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set proposed outcome
|
|
||||||
await prisma.projectRoundState.update({
|
|
||||||
where: { id: prs.id },
|
|
||||||
data: { proposedOutcome },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
processed++
|
processed++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Phase 2: Batch state transitions in a single transaction ──
|
||||||
|
const transitionUpdates = updates.filter((u) => u.needsTransition)
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
if (transitionUpdates.length > 0) {
|
||||||
|
await prisma.$transaction(async (tx: any) => {
|
||||||
|
// Step through intermediate states in bulk
|
||||||
|
// PENDING → IN_PROGRESS for projects going to COMPLETED
|
||||||
|
const pendingToCompleted = transitionUpdates.filter(
|
||||||
|
(u) => u.currentState === 'PENDING' && u.targetState === ('COMPLETED' as string),
|
||||||
|
)
|
||||||
|
if (pendingToCompleted.length > 0) {
|
||||||
|
await tx.projectRoundState.updateMany({
|
||||||
|
where: { id: { in: pendingToCompleted.map((u) => u.prsId) } },
|
||||||
|
data: { state: 'IN_PROGRESS' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// IN_PROGRESS → COMPLETED (includes those just moved from PENDING)
|
||||||
|
const toCompleted = transitionUpdates.filter(
|
||||||
|
(u) => u.targetState === ('COMPLETED' as string) &&
|
||||||
|
(u.currentState === 'PENDING' || u.currentState === 'IN_PROGRESS'),
|
||||||
|
)
|
||||||
|
if (toCompleted.length > 0) {
|
||||||
|
await tx.projectRoundState.updateMany({
|
||||||
|
where: { id: { in: toCompleted.map((u) => u.prsId) } },
|
||||||
|
data: { state: 'COMPLETED' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PENDING → REJECTED (direct terminal transition)
|
||||||
|
const pendingToRejected = transitionUpdates.filter(
|
||||||
|
(u) => u.currentState === 'PENDING' && u.targetState === ('REJECTED' as string),
|
||||||
|
)
|
||||||
|
if (pendingToRejected.length > 0) {
|
||||||
|
await tx.projectRoundState.updateMany({
|
||||||
|
where: { id: { in: pendingToRejected.map((u) => u.prsId) } },
|
||||||
|
data: { state: 'REJECTED', exitedAt: now },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other single-step transitions (e.g., IN_PROGRESS → COMPLETED already handled)
|
||||||
|
const otherTransitions = transitionUpdates.filter(
|
||||||
|
(u) =>
|
||||||
|
!(u.currentState === 'PENDING' && (u.targetState === ('COMPLETED' as string) || u.targetState === ('REJECTED' as string))) &&
|
||||||
|
!(u.currentState === 'IN_PROGRESS' && u.targetState === ('COMPLETED' as string)),
|
||||||
|
)
|
||||||
|
if (otherTransitions.length > 0) {
|
||||||
|
await tx.projectRoundState.updateMany({
|
||||||
|
where: { id: { in: otherTransitions.map((u) => u.prsId) } },
|
||||||
|
data: { state: otherTransitions[0].targetState },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch create audit logs for all transitions
|
||||||
|
await tx.decisionAuditLog.createMany({
|
||||||
|
data: transitionUpdates.map((u) => ({
|
||||||
|
eventType: 'project_round.transitioned',
|
||||||
|
entityType: 'ProjectRoundState',
|
||||||
|
entityId: u.prsId,
|
||||||
|
actorId,
|
||||||
|
detailsJson: {
|
||||||
|
projectId: u.projectId,
|
||||||
|
roundId,
|
||||||
|
previousState: u.currentState,
|
||||||
|
newState: u.targetState,
|
||||||
|
batchProcessed: true,
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
snapshotJson: {
|
||||||
|
timestamp: now.toISOString(),
|
||||||
|
emittedBy: 'round-finalization',
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 3: Batch update proposed outcomes ──
|
||||||
|
const outcomeUpdates = updates.filter((u) => u.proposedOutcome)
|
||||||
|
// Group by proposed outcome for efficient updateMany calls
|
||||||
|
const outcomeGroups = new Map<ProjectRoundStateValue, string[]>()
|
||||||
|
for (const u of outcomeUpdates) {
|
||||||
|
const ids = outcomeGroups.get(u.proposedOutcome) ?? []
|
||||||
|
ids.push(u.prsId)
|
||||||
|
outcomeGroups.set(u.proposedOutcome, ids)
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(outcomeGroups.entries()).map(([outcome, ids]) =>
|
||||||
|
prisma.projectRoundState.updateMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
data: { proposedOutcome: outcome },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return { processed }
|
return { processed }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,6 +792,8 @@ export async function confirmFinalization(
|
|||||||
const inviteTokenMap = new Map<string, string>() // userId → token
|
const inviteTokenMap = new Map<string, string>() // userId → token
|
||||||
const expiryMs = await getInviteExpiryMs(prisma)
|
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) {
|
for (const prs of finalizedStates) {
|
||||||
if (prs.state !== 'PASSED') continue
|
if (prs.state !== 'PASSED') continue
|
||||||
const users = prs.project.teamMembers.length > 0
|
const users = prs.project.teamMembers.length > 0
|
||||||
@@ -710,16 +803,25 @@ export async function confirmFinalization(
|
|||||||
if (user && !user.passwordHash && !inviteTokenMap.has(user.id)) {
|
if (user && !user.passwordHash && !inviteTokenMap.has(user.id)) {
|
||||||
const token = generateInviteToken()
|
const token = generateInviteToken()
|
||||||
inviteTokenMap.set(user.id, token)
|
inviteTokenMap.set(user.id, token)
|
||||||
await prisma.user.update({
|
tokenUpdates.push({ userId: user.id, token })
|
||||||
where: { id: user.id },
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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: {
|
data: {
|
||||||
inviteToken: token,
|
inviteToken: t.token,
|
||||||
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
inviteTokenExpiresAt: tokenExpiry,
|
||||||
status: 'INVITED',
|
status: 'INVITED',
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
}
|
),
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const advancedUserIds = new Set<string>()
|
const advancedUserIds = new Set<string>()
|
||||||
@@ -801,7 +903,7 @@ export async function confirmFinalization(
|
|||||||
|
|
||||||
// Create in-app notifications
|
// Create in-app notifications
|
||||||
if (advancedUserIds.size > 0) {
|
if (advancedUserIds.size > 0) {
|
||||||
void createBulkNotifications({
|
createBulkNotifications({
|
||||||
userIds: [...advancedUserIds],
|
userIds: [...advancedUserIds],
|
||||||
type: 'project_advanced',
|
type: 'project_advanced',
|
||||||
title: 'Your project has advanced!',
|
title: 'Your project has advanced!',
|
||||||
@@ -810,11 +912,13 @@ export async function confirmFinalization(
|
|||||||
linkLabel: 'View Dashboard',
|
linkLabel: 'View Dashboard',
|
||||||
icon: 'Trophy',
|
icon: 'Trophy',
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('[Finalization] createBulkNotifications (advanced) failed:', err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rejectedUserIds.size > 0) {
|
if (rejectedUserIds.size > 0) {
|
||||||
void createBulkNotifications({
|
createBulkNotifications({
|
||||||
userIds: [...rejectedUserIds],
|
userIds: [...rejectedUserIds],
|
||||||
type: 'project_rejected',
|
type: 'project_rejected',
|
||||||
title: 'Competition Update',
|
title: 'Competition Update',
|
||||||
@@ -823,6 +927,8 @@ export async function confirmFinalization(
|
|||||||
linkLabel: 'View Dashboard',
|
linkLabel: 'View Dashboard',
|
||||||
icon: 'Info',
|
icon: 'Info',
|
||||||
priority: 'normal',
|
priority: 'normal',
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('[Finalization] createBulkNotifications (rejected) failed:', err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ZodError } from 'zod'
|
|||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
import type { Context } from './context'
|
import type { Context } from './context'
|
||||||
import type { UserRole } from '@prisma/client'
|
import type { UserRole } from '@prisma/client'
|
||||||
|
import { checkRateLimit } from '@/lib/rate-limit'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize tRPC with context type and configuration
|
* 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
|
// Procedure Types
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Protected procedure - requires authenticated user.
|
* 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
|
export const protectedProcedure = t.procedure
|
||||||
.use(isAuthenticated)
|
.use(isAuthenticated)
|
||||||
|
.use(withRateLimit)
|
||||||
.use(withErrorAudit)
|
.use(withErrorAudit)
|
||||||
.use(withMutationAudit)
|
.use(withMutationAudit)
|
||||||
|
|
||||||
@@ -318,6 +365,7 @@ export const protectedProcedure = t.procedure
|
|||||||
*/
|
*/
|
||||||
export const adminProcedure = t.procedure
|
export const adminProcedure = t.procedure
|
||||||
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN'))
|
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN'))
|
||||||
|
.use(withRateLimit)
|
||||||
.use(withErrorAudit)
|
.use(withErrorAudit)
|
||||||
.use(withMutationAudit)
|
.use(withMutationAudit)
|
||||||
|
|
||||||
@@ -335,6 +383,7 @@ export const superAdminProcedure = t.procedure
|
|||||||
*/
|
*/
|
||||||
export const juryProcedure = t.procedure
|
export const juryProcedure = t.procedure
|
||||||
.use(hasRole('JURY_MEMBER'))
|
.use(hasRole('JURY_MEMBER'))
|
||||||
|
.use(withRateLimit)
|
||||||
.use(withErrorAudit)
|
.use(withErrorAudit)
|
||||||
.use(withMutationAudit)
|
.use(withMutationAudit)
|
||||||
|
|
||||||
@@ -344,6 +393,7 @@ export const juryProcedure = t.procedure
|
|||||||
*/
|
*/
|
||||||
export const mentorProcedure = t.procedure
|
export const mentorProcedure = t.procedure
|
||||||
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR'))
|
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR'))
|
||||||
|
.use(withRateLimit)
|
||||||
.use(withErrorAudit)
|
.use(withErrorAudit)
|
||||||
.use(withMutationAudit)
|
.use(withMutationAudit)
|
||||||
|
|
||||||
@@ -353,6 +403,7 @@ export const mentorProcedure = t.procedure
|
|||||||
*/
|
*/
|
||||||
export const observerProcedure = t.procedure
|
export const observerProcedure = t.procedure
|
||||||
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'))
|
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'))
|
||||||
|
.use(withRateLimit)
|
||||||
.use(withErrorAudit)
|
.use(withErrorAudit)
|
||||||
.use(withMutationAudit)
|
.use(withMutationAudit)
|
||||||
|
|
||||||
@@ -362,6 +413,7 @@ export const observerProcedure = t.procedure
|
|||||||
*/
|
*/
|
||||||
export const awardMasterProcedure = t.procedure
|
export const awardMasterProcedure = t.procedure
|
||||||
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER'))
|
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER'))
|
||||||
|
.use(withRateLimit)
|
||||||
.use(withErrorAudit)
|
.use(withErrorAudit)
|
||||||
.use(withMutationAudit)
|
.use(withMutationAudit)
|
||||||
|
|
||||||
@@ -371,5 +423,12 @@ export const awardMasterProcedure = t.procedure
|
|||||||
*/
|
*/
|
||||||
export const audienceProcedure = t.procedure
|
export const audienceProcedure = t.procedure
|
||||||
.use(isAuthenticated)
|
.use(isAuthenticated)
|
||||||
|
.use(withRateLimit)
|
||||||
.use(withErrorAudit)
|
.use(withErrorAudit)
|
||||||
.use(withMutationAudit)
|
.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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user