Files
MOPC-Portal/src/server/services/award-eligibility-job.ts
Matt 1308c3ba87
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code
Phase 1 — Critical bugs:
- Fix deliberation participant selection (wire jury group query)
- Fix reports "By Round" tab (inline content instead of 404 route)
- Fix messages "Sent History" (add message.sent procedure, wire tab)
- Add missing fields to competition award form (criteriaText, maxRankedPicks)
- Wire LiveControlPanel buttons (cursor, voting, scores)
- Fix ResultLockControls empty snapshot (fetch actual data before lock)
- Fix SubmissionWindowManager losing fields on edit

Phase 2 — Backend fixes:
- Remove write-in-query from specialAward.get
- Fix award eligibility job overwriting manual shortlist overrides
- Fix filtering startJob deleting all prior results (defer cleanup to post-success)
- Tighten access control: protectedProcedure → adminProcedure on 8 procedures
- Add audit logging to deliberation mutations
- Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete

Phase 3 — Auto-refresh:
- Add refetchInterval to 15+ admin pages/components (10s–30s)
- Fix AI job polling: derive speed from job status for all viewers

Phase 4 — Dead code cleanup:
- Delete unused command-palette, pdf-report, admin-page-transition
- Remove dead subItems sidebar code, unused GripVertical import
- Replace redundant isGenerating state with mutation.isPending
- Add Role column to jury members table
- Remove misleading manual mentor assignment stub

Phase 5 — UX improvements:
- Fix rounds page single-competition assumption (add selector)
- Remove raw UUID fallback in deliberation config
- Fix programs page "Stage" → "Round" terminology

Phase 6 — Backend hardening:
- Complete logAudit calls (add prisma, ipAddress, userAgent)
- Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear)
- Batch user.bulkCreate writes (assignments, jury memberships, intents)
- Remove any casts from deliberation service (typed PrismaClient + TransactionClient)
- Fix stale DeliberationStatus enum values blocking build

40 files changed, 1010 insertions(+), 612 deletions(-)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:20:13 +01:00

293 lines
9.2 KiB
TypeScript

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<void> {
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<string, { eligible: boolean; confidence: number; qualityScore: number; reasoning: string }> | 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 — preserve manual overrides and shortlist status
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: {
// Only update AI-computed fields; DO NOT reset overriddenBy,
// overriddenAt, shortlisted, confirmedAt, confirmedBy — those
// reflect admin decisions that must survive re-runs.
qualityScore: e.qualityScore,
aiReasoningJson: e.aiReasoningJson ?? undefined,
},
})
)
)
// For records without manual override, sync the eligible/method fields
const nonOverridden = await prisma.awardEligibility.findMany({
where: { awardId, overriddenBy: null },
select: { projectId: true },
})
const nonOverriddenIds = new Set(nonOverridden.map((r) => r.projectId))
if (nonOverriddenIds.size > 0) {
await prisma.$transaction(
eligibilities
.filter((e) => nonOverriddenIds.has(e.projectId))
.map((e) =>
prisma.awardEligibility.update({
where: {
awardId_projectId: { awardId, projectId: e.projectId },
},
data: {
eligible: e.eligible,
method: e.method as 'AUTO' | 'MANUAL',
},
})
)
)
}
// Auto-shortlist top N eligible projects by qualityScore
// Only auto-shortlist records that aren't already manually shortlisted
const shortlistSize = award.shortlistSize ?? 10
const alreadyShortlisted = await prisma.awardEligibility.findMany({
where: { awardId, shortlisted: true, overriddenBy: { not: null } },
select: { projectId: true },
})
const manuallyShortlistedIds = new Set(alreadyShortlisted.map((r) => r.projectId))
const topEligible = eligibilities
.filter((e) => e.eligible && e.qualityScore != null && !manuallyShortlistedIds.has(e.projectId))
.sort((a, b) => (b.qualityScore ?? 0) - (a.qualityScore ?? 0))
.slice(0, Math.max(0, shortlistSize - manuallyShortlistedIds.size))
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)
}
}
}