import { prisma } from '@/lib/prisma' import { aiInterpretCriteria } from './ai-award-eligibility' const BATCH_SIZE = 20 /** * Process eligibility for an award in the background. * Updates progress in the database as it goes so the frontend can poll. */ export async function processEligibilityJob( awardId: string, includeSubmitted: boolean, userId: string, filteringRoundId?: string ): Promise { try { // Mark job as PROCESSING const award = await prisma.specialAward.findUniqueOrThrow({ where: { id: awardId }, include: { program: true }, }) // Rich select matching the data the integrated filtering pass sends const projectSelect = { id: true, title: true, description: true, competitionCategory: true, country: true, geographicZone: true, tags: true, oceanIssue: true, institution: true, foundedAt: true, wantsMentorship: true, submissionSource: true, submittedAt: true, _count: { select: { teamMembers: true, files: true } }, files: { select: { fileType: true, size: true, pageCount: true } }, } as const // Get projects — scoped to filtering round PASSED projects if provided let projects: Array<{ id: string title: string description: string | null competitionCategory: string | null country: string | null geographicZone: string | null tags: string[] oceanIssue: string | null institution: string | null foundedAt: Date | null wantsMentorship: boolean submissionSource: string submittedAt: Date | null _count: { teamMembers: number; files: number } files: Array<{ fileType: string | null; size: number; pageCount: number | null }> }> if (filteringRoundId) { // Scope to projects that effectively PASSED filtering (including admin overrides) const passedResults = await prisma.filteringResult.findMany({ where: { roundId: filteringRoundId, OR: [ { finalOutcome: 'PASSED' }, { finalOutcome: null, outcome: 'PASSED' }, ], }, select: { projectId: true }, }) const passedIds = passedResults.map((r) => r.projectId) if (passedIds.length === 0) { await prisma.specialAward.update({ where: { id: awardId }, data: { eligibilityJobStatus: 'COMPLETED', eligibilityJobTotal: 0, eligibilityJobDone: 0, }, }) return } projects = await prisma.project.findMany({ where: { id: { in: passedIds }, programId: award.programId, }, select: projectSelect, }) } else { const statusFilter = includeSubmitted ? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const) : (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const) projects = await prisma.project.findMany({ where: { programId: award.programId, status: { in: [...statusFilter] }, }, select: projectSelect, }) } if (projects.length === 0) { await prisma.specialAward.update({ where: { id: awardId }, data: { eligibilityJobStatus: 'COMPLETED', eligibilityJobTotal: 0, eligibilityJobDone: 0, }, }) return } await prisma.specialAward.update({ where: { id: awardId }, data: { eligibilityJobStatus: 'PROCESSING', eligibilityJobTotal: projects.length, eligibilityJobDone: 0, eligibilityJobError: null, eligibilityJobStarted: new Date(), }, }) // AI interpretation (if criteria text exists AND AI eligibility is enabled) // Process in batches to avoid timeouts let aiResults: Map | undefined if (award.criteriaText && award.useAiEligibility) { aiResults = new Map() for (let i = 0; i < projects.length; i += BATCH_SIZE) { const batch = projects.slice(i, i + BATCH_SIZE) const aiEvals = await aiInterpretCriteria(award.criteriaText, batch) for (const e of aiEvals) { aiResults.set(e.projectId, { eligible: e.eligible, confidence: e.confidence, qualityScore: e.qualityScore, reasoning: e.reasoning, }) } // Update progress await prisma.specialAward.update({ where: { id: awardId }, data: { eligibilityJobDone: Math.min(i + BATCH_SIZE, projects.length), }, }) } } else { // No AI needed, mark all as done await prisma.specialAward.update({ where: { id: awardId }, data: { eligibilityJobDone: projects.length }, }) } // Combine results const eligibilities = projects.map((project) => { const aiEval = aiResults?.get(project.id) const eligible = aiEval?.eligible ?? true const method = aiResults ? 'AUTO' : 'MANUAL' return { projectId: project.id, eligible, method, qualityScore: aiEval?.qualityScore ?? null, aiReasoningJson: aiEval ? { confidence: aiEval.confidence, qualityScore: aiEval.qualityScore, reasoning: aiEval.reasoning } : null, } }) // Upsert eligibilities await prisma.$transaction( eligibilities.map((e) => prisma.awardEligibility.upsert({ where: { awardId_projectId: { awardId, projectId: e.projectId, }, }, create: { awardId, projectId: e.projectId, eligible: e.eligible, method: e.method as 'AUTO' | 'MANUAL', qualityScore: e.qualityScore, aiReasoningJson: e.aiReasoningJson ?? undefined, }, update: { eligible: e.eligible, method: e.method as 'AUTO' | 'MANUAL', qualityScore: e.qualityScore, aiReasoningJson: e.aiReasoningJson ?? undefined, overriddenBy: null, overriddenAt: null, shortlisted: false, confirmedAt: null, confirmedBy: null, }, }) ) ) // Auto-shortlist top N eligible projects by qualityScore const shortlistSize = award.shortlistSize ?? 10 const topEligible = eligibilities .filter((e) => e.eligible && e.qualityScore != null) .sort((a, b) => (b.qualityScore ?? 0) - (a.qualityScore ?? 0)) .slice(0, shortlistSize) if (topEligible.length > 0) { await prisma.$transaction( topEligible.map((e) => prisma.awardEligibility.update({ where: { awardId_projectId: { awardId, projectId: e.projectId, }, }, data: { shortlisted: true }, }) ) ) } // Mark as completed await prisma.specialAward.update({ where: { id: awardId }, data: { eligibilityJobStatus: 'COMPLETED', eligibilityJobDone: projects.length, }, }) } catch (error) { // Mark as failed const errorMessage = error instanceof Error ? error.message : 'Unknown error' try { await prisma.specialAward.update({ where: { id: awardId }, data: { eligibilityJobStatus: 'FAILED', eligibilityJobError: errorMessage, }, }) } catch { // If we can't even update the status, log and give up console.error('Failed to update eligibility job status:', error) } } }