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

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

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

View File

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