All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
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>
293 lines
9.2 KiB
TypeScript
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)
|
|
}
|
|
}
|
|
}
|