Fix AI filtering bugs, add special award shortlist integration
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m20s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m20s
Part 1 - Bug Fixes: - Fix toProjectWithRelations() stripping file fields needed by AI (detectedLang, textContent, etc.) - Fix parseAIData() reading flat when aiScreeningJson is nested under rule ID - Fix getAIConfidenceScore() with same nesting issue (always returned 0) Part 2 - Special Award Track Integration: - Add shortlistSize to SpecialAward, qualityScore/shortlisted/confirmed fields to AwardEligibility - Add specialAwardId to Round for award-owned rounds - Update AI eligibility service to return qualityScore (0-100) for ranking - Update eligibility job with filteringRoundId scoping and auto-shortlist top N - Add 8 new specialAward router procedures (listForRound, runEligibilityForRound, listShortlist, toggleShortlisted, confirmShortlist, listRounds, createRound, deleteRound) - Create award-shortlist.tsx component with ranked table, shortlist checkboxes, confirm dialog - Add "Special Award Tracks" section to filtering dashboard Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
-- AlterTable: Add shortlistSize to SpecialAward
|
||||
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "shortlistSize" INTEGER NOT NULL DEFAULT 10;
|
||||
|
||||
-- AlterTable: Add qualityScore, shortlisted, confirmedAt, confirmedBy to AwardEligibility
|
||||
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "qualityScore" DOUBLE PRECISION;
|
||||
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "shortlisted" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "confirmedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "confirmedBy" TEXT;
|
||||
|
||||
-- AlterTable: Add specialAwardId to Round
|
||||
ALTER TABLE "Round" ADD COLUMN IF NOT EXISTS "specialAwardId" TEXT;
|
||||
|
||||
-- AddForeignKey: AwardEligibility.confirmedBy -> User.id
|
||||
ALTER TABLE "AwardEligibility" ADD CONSTRAINT "AwardEligibility_confirmedBy_fkey" FOREIGN KEY ("confirmedBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey: Round.specialAwardId -> SpecialAward.id
|
||||
ALTER TABLE "Round" ADD CONSTRAINT "Round_specialAwardId_fkey" FOREIGN KEY ("specialAwardId") REFERENCES "SpecialAward"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "Round_specialAwardId_idx" ON "Round"("specialAwardId");
|
||||
@@ -379,6 +379,7 @@ model User {
|
||||
|
||||
// Award overrides
|
||||
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
|
||||
awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer")
|
||||
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
|
||||
|
||||
// In-app notifications
|
||||
@@ -1507,6 +1508,7 @@ model SpecialAward {
|
||||
juryGroupId String?
|
||||
eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN)
|
||||
decisionMode String? // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION"
|
||||
shortlistSize Int @default(10)
|
||||
|
||||
// Eligibility job tracking
|
||||
eligibilityJobStatus String? // PENDING, PROCESSING, COMPLETED, FAILED
|
||||
@@ -1530,6 +1532,7 @@ model SpecialAward {
|
||||
competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull)
|
||||
evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull)
|
||||
awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||
rounds Round[] @relation("AwardRounds")
|
||||
|
||||
@@index([programId])
|
||||
@@index([status])
|
||||
@@ -1545,11 +1548,17 @@ model AwardEligibility {
|
||||
method EligibilityMethod @default(AUTO)
|
||||
eligible Boolean @default(false)
|
||||
aiReasoningJson Json? @db.JsonB
|
||||
qualityScore Float?
|
||||
shortlisted Boolean @default(false)
|
||||
|
||||
// Admin override
|
||||
overriddenBy String?
|
||||
overriddenAt DateTime?
|
||||
|
||||
// Shortlist confirmation
|
||||
confirmedAt DateTime?
|
||||
confirmedBy String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -1557,6 +1566,7 @@ model AwardEligibility {
|
||||
award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
overriddenByUser User? @relation("AwardEligibilityOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull)
|
||||
confirmer User? @relation("AwardEligibilityConfirmer", fields: [confirmedBy], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([awardId, projectId])
|
||||
@@index([awardId])
|
||||
@@ -2118,12 +2128,14 @@ model Round {
|
||||
// Links to other entities
|
||||
juryGroupId String?
|
||||
submissionWindowId String?
|
||||
specialAwardId String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
|
||||
specialAward SpecialAward? @relation("AwardRounds", fields: [specialAwardId], references: [id], onDelete: SetNull)
|
||||
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
|
||||
projectRoundStates ProjectRoundState[]
|
||||
@@ -2157,6 +2169,7 @@ model Round {
|
||||
@@index([competitionId])
|
||||
@@index([roundType])
|
||||
@@index([status])
|
||||
@@index([specialAwardId])
|
||||
}
|
||||
|
||||
model ProjectRoundState {
|
||||
|
||||
323
src/components/admin/round/award-shortlist.tsx
Normal file
323
src/components/admin/round/award-shortlist.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
Award,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
Play,
|
||||
Star,
|
||||
Trophy,
|
||||
} from 'lucide-react'
|
||||
|
||||
type AwardShortlistProps = {
|
||||
awardId: string
|
||||
roundId: string
|
||||
awardName: string
|
||||
criteriaText?: string | null
|
||||
eligibilityMode: string
|
||||
shortlistSize: number
|
||||
jobStatus?: string | null
|
||||
jobTotal?: number | null
|
||||
jobDone?: number | null
|
||||
}
|
||||
|
||||
export function AwardShortlist({
|
||||
awardId,
|
||||
roundId,
|
||||
awardName,
|
||||
criteriaText,
|
||||
eligibilityMode,
|
||||
shortlistSize,
|
||||
jobStatus,
|
||||
jobTotal,
|
||||
jobDone,
|
||||
}: AwardShortlistProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const isRunning = jobStatus === 'PENDING' || jobStatus === 'PROCESSING'
|
||||
|
||||
const { data: shortlist, isLoading: isLoadingShortlist } = trpc.specialAward.listShortlist.useQuery(
|
||||
{ awardId, perPage: 100 },
|
||||
{ enabled: expanded && !isRunning }
|
||||
)
|
||||
|
||||
const { data: jobPoll } = trpc.specialAward.getEligibilityJobStatus.useQuery(
|
||||
{ awardId },
|
||||
{ enabled: isRunning, refetchInterval: 3000 }
|
||||
)
|
||||
|
||||
const runMutation = trpc.specialAward.runEligibilityForRound.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Eligibility evaluation started')
|
||||
utils.specialAward.getEligibilityJobStatus.invalidate({ awardId })
|
||||
utils.specialAward.listForRound.invalidate({ roundId })
|
||||
},
|
||||
onError: (err) => toast.error(`Failed: ${err.message}`),
|
||||
})
|
||||
|
||||
const toggleMutation = trpc.specialAward.toggleShortlisted.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.specialAward.listShortlist.invalidate({ awardId })
|
||||
utils.specialAward.listForRound.invalidate({ roundId })
|
||||
toast.success(data.shortlisted ? 'Added to shortlist' : 'Removed from shortlist')
|
||||
},
|
||||
onError: (err) => toast.error(`Failed: ${err.message}`),
|
||||
})
|
||||
|
||||
const confirmMutation = trpc.specialAward.confirmShortlist.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.specialAward.listShortlist.invalidate({ awardId })
|
||||
utils.specialAward.listForRound.invalidate({ roundId })
|
||||
toast.success(
|
||||
`Confirmed ${data.confirmedCount} projects` +
|
||||
(data.routedCount > 0 ? ` — ${data.routedCount} routed to award track` : '')
|
||||
)
|
||||
},
|
||||
onError: (err) => toast.error(`Failed: ${err.message}`),
|
||||
})
|
||||
|
||||
const currentJobStatus = jobPoll?.eligibilityJobStatus ?? jobStatus
|
||||
const currentJobDone = jobPoll?.eligibilityJobDone ?? jobDone
|
||||
const currentJobTotal = jobPoll?.eligibilityJobTotal ?? jobTotal
|
||||
const jobProgress = currentJobTotal && currentJobTotal > 0
|
||||
? Math.round(((currentJobDone ?? 0) / currentJobTotal) * 100)
|
||||
: 0
|
||||
|
||||
const shortlistedCount = shortlist?.eligibilities?.filter((e) => e.shortlisted).length ?? 0
|
||||
|
||||
return (
|
||||
<Collapsible open={expanded} onOpenChange={setExpanded}>
|
||||
<div className="border rounded-lg">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<Trophy className="h-5 w-5 text-amber-600" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm">{awardName}</h4>
|
||||
{criteriaText && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-1 max-w-md">
|
||||
{criteriaText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className={eligibilityMode === 'SEPARATE_POOL'
|
||||
? 'bg-purple-50 text-purple-700 border-purple-200'
|
||||
: 'bg-blue-50 text-blue-700 border-blue-200'
|
||||
}>
|
||||
{eligibilityMode === 'SEPARATE_POOL' ? 'Separate Pool' : 'Main Pool'}
|
||||
</Badge>
|
||||
{currentJobStatus === 'COMPLETED' && (
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
Evaluated
|
||||
</Badge>
|
||||
)}
|
||||
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="border-t p-4 space-y-4">
|
||||
{/* Job controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Evaluate PASSED projects against this award's criteria
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => runMutation.mutate({ awardId, roundId })}
|
||||
disabled={runMutation.isPending || isRunning}
|
||||
>
|
||||
{isRunning ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Processing...</>
|
||||
) : runMutation.isPending ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Starting...</>
|
||||
) : currentJobStatus === 'COMPLETED' ? (
|
||||
<><Play className="h-4 w-4 mr-2" />Re-evaluate</>
|
||||
) : (
|
||||
<><Play className="h-4 w-4 mr-2" />Run Eligibility</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{isRunning && currentJobTotal && currentJobTotal > 0 && (
|
||||
<div className="space-y-1">
|
||||
<Progress value={jobProgress} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{currentJobDone ?? 0} / {currentJobTotal} projects
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shortlist table */}
|
||||
{expanded && currentJobStatus === 'COMPLETED' && (
|
||||
<>
|
||||
{isLoadingShortlist ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
|
||||
</div>
|
||||
) : shortlist && shortlist.eligibilities.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">
|
||||
{shortlist.total} eligible projects
|
||||
{shortlistedCount > 0 && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({shortlistedCount} shortlisted)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{shortlistedCount > 0 && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="default">
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
Confirm Shortlist ({shortlistedCount})
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Shortlist</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{eligibilityMode === 'SEPARATE_POOL'
|
||||
? `This will confirm ${shortlistedCount} projects for the "${awardName}" award track. Projects will be routed to the award's rounds for separate evaluation.`
|
||||
: `This will confirm ${shortlistedCount} projects as eligible for the "${awardName}" award. Projects remain in the main competition pool.`
|
||||
}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => confirmMutation.mutate({ awardId })}
|
||||
disabled={confirmMutation.isPending}
|
||||
>
|
||||
{confirmMutation.isPending ? 'Confirming...' : 'Confirm'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left w-8">#</th>
|
||||
<th className="px-3 py-2 text-left">Project</th>
|
||||
<th className="px-3 py-2 text-left w-24">Score</th>
|
||||
<th className="px-3 py-2 text-left w-32">Reasoning</th>
|
||||
<th className="px-3 py-2 text-center w-20">Shortlist</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shortlist.eligibilities.map((e, i) => {
|
||||
const reasoning = (e.aiReasoningJson as Record<string, unknown>)?.reasoning as string | undefined
|
||||
return (
|
||||
<tr key={e.id} className={`border-t ${e.shortlisted ? 'bg-amber-50/50' : ''}`}>
|
||||
<td className="px-3 py-2 text-muted-foreground font-mono">
|
||||
{i + 1}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div>
|
||||
<p className="font-medium">{e.project.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{e.project.teamName || e.project.country || e.project.competitionCategory || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={e.qualityScore ?? 0}
|
||||
className="h-2 w-16"
|
||||
/>
|
||||
<span className="text-xs font-mono font-medium">
|
||||
{e.qualityScore ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{reasoning ? (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2" title={reasoning}>
|
||||
{reasoning}
|
||||
</p>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Checkbox
|
||||
checked={e.shortlisted}
|
||||
onCheckedChange={() =>
|
||||
toggleMutation.mutate({ awardId, projectId: e.project.id })
|
||||
}
|
||||
disabled={toggleMutation.isPending}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No eligible projects found
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Not yet evaluated */}
|
||||
{expanded && !currentJobStatus && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Click "Run Eligibility" to evaluate projects against this award's criteria
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Failed */}
|
||||
{currentJobStatus === 'FAILED' && (
|
||||
<p className="text-sm text-red-600 text-center py-2">
|
||||
Eligibility evaluation failed. Try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -45,11 +45,11 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
Award,
|
||||
Play,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -68,10 +68,11 @@ import {
|
||||
FileText,
|
||||
Brain,
|
||||
ListFilter,
|
||||
GripVertical,
|
||||
Settings2,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { AwardShortlist } from './award-shortlist'
|
||||
|
||||
type FilteringDashboardProps = {
|
||||
competitionId: string
|
||||
@@ -103,17 +104,29 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// AI criteria state
|
||||
const [criteriaText, setCriteriaText] = useState('')
|
||||
const [aiAction, setAiAction] = useState<'PASS' | 'REJECT' | 'FLAG'>('FLAG')
|
||||
const [batchSize, setBatchSize] = useState(20)
|
||||
const [parallelBatches, setParallelBatches] = useState(3)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [criteriaDirty, setCriteriaDirty] = useState(false)
|
||||
const criteriaLoaded = useRef(false)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// -- Queries --
|
||||
const { data: stats, isLoading: statsLoading } = trpc.filtering.getResultStats.useQuery(
|
||||
const { data: latestJob } = trpc.filtering.getLatestJob.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 15_000 },
|
||||
{ refetchInterval: pollingJobId ? 3_000 : 15_000 },
|
||||
)
|
||||
|
||||
const { data: latestJob, isLoading: jobLoading } = trpc.filtering.getLatestJob.useQuery(
|
||||
// Dynamic refetch: 3s during running job, 15s otherwise
|
||||
const isRunning = !!pollingJobId || latestJob?.status === 'RUNNING'
|
||||
|
||||
const { data: stats, isLoading: statsLoading } = trpc.filtering.getResultStats.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 15_000 },
|
||||
{ refetchInterval: isRunning ? 3_000 : 15_000 },
|
||||
)
|
||||
|
||||
const { data: rules } = trpc.filtering.getRules.useQuery(
|
||||
@@ -128,7 +141,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
page,
|
||||
perPage: 25,
|
||||
},
|
||||
{ refetchInterval: 15_000 },
|
||||
{ refetchInterval: isRunning ? 3_000 : 15_000 },
|
||||
)
|
||||
|
||||
const { data: jobStatus } = trpc.filtering.getJobStatus.useQuery(
|
||||
@@ -139,6 +152,19 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
},
|
||||
)
|
||||
|
||||
// Load AI criteria from existing rule
|
||||
const aiRule = rules?.find((r: any) => r.ruleType === 'AI_SCREENING')
|
||||
useEffect(() => {
|
||||
if (aiRule && !criteriaLoaded.current) {
|
||||
const config = (aiRule.configJson || {}) as Record<string, unknown>
|
||||
setCriteriaText((config.criteriaText as string) || '')
|
||||
setAiAction((config.action as 'PASS' | 'REJECT' | 'FLAG') || 'FLAG')
|
||||
setBatchSize((config.batchSize as number) || 20)
|
||||
setParallelBatches((config.parallelBatches as number) || 3)
|
||||
criteriaLoaded.current = true
|
||||
}
|
||||
}, [aiRule])
|
||||
|
||||
// Stop polling when job completes
|
||||
useEffect(() => {
|
||||
if (jobStatus && (jobStatus.status === 'COMPLETED' || jobStatus.status === 'FAILED')) {
|
||||
@@ -162,6 +188,16 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
}, [latestJob])
|
||||
|
||||
// -- Mutations --
|
||||
const createRuleMutation = trpc.filtering.createRule.useMutation({
|
||||
onSuccess: () => utils.filtering.getRules.invalidate({ roundId }),
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateRuleMutation = trpc.filtering.updateRule.useMutation({
|
||||
onSuccess: () => utils.filtering.getRules.invalidate({ roundId }),
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const startJobMutation = trpc.filtering.startJob.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setPollingJobId(data.jobId)
|
||||
@@ -212,8 +248,59 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
})
|
||||
|
||||
// -- Handlers --
|
||||
const handleStartJob = () => {
|
||||
const handleSaveAndRun = async () => {
|
||||
if (!criteriaText.trim()) {
|
||||
toast.error('Please write screening criteria first')
|
||||
return
|
||||
}
|
||||
|
||||
const configJson = {
|
||||
criteriaText,
|
||||
action: aiAction,
|
||||
batchSize,
|
||||
parallelBatches,
|
||||
}
|
||||
|
||||
try {
|
||||
if (aiRule) {
|
||||
await updateRuleMutation.mutateAsync({ id: (aiRule as any).id, configJson })
|
||||
} else {
|
||||
await createRuleMutation.mutateAsync({
|
||||
roundId,
|
||||
name: 'AI Screening',
|
||||
ruleType: 'AI_SCREENING',
|
||||
configJson,
|
||||
priority: 0,
|
||||
})
|
||||
}
|
||||
setCriteriaDirty(false)
|
||||
startJobMutation.mutate({ roundId })
|
||||
} catch {
|
||||
// Error handled by mutation onError
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveCriteria = async () => {
|
||||
const configJson = {
|
||||
criteriaText,
|
||||
action: aiAction,
|
||||
batchSize,
|
||||
parallelBatches,
|
||||
}
|
||||
|
||||
if (aiRule) {
|
||||
await updateRuleMutation.mutateAsync({ id: (aiRule as any).id, configJson })
|
||||
} else {
|
||||
await createRuleMutation.mutateAsync({
|
||||
roundId,
|
||||
name: 'AI Screening',
|
||||
ruleType: 'AI_SCREENING',
|
||||
configJson,
|
||||
priority: 0,
|
||||
})
|
||||
}
|
||||
setCriteriaDirty(false)
|
||||
toast.success('Criteria saved')
|
||||
}
|
||||
|
||||
const handleOverride = () => {
|
||||
@@ -275,14 +362,25 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
|
||||
const parseAIData = (json: unknown): AIScreeningData | null => {
|
||||
if (!json || typeof json !== 'object') return null
|
||||
return json as AIScreeningData
|
||||
const obj = json as Record<string, unknown>
|
||||
// aiScreeningJson is nested under rule ID: { [ruleId]: { outcome, confidence, ... } }
|
||||
// Unwrap first entry if top-level keys don't include expected AI fields
|
||||
if (!('outcome' in obj)) {
|
||||
const keys = Object.keys(obj)
|
||||
if (keys.length > 0) {
|
||||
const inner = obj[keys[0]]
|
||||
if (inner && typeof inner === 'object') return inner as AIScreeningData
|
||||
}
|
||||
return null
|
||||
}
|
||||
return obj as unknown as AIScreeningData
|
||||
}
|
||||
|
||||
// Is there a running job?
|
||||
const isRunning = !!pollingJobId || latestJob?.status === 'RUNNING'
|
||||
const activeJob = jobStatus || (latestJob?.status === 'RUNNING' ? latestJob : null)
|
||||
const hasResults = stats && stats.total > 0
|
||||
const hasRules = rules && rules.length > 0
|
||||
const nonAiRules = rules?.filter((r: any) => r.ruleType !== 'AI_SCREENING') ?? []
|
||||
const hasNonAiRules = nonAiRules.length > 0
|
||||
const isSaving = createRuleMutation.isPending || updateRuleMutation.isPending
|
||||
|
||||
// Filter results by search query (client-side)
|
||||
const displayResults = resultsPage?.results.filter((r: any) => {
|
||||
@@ -296,35 +394,48 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Job Control */}
|
||||
{/* Main Card: AI Screening Criteria + Controls */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">AI Filtering</CardTitle>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Brain className="h-5 w-5 text-purple-600" />
|
||||
AI Screening
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Run AI screening against {hasRules ? rules.length : 0} active rule{rules?.length !== 1 ? 's' : ''}
|
||||
Write all your screening criteria below — the AI evaluates each project against them
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{criteriaDirty && (
|
||||
<Button
|
||||
onClick={handleStartJob}
|
||||
disabled={isRunning || startJobMutation.isPending || !hasRules}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSaveCriteria}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveAndRun}
|
||||
disabled={isRunning || startJobMutation.isPending || !criteriaText.trim()}
|
||||
>
|
||||
{isRunning ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Running...</>
|
||||
) : (
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
<><Play className="h-4 w-4 mr-2" />Run Filtering</>
|
||||
)}
|
||||
{isRunning ? 'Running...' : 'Run Filtering'}
|
||||
</Button>
|
||||
{hasResults && (
|
||||
{hasResults && !isRunning && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={isRunning || finalizeMutation.isPending}>
|
||||
<Button variant="outline" size="sm" disabled={finalizeMutation.isPending}>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Finalize Results
|
||||
Finalize
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
@@ -353,16 +464,76 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
placeholder={`Write all your screening criteria here. The AI will evaluate each project against these requirements.\n\nExample:\n1. Project must have clear ocean conservation impact\n2. Documents should be in English or French\n3. For Business Concepts, academic rigor is acceptable\n4. For African projects, apply a lower quality threshold (score >= 5/10)\n5. Flag any submissions that appear to be AI-generated filler`}
|
||||
value={criteriaText}
|
||||
onChange={(e) => { setCriteriaText(e.target.value); setCriteriaDirty(true) }}
|
||||
rows={8}
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Available data per project: category, country, region, founded year, ocean issue, tags,
|
||||
description, file details (type, pages, size, detected language), and team size.
|
||||
</p>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
Advanced Settings
|
||||
{advancedOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3">
|
||||
<div className="grid grid-cols-3 gap-3 rounded-lg border p-3 bg-muted/20">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground mb-1 block">Default Action</Label>
|
||||
<Select value={aiAction} onValueChange={(v) => { setAiAction(v as 'PASS' | 'REJECT' | 'FLAG'); setCriteriaDirty(true) }}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="FLAG">Flag for review</SelectItem>
|
||||
<SelectItem value="REJECT">Auto-reject failures</SelectItem>
|
||||
<SelectItem value="PASS">Auto-pass matches</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground mb-1 block">Batch Size</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
className="h-8 text-xs"
|
||||
value={batchSize}
|
||||
onChange={(e) => { setBatchSize(parseInt(e.target.value) || 20); setCriteriaDirty(true) }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground mb-1 block">Parallel Batches</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
className="h-8 text-xs"
|
||||
value={parallelBatches}
|
||||
onChange={(e) => { setParallelBatches(parseInt(e.target.value) || 3); setCriteriaDirty(true) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Job Progress */}
|
||||
{isRunning && activeJob && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 pt-3 border-t">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
<span className="text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Processing batch {activeJob.currentBatch} of {activeJob.totalBatches || '?'}
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
<span className="font-mono text-sm">
|
||||
{activeJob.processedCount}/{activeJob.totalProjects}
|
||||
</span>
|
||||
</div>
|
||||
@@ -372,43 +543,34 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Results appear in the table below as each batch completes
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{/* Last job summary */}
|
||||
{!isRunning && latestJob && latestJob.status === 'COMPLETED' && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground pt-3 border-t">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
Last run completed: {latestJob.passedCount} passed, {latestJob.filteredCount} filtered, {latestJob.flaggedCount} flagged
|
||||
Last run: {latestJob.passedCount} passed, {latestJob.filteredCount} filtered, {latestJob.flaggedCount} flagged
|
||||
<span className="text-xs">
|
||||
({new Date(latestJob.completedAt!).toLocaleDateString()})
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{!isRunning && latestJob && latestJob.status === 'FAILED' && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center gap-2 text-sm text-red-600">
|
||||
<div className="flex items-center gap-2 text-sm text-red-600 pt-3 border-t">
|
||||
<XCircle className="h-4 w-4" />
|
||||
Last run failed: {latestJob.errorMessage || 'Unknown error'}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{!hasRules && (
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-sm text-amber-600">
|
||||
No active filtering rules configured. Add rules in the Configuration tab first.
|
||||
</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Filtering Rules */}
|
||||
<FilteringRulesSection roundId={roundId} />
|
||||
{/* Additional Rules (Field-Based & Document Checks) */}
|
||||
<AdditionalRulesSection roundId={roundId} hasNonAiRules={hasNonAiRules} />
|
||||
|
||||
{/* Stats Cards */}
|
||||
{statsLoading ? (
|
||||
@@ -477,7 +639,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
) : null}
|
||||
|
||||
{/* Results Table */}
|
||||
{(hasResults || resultsLoading) && (
|
||||
{(hasResults || resultsLoading || isRunning) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
@@ -485,6 +647,11 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
<CardTitle className="text-base">Filtering Results</CardTitle>
|
||||
<CardDescription>
|
||||
Review AI screening outcomes — click a row to see reasoning, use quick buttons to override
|
||||
{isRunning && (
|
||||
<span className="ml-2 text-purple-600 font-medium animate-pulse">
|
||||
New results streaming in...
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -573,7 +740,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
const isExpanded = expandedId === result.id
|
||||
|
||||
return (
|
||||
<div key={result.id} className="border-b last:border-b-0">
|
||||
<div key={result.id} className="border-b last:border-b-0 animate-in fade-in-0 duration-300">
|
||||
{/* Main Row */}
|
||||
<div
|
||||
className="grid grid-cols-[40px_1fr_120px_100px_70px_70px_120px] gap-2 px-3 py-2.5 items-center hover:bg-muted/50 text-sm cursor-pointer"
|
||||
@@ -816,10 +983,10 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Sparkles className="h-8 w-8 text-muted-foreground mb-3" />
|
||||
<p className="text-sm font-medium">
|
||||
{searchQuery.trim() ? 'No results match your search' : 'No results yet'}
|
||||
{searchQuery.trim() ? 'No results match your search' : isRunning ? 'Results will appear here as batches complete' : 'No results yet'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{searchQuery.trim() ? 'Try a different search term' : 'Run the filtering job to screen projects'}
|
||||
{searchQuery.trim() ? 'Try a different search term' : isRunning ? 'The AI is processing projects...' : 'Run filtering to screen projects'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -827,6 +994,9 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Special Award Tracks */}
|
||||
{hasResults && <AwardTracksSection competitionId={competitionId} roundId={roundId} />}
|
||||
|
||||
{/* Single Override Dialog (with reason) */}
|
||||
<Dialog open={overrideDialogOpen} onOpenChange={setOverrideDialogOpen}>
|
||||
<DialogContent>
|
||||
@@ -948,14 +1118,13 @@ function ConfidenceIndicator({ value }: { value: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Filtering Rules Section ────────────────────────────────────────────────
|
||||
// ─── Additional Rules Section (Field-Based & Document Checks only) ──────────
|
||||
|
||||
type RuleType = 'FIELD_BASED' | 'DOCUMENT_CHECK' | 'AI_SCREENING'
|
||||
type RuleType = 'FIELD_BASED' | 'DOCUMENT_CHECK'
|
||||
|
||||
const RULE_TYPE_META: Record<RuleType, { label: string; icon: typeof ListFilter; color: string; description: string }> = {
|
||||
FIELD_BASED: { label: 'Field-Based', icon: ListFilter, color: 'bg-blue-100 text-blue-800 border-blue-200', description: 'Evaluate project fields (category, founding date, location, etc.)' },
|
||||
DOCUMENT_CHECK: { label: 'Document Check', icon: FileText, color: 'bg-teal-100 text-teal-800 border-teal-200', description: 'Validate file uploads (min count, formats, page limits)' },
|
||||
AI_SCREENING: { label: 'AI Screening', icon: Brain, color: 'bg-purple-100 text-purple-800 border-purple-200', description: 'GPT evaluates projects against natural language criteria' },
|
||||
}
|
||||
|
||||
const FIELD_OPTIONS = [
|
||||
@@ -997,11 +1166,6 @@ type RuleFormData = {
|
||||
maxPages: number | ''
|
||||
maxPagesByFileType: Record<string, number>
|
||||
docAction: 'PASS' | 'REJECT' | 'FLAG'
|
||||
// AI_SCREENING
|
||||
criteriaText: string
|
||||
aiAction: 'PASS' | 'REJECT' | 'FLAG'
|
||||
batchSize: number
|
||||
parallelBatches: number
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: RuleFormData = {
|
||||
@@ -1016,10 +1180,6 @@ const DEFAULT_FORM: RuleFormData = {
|
||||
maxPages: '',
|
||||
maxPagesByFileType: {},
|
||||
docAction: 'REJECT',
|
||||
criteriaText: '',
|
||||
aiAction: 'FLAG',
|
||||
batchSize: 20,
|
||||
parallelBatches: 1,
|
||||
}
|
||||
|
||||
function buildConfigJson(form: RuleFormData): Record<string, unknown> {
|
||||
@@ -1044,13 +1204,6 @@ function buildConfigJson(form: RuleFormData): Record<string, unknown> {
|
||||
if (Object.keys(form.maxPagesByFileType).length > 0) config.maxPagesByFileType = form.maxPagesByFileType
|
||||
return config
|
||||
}
|
||||
case 'AI_SCREENING':
|
||||
return {
|
||||
criteriaText: form.criteriaText,
|
||||
action: form.aiAction,
|
||||
batchSize: form.batchSize,
|
||||
parallelBatches: form.parallelBatches,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1075,21 +1228,13 @@ function parseConfigToForm(rule: { name: string; ruleType: string; configJson: u
|
||||
maxPagesByFileType: (config.maxPagesByFileType as Record<string, number>) || {},
|
||||
docAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'REJECT',
|
||||
}
|
||||
case 'AI_SCREENING':
|
||||
return {
|
||||
...base,
|
||||
criteriaText: (config.criteriaText as string) || '',
|
||||
aiAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'FLAG',
|
||||
batchSize: (config.batchSize as number) || 20,
|
||||
parallelBatches: (config.parallelBatches as number) || 1,
|
||||
}
|
||||
default:
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
function AdditionalRulesSection({ roundId, hasNonAiRules }: { roundId: string; hasNonAiRules: boolean }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingRule, setEditingRule] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<RuleFormData>({ ...DEFAULT_FORM })
|
||||
@@ -1097,11 +1242,14 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: rules, isLoading } = trpc.filtering.getRules.useQuery(
|
||||
const { data: allRules, isLoading } = trpc.filtering.getRules.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
|
||||
// Only show non-AI rules
|
||||
const rules = allRules?.filter((r: any) => r.ruleType !== 'AI_SCREENING') ?? []
|
||||
|
||||
const createMutation = trpc.filtering.createRule.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.filtering.getRules.invalidate({ roundId })
|
||||
@@ -1156,12 +1304,10 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingRule(null)
|
||||
setForm({ ...DEFAULT_FORM, priority: (rules?.length ?? 0) })
|
||||
setForm({ ...DEFAULT_FORM, priority: rules.length })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const meta = RULE_TYPE_META[form.ruleType]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
@@ -1172,9 +1318,9 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
<div className="flex items-center gap-3">
|
||||
<ListFilter className="h-5 w-5 text-[#053d57]" />
|
||||
<div>
|
||||
<CardTitle className="text-base">Filtering Rules</CardTitle>
|
||||
<CardTitle className="text-base">Additional Rules</CardTitle>
|
||||
<CardDescription>
|
||||
{rules?.length ?? 0} active rule{(rules?.length ?? 0) !== 1 ? 's' : ''} — executed in priority order
|
||||
{rules.length} field-based / document check rule{rules.length !== 1 ? 's' : ''} — applied alongside AI screening
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1199,7 +1345,7 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
|
||||
</div>
|
||||
) : rules && rules.length > 0 ? (
|
||||
) : rules.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{rules.map((rule: any, idx: number) => {
|
||||
const typeMeta = RULE_TYPE_META[rule.ruleType as RuleType] || RULE_TYPE_META.FIELD_BASED
|
||||
@@ -1212,7 +1358,6 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
className="flex items-center gap-3 rounded-lg border p-3 bg-background hover:bg-muted/30 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<GripVertical className="h-4 w-4 opacity-0 group-hover:opacity-50" />
|
||||
<span className="text-xs font-mono w-5 text-center">{idx + 1}</span>
|
||||
</div>
|
||||
|
||||
@@ -1234,17 +1379,9 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
{config.minFileCount ? `Min ${config.minFileCount} files` : ''}
|
||||
{config.requiredFileTypes ? ` \u00b7 Types: ${(config.requiredFileTypes as string[]).join(', ')}` : ''}
|
||||
{config.maxPages ? ` \u00b7 Max ${config.maxPages} pages` : ''}
|
||||
{config.maxPagesByFileType && Object.keys(config.maxPagesByFileType as object).length > 0
|
||||
? ` \u00b7 Page limits per type`
|
||||
: ''}
|
||||
{' \u2192 '}{config.action as string}
|
||||
</>
|
||||
)}
|
||||
{rule.ruleType === 'AI_SCREENING' && (
|
||||
<>
|
||||
{((config.criteriaText as string) || '').substring(0, 80)}{((config.criteriaText as string) || '').length > 80 ? '...' : ''} → {config.action as string}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1277,15 +1414,14 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<ListFilter className="h-8 w-8 text-muted-foreground mb-3" />
|
||||
<p className="text-sm font-medium">No filtering rules configured</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-3">
|
||||
Add rules to define how projects are screened
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<ListFilter className="h-6 w-6 text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No additional rules — AI screening criteria handle everything
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={openCreate}>
|
||||
<Button variant="outline" size="sm" className="mt-2" onClick={openCreate}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add First Rule
|
||||
Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1301,9 +1437,9 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
}}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingRule ? 'Edit Rule' : 'Create Filtering Rule'}</DialogTitle>
|
||||
<DialogTitle>{editingRule ? 'Edit Rule' : 'Create Rule'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingRule ? 'Update this filtering rule configuration' : 'Define a new rule for screening projects'}
|
||||
{editingRule ? 'Update this filtering rule' : 'Add a field-based or document check rule'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -1329,10 +1465,10 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rule Type Selector */}
|
||||
{/* Rule Type Selector (only Field-Based and Document Check) */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">Rule Type</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(Object.entries(RULE_TYPE_META) as [RuleType, typeof RULE_TYPE_META[RuleType]][]).map(([type, m]) => {
|
||||
const Icon = m.icon
|
||||
const selected = form.ruleType === type
|
||||
@@ -1361,7 +1497,6 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
<div className="space-y-3 rounded-lg border p-4 bg-muted/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Conditions</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={form.logic} onValueChange={(v) => setForm((f) => ({ ...f, logic: v as 'AND' | 'OR' }))}>
|
||||
<SelectTrigger className="w-20 h-7 text-xs">
|
||||
<SelectValue />
|
||||
@@ -1372,7 +1507,6 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.conditions.map((cond, i) => {
|
||||
const fieldMeta = FIELD_OPTIONS.find((f) => f.value === cond.field)
|
||||
@@ -1586,62 +1720,6 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.ruleType === 'AI_SCREENING' && (
|
||||
<div className="space-y-4 rounded-lg border p-4 bg-muted/20">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground mb-1.5 block">Screening Criteria</Label>
|
||||
<Textarea
|
||||
placeholder="Write the criteria the AI should evaluate against. Example: 1. Ocean conservation impact must be clearly stated 2. Documents must be in English 3. For Business Concepts, academic rigor is acceptable 4. For African projects, apply a lower quality threshold (score >= 5/10)"
|
||||
value={form.criteriaText}
|
||||
onChange={(e) => setForm((f) => ({ ...f, criteriaText: e.target.value }))}
|
||||
rows={8}
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
The AI has access to: category, country, region, founded year, ocean issue, tags, description, file details (type, page count, size, detected language), and team size.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground mb-1 block">Action</Label>
|
||||
<Select value={form.aiAction} onValueChange={(v) => setForm((f) => ({ ...f, aiAction: v as any }))}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="FLAG">Flag for review</SelectItem>
|
||||
<SelectItem value="REJECT">Auto-reject</SelectItem>
|
||||
<SelectItem value="PASS">Auto-pass</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground mb-1 block">Batch Size</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
className="h-8 text-xs"
|
||||
value={form.batchSize}
|
||||
onChange={(e) => setForm((f) => ({ ...f, batchSize: parseInt(e.target.value) || 20 }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground mb-1 block">Parallel Batches</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
className="h-8 text-xs"
|
||||
value={form.parallelBatches}
|
||||
onChange={(e) => setForm((f) => ({ ...f, parallelBatches: parseInt(e.target.value) || 1 }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -1685,3 +1763,45 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Award Tracks Section ───────────────────────────────────────────────────
|
||||
|
||||
function AwardTracksSection({ competitionId, roundId }: { competitionId: string; roundId: string }) {
|
||||
const { data: awards, isLoading } = trpc.specialAward.listForRound.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: !!roundId }
|
||||
)
|
||||
|
||||
if (isLoading) return null
|
||||
if (!awards || awards.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Award className="h-5 w-5 text-amber-600" />
|
||||
Special Award Tracks
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Evaluate passed projects against special award criteria and manage shortlists
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{awards.map((award) => (
|
||||
<AwardShortlist
|
||||
key={award.id}
|
||||
awardId={award.id}
|
||||
roundId={roundId}
|
||||
awardName={award.name}
|
||||
criteriaText={award.criteriaText}
|
||||
eligibilityMode={award.eligibilityMode}
|
||||
shortlistSize={award.shortlistSize}
|
||||
jobStatus={award.eligibilityJobStatus}
|
||||
jobTotal={award.eligibilityJobTotal}
|
||||
jobDone={award.eligibilityJobDone}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,20 @@ function getAIConfidenceScore(aiScreeningJson: Prisma.JsonValue | null): number
|
||||
if (!aiScreeningJson || typeof aiScreeningJson !== 'object' || Array.isArray(aiScreeningJson)) {
|
||||
return 0
|
||||
}
|
||||
const obj = aiScreeningJson as Record<string, unknown>
|
||||
for (const key of ['overallScore', 'confidenceScore', 'score', 'qualityScore']) {
|
||||
let obj = aiScreeningJson as Record<string, unknown>
|
||||
// aiScreeningJson is nested under rule ID: { [ruleId]: { confidence, ... } }
|
||||
// Unwrap if top-level keys don't include expected score fields
|
||||
const scoreKeys = ['overallScore', 'confidenceScore', 'score', 'qualityScore', 'confidence']
|
||||
if (!scoreKeys.some((k) => k in obj)) {
|
||||
const keys = Object.keys(obj)
|
||||
if (keys.length > 0) {
|
||||
const inner = obj[keys[0]]
|
||||
if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
|
||||
obj = inner as Record<string, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of scoreKeys) {
|
||||
if (typeof obj[key] === 'number') {
|
||||
return obj[key] as number
|
||||
}
|
||||
@@ -146,17 +158,11 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
||||
})
|
||||
}
|
||||
|
||||
// Execute rules
|
||||
const results = await executeFilteringRules(rules, projects, userId, roundId, onProgress)
|
||||
|
||||
// Count outcomes
|
||||
const passedCount = results.filter((r) => r.outcome === 'PASSED').length
|
||||
const filteredCount = results.filter((r) => r.outcome === 'FILTERED_OUT').length
|
||||
const flaggedCount = results.filter((r) => r.outcome === 'FLAGGED').length
|
||||
|
||||
// Upsert results
|
||||
// Execute rules — upsert results per batch for streaming to the UI
|
||||
const results = await executeFilteringRules(rules, projects, userId, roundId, onProgress, async (batchResults) => {
|
||||
if (batchResults.length === 0) return
|
||||
await prisma.$transaction(
|
||||
results.map((r) =>
|
||||
batchResults.map((r) =>
|
||||
prisma.filteringResult.upsert({
|
||||
where: {
|
||||
roundId_projectId: {
|
||||
@@ -183,6 +189,12 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
// Count outcomes
|
||||
const passedCount = results.filter((r) => r.outcome === 'PASSED').length
|
||||
const filteredCount = results.filter((r) => r.outcome === 'FILTERED_OUT').length
|
||||
const flaggedCount = results.filter((r) => r.outcome === 'FLAGGED').length
|
||||
|
||||
// Mark job as completed
|
||||
await prisma.filteringJob.update({
|
||||
|
||||
@@ -764,4 +764,372 @@ export const specialAwardRouter = router({
|
||||
|
||||
return award
|
||||
}),
|
||||
|
||||
// ─── Round-Scoped Eligibility & Shortlists ──────────────────────────────
|
||||
|
||||
/**
|
||||
* List awards for a competition (from a filtering round context)
|
||||
*/
|
||||
listForRound: protectedProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Get competition from round
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { competitionId: true },
|
||||
})
|
||||
|
||||
const awards = await ctx.prisma.specialAward.findMany({
|
||||
where: { competitionId: round.competitionId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
eligibilities: { where: { eligible: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Get shortlisted counts
|
||||
const shortlistedCounts = await ctx.prisma.awardEligibility.groupBy({
|
||||
by: ['awardId'],
|
||||
where: {
|
||||
awardId: { in: awards.map((a) => a.id) },
|
||||
shortlisted: true,
|
||||
},
|
||||
_count: true,
|
||||
})
|
||||
const shortlistMap = new Map(shortlistedCounts.map((s) => [s.awardId, s._count]))
|
||||
|
||||
return awards.map((a) => ({
|
||||
...a,
|
||||
shortlistedCount: shortlistMap.get(a.id) ?? 0,
|
||||
}))
|
||||
}),
|
||||
|
||||
/**
|
||||
* Run eligibility scoped to a filtering round's PASSED projects
|
||||
*/
|
||||
runEligibilityForRound: adminProcedure
|
||||
.input(z.object({
|
||||
awardId: z.string(),
|
||||
roundId: z.string(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Set job status to PENDING
|
||||
await ctx.prisma.specialAward.update({
|
||||
where: { id: input.awardId },
|
||||
data: {
|
||||
eligibilityJobStatus: 'PENDING',
|
||||
eligibilityJobTotal: null,
|
||||
eligibilityJobDone: null,
|
||||
eligibilityJobError: null,
|
||||
eligibilityJobStarted: null,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.awardId,
|
||||
detailsJson: { action: 'RUN_ELIGIBILITY_FOR_ROUND', roundId: input.roundId },
|
||||
})
|
||||
|
||||
// Fire and forget - process in background with round scoping
|
||||
void processEligibilityJob(
|
||||
input.awardId,
|
||||
true, // include submitted
|
||||
ctx.user.id,
|
||||
input.roundId
|
||||
)
|
||||
|
||||
return { started: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get ranked shortlist for an award
|
||||
*/
|
||||
listShortlist: protectedProcedure
|
||||
.input(z.object({
|
||||
awardId: z.string(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
perPage: z.number().int().min(1).max(100).default(50),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { awardId, page, perPage } = input
|
||||
const skip = (page - 1) * perPage
|
||||
|
||||
const [eligibilities, total, award] = await Promise.all([
|
||||
ctx.prisma.awardEligibility.findMany({
|
||||
where: { awardId, eligible: true },
|
||||
skip,
|
||||
take: perPage,
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
competitionCategory: true,
|
||||
country: true,
|
||||
tags: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { qualityScore: 'desc' },
|
||||
}),
|
||||
ctx.prisma.awardEligibility.count({ where: { awardId, eligible: true } }),
|
||||
ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: awardId },
|
||||
select: { shortlistSize: true, eligibilityMode: true, name: true },
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
eligibilities,
|
||||
total,
|
||||
page,
|
||||
perPage,
|
||||
totalPages: Math.ceil(total / perPage),
|
||||
shortlistSize: award.shortlistSize,
|
||||
eligibilityMode: award.eligibilityMode,
|
||||
awardName: award.name,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Toggle shortlisted status for a project-award pair
|
||||
*/
|
||||
toggleShortlisted: adminProcedure
|
||||
.input(z.object({
|
||||
awardId: z.string(),
|
||||
projectId: z.string(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const current = await ctx.prisma.awardEligibility.findUniqueOrThrow({
|
||||
where: {
|
||||
awardId_projectId: {
|
||||
awardId: input.awardId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
},
|
||||
select: { shortlisted: true },
|
||||
})
|
||||
|
||||
const updated = await ctx.prisma.awardEligibility.update({
|
||||
where: {
|
||||
awardId_projectId: {
|
||||
awardId: input.awardId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
},
|
||||
data: { shortlisted: !current.shortlisted },
|
||||
})
|
||||
|
||||
return { shortlisted: updated.shortlisted }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Confirm shortlist — for SEPARATE_POOL awards, creates ProjectRoundState entries
|
||||
*/
|
||||
confirmShortlist: adminProcedure
|
||||
.input(z.object({ awardId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
select: { eligibilityMode: true, name: true, competitionId: true },
|
||||
})
|
||||
|
||||
// Get shortlisted projects
|
||||
const shortlisted = await ctx.prisma.awardEligibility.findMany({
|
||||
where: { awardId: input.awardId, shortlisted: true, eligible: true },
|
||||
select: { projectId: true },
|
||||
})
|
||||
|
||||
if (shortlisted.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No shortlisted projects to confirm',
|
||||
})
|
||||
}
|
||||
|
||||
// Mark all as confirmed
|
||||
await ctx.prisma.awardEligibility.updateMany({
|
||||
where: { awardId: input.awardId, shortlisted: true, eligible: true },
|
||||
data: {
|
||||
confirmedAt: new Date(),
|
||||
confirmedBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
// For SEPARATE_POOL: create ProjectRoundState entries in award rounds (if any exist)
|
||||
let routedCount = 0
|
||||
if (award.eligibilityMode === 'SEPARATE_POOL') {
|
||||
const awardRounds = await ctx.prisma.round.findMany({
|
||||
where: { specialAwardId: input.awardId },
|
||||
select: { id: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
if (awardRounds.length > 0) {
|
||||
const firstRound = awardRounds[0]
|
||||
const projectIds = shortlisted.map((s) => s.projectId)
|
||||
|
||||
// Create ProjectRoundState entries for confirmed projects in the first award round
|
||||
await ctx.prisma.projectRoundState.createMany({
|
||||
data: projectIds.map((projectId) => ({
|
||||
projectId,
|
||||
roundId: firstRound.id,
|
||||
state: 'PENDING' as const,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
routedCount = projectIds.length
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.awardId,
|
||||
detailsJson: {
|
||||
action: 'CONFIRM_SHORTLIST',
|
||||
confirmedCount: shortlisted.length,
|
||||
eligibilityMode: award.eligibilityMode,
|
||||
routedCount,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
confirmedCount: shortlisted.length,
|
||||
routedCount,
|
||||
eligibilityMode: award.eligibilityMode,
|
||||
}
|
||||
}),
|
||||
|
||||
// ─── Award Rounds ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List rounds belonging to an award
|
||||
*/
|
||||
listRounds: protectedProcedure
|
||||
.input(z.object({ awardId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.round.findMany({
|
||||
where: { specialAwardId: input.awardId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
juryGroup: { select: { id: true, name: true } },
|
||||
_count: {
|
||||
select: {
|
||||
projectRoundStates: true,
|
||||
assignments: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a round linked to an award
|
||||
*/
|
||||
createRound: adminProcedure
|
||||
.input(z.object({
|
||||
awardId: z.string(),
|
||||
name: z.string().min(1),
|
||||
roundType: z.enum(['INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING', 'LIVE_FINAL', 'DELIBERATION']).default('EVALUATION'),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
select: { competitionId: true, name: true },
|
||||
})
|
||||
|
||||
if (!award.competitionId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Award must be linked to a competition before creating rounds',
|
||||
})
|
||||
}
|
||||
|
||||
// Get max sort order for this award's rounds
|
||||
const maxOrder = await ctx.prisma.round.aggregate({
|
||||
where: { specialAwardId: input.awardId },
|
||||
_max: { sortOrder: true },
|
||||
})
|
||||
|
||||
// Also need max sortOrder across the competition to avoid unique constraint conflicts
|
||||
const maxCompOrder = await ctx.prisma.round.aggregate({
|
||||
where: { competitionId: award.competitionId },
|
||||
_max: { sortOrder: true },
|
||||
})
|
||||
|
||||
const sortOrder = Math.max(
|
||||
(maxOrder._max.sortOrder ?? 0) + 1,
|
||||
(maxCompOrder._max.sortOrder ?? 0) + 1
|
||||
)
|
||||
|
||||
const slug = `${award.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${input.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`
|
||||
|
||||
const round = await ctx.prisma.round.create({
|
||||
data: {
|
||||
competitionId: award.competitionId,
|
||||
specialAwardId: input.awardId,
|
||||
name: input.name,
|
||||
slug,
|
||||
roundType: input.roundType,
|
||||
sortOrder,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Round',
|
||||
entityId: round.id,
|
||||
detailsJson: { awardId: input.awardId, awardName: award.name, roundType: input.roundType },
|
||||
})
|
||||
|
||||
return round
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete an award round (only if DRAFT)
|
||||
*/
|
||||
deleteRound: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { status: true, specialAwardId: true },
|
||||
})
|
||||
|
||||
if (!round.specialAwardId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'This round is not an award round',
|
||||
})
|
||||
}
|
||||
|
||||
if (round.status !== 'ROUND_DRAFT') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Only draft rounds can be deleted',
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.prisma.round.delete({ where: { id: input.roundId } })
|
||||
|
||||
await logAudit({
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Round',
|
||||
entityId: input.roundId,
|
||||
detailsJson: { awardId: round.specialAwardId },
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -48,6 +48,7 @@ Return a JSON object:
|
||||
"project_id": "PROJECT_001",
|
||||
"eligible": true/false,
|
||||
"confidence": 0.0-1.0,
|
||||
"quality_score": 0-100,
|
||||
"reasoning": "2-3 sentence explanation covering key dimensions",
|
||||
"dimensionScores": {
|
||||
"geographic": 0.0-1.0,
|
||||
@@ -59,6 +60,8 @@ Return a JSON object:
|
||||
]
|
||||
}
|
||||
|
||||
quality_score is a 0-100 integer measuring how well the project fits the award criteria (used for ranking shortlists). 100 = perfect fit, 0 = no fit. Even ineligible projects should receive a score for reference.
|
||||
|
||||
## Guidelines
|
||||
- Base evaluation only on provided data — do not infer missing information
|
||||
- eligible=true only when ALL required dimensions score above 0.5
|
||||
@@ -77,6 +80,7 @@ export interface EligibilityResult {
|
||||
projectId: string
|
||||
eligible: boolean
|
||||
confidence: number
|
||||
qualityScore: number
|
||||
reasoning: string
|
||||
method: 'AUTO' | 'AI'
|
||||
}
|
||||
@@ -229,6 +233,7 @@ Evaluate eligibility for each project.`
|
||||
project_id: string
|
||||
eligible: boolean
|
||||
confidence: number
|
||||
quality_score?: number
|
||||
reasoning: string
|
||||
}>
|
||||
}
|
||||
@@ -273,6 +278,7 @@ Evaluate eligibility for each project.`
|
||||
projectId: mapping.realId,
|
||||
eligible: eval_.eligible,
|
||||
confidence: eval_.confidence,
|
||||
qualityScore: Math.max(0, Math.min(100, eval_.quality_score ?? 0)),
|
||||
reasoning: eval_.reasoning,
|
||||
method: 'AI',
|
||||
})
|
||||
@@ -305,6 +311,7 @@ Evaluate eligibility for each project.`
|
||||
projectId: mapping.realId,
|
||||
eligible: false,
|
||||
confidence: 0,
|
||||
qualityScore: 0,
|
||||
reasoning: 'AI response parse error — requires manual review',
|
||||
method: 'AI',
|
||||
})
|
||||
@@ -333,6 +340,7 @@ export async function aiInterpretCriteria(
|
||||
projectId: p.id,
|
||||
eligible: false,
|
||||
confidence: 0,
|
||||
qualityScore: 0,
|
||||
reasoning: 'AI unavailable — requires manual eligibility review',
|
||||
method: 'AI' as const,
|
||||
}))
|
||||
@@ -401,6 +409,7 @@ export async function aiInterpretCriteria(
|
||||
projectId: p.id,
|
||||
eligible: false,
|
||||
confidence: 0,
|
||||
qualityScore: 0,
|
||||
reasoning: `AI error: ${classified.message}`,
|
||||
method: 'AI' as const,
|
||||
}))
|
||||
|
||||
@@ -510,7 +510,8 @@ export async function executeAIScreening(
|
||||
projects: ProjectForFiltering[],
|
||||
userId?: string,
|
||||
entityId?: string,
|
||||
onProgress?: ProgressCallback
|
||||
onProgress?: ProgressCallback,
|
||||
onBatchComplete?: (batchResults: Map<string, AIScreeningResult>) => Promise<void>
|
||||
): Promise<Map<string, AIScreeningResult>> {
|
||||
const results = new Map<string, AIScreeningResult>()
|
||||
|
||||
@@ -599,6 +600,17 @@ export async function executeAIScreening(
|
||||
processedBatches++
|
||||
}
|
||||
|
||||
// Emit batch results for streaming
|
||||
if (onBatchComplete) {
|
||||
const chunkResults = new Map<string, AIScreeningResult>()
|
||||
for (const { batchResults: br } of parallelResults) {
|
||||
for (const [id, result] of br) {
|
||||
chunkResults.set(id, result)
|
||||
}
|
||||
}
|
||||
await onBatchComplete(chunkResults)
|
||||
}
|
||||
|
||||
// Report progress after each parallel chunk
|
||||
if (onProgress) {
|
||||
await onProgress({
|
||||
@@ -653,43 +665,29 @@ export async function executeFilteringRules(
|
||||
projects: ProjectForFiltering[],
|
||||
userId?: string,
|
||||
roundId?: string,
|
||||
onProgress?: ProgressCallback
|
||||
onProgress?: ProgressCallback,
|
||||
onResultsBatch?: (results: ProjectFilteringResult[]) => Promise<void>
|
||||
): Promise<ProjectFilteringResult[]> {
|
||||
const activeRules = rules
|
||||
.filter((r) => r.isActive)
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
|
||||
// Separate AI screening rules (need batch processing)
|
||||
const aiRules = activeRules.filter((r) => r.ruleType === 'AI_SCREENING')
|
||||
const nonAiRules = activeRules.filter((r) => r.ruleType !== 'AI_SCREENING')
|
||||
|
||||
// Pre-compute AI screening results if needed
|
||||
const aiResults = new Map<string, Map<string, AIScreeningResult>>()
|
||||
|
||||
for (const aiRule of aiRules) {
|
||||
const config = aiRule.configJson as unknown as AIScreeningConfig
|
||||
const screeningResults = await executeAIScreening(config, projects, userId, roundId, onProgress)
|
||||
aiResults.set(aiRule.id, screeningResults)
|
||||
}
|
||||
|
||||
// Evaluate each project
|
||||
const results: ProjectFilteringResult[] = []
|
||||
|
||||
// Pre-evaluate non-AI rules for all projects (instant)
|
||||
const nonAiEval = new Map<string, { ruleResults: RuleResult[]; hasFailed: boolean; hasFlagged: boolean }>()
|
||||
for (const project of projects) {
|
||||
const ruleResults: RuleResult[] = []
|
||||
let hasFailed = false
|
||||
let hasFlagged = false
|
||||
|
||||
// Evaluate non-AI rules
|
||||
for (const rule of nonAiRules) {
|
||||
let result: { passed: boolean; action: 'PASS' | 'REJECT' | 'FLAG' }
|
||||
|
||||
if (rule.ruleType === 'FIELD_BASED') {
|
||||
const config = rule.configJson as unknown as FieldRuleConfig
|
||||
result = evaluateFieldRule(config, project)
|
||||
result = evaluateFieldRule(rule.configJson as unknown as FieldRuleConfig, project)
|
||||
} else if (rule.ruleType === 'DOCUMENT_CHECK') {
|
||||
const config = rule.configJson as unknown as DocumentCheckConfig
|
||||
result = evaluateDocumentRule(config, project)
|
||||
result = evaluateDocumentRule(rule.configJson as unknown as DocumentCheckConfig, project)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
@@ -701,65 +699,107 @@ export async function executeFilteringRules(
|
||||
passed: result.passed,
|
||||
action: result.action,
|
||||
})
|
||||
|
||||
if (!result.passed) {
|
||||
if (result.action === 'REJECT') hasFailed = true
|
||||
if (result.action === 'FLAG') hasFlagged = true
|
||||
}
|
||||
}
|
||||
nonAiEval.set(project.id, { ruleResults, hasFailed, hasFlagged })
|
||||
}
|
||||
|
||||
// Evaluate AI rules
|
||||
// Helper: combine non-AI + AI results for a single project
|
||||
function computeProjectResult(
|
||||
projectId: string,
|
||||
aiRuleResults: Array<{ ruleId: string; ruleName: string; passed: boolean; action: string; reasoning?: string }>,
|
||||
aiScreeningData: Record<string, unknown>
|
||||
): ProjectFilteringResult {
|
||||
const nonAi = nonAiEval.get(projectId)!
|
||||
const ruleResults: RuleResult[] = [...nonAi.ruleResults]
|
||||
let hasFailed = nonAi.hasFailed
|
||||
let hasFlagged = nonAi.hasFlagged
|
||||
|
||||
for (const ar of aiRuleResults) {
|
||||
ruleResults.push({
|
||||
ruleId: ar.ruleId,
|
||||
ruleName: ar.ruleName,
|
||||
ruleType: 'AI_SCREENING',
|
||||
passed: ar.passed,
|
||||
action: ar.action as 'PASS' | 'REJECT' | 'FLAG',
|
||||
reasoning: ar.reasoning,
|
||||
})
|
||||
if (!ar.passed) {
|
||||
if (ar.action === 'REJECT') hasFailed = true
|
||||
else hasFlagged = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projectId,
|
||||
outcome: hasFailed ? 'FILTERED_OUT' : hasFlagged ? 'FLAGGED' : 'PASSED',
|
||||
ruleResults,
|
||||
aiScreeningJson: Object.keys(aiScreeningData).length > 0 ? aiScreeningData : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// No AI rules → compute all results immediately
|
||||
if (aiRules.length === 0) {
|
||||
const results = projects.map((p) => computeProjectResult(p.id, [], {}))
|
||||
if (onResultsBatch) await onResultsBatch(results)
|
||||
return results
|
||||
}
|
||||
|
||||
// Single AI rule → stream results per batch
|
||||
if (aiRules.length === 1) {
|
||||
const aiRule = aiRules[0]
|
||||
const config = aiRule.configJson as unknown as AIScreeningConfig
|
||||
const allResults: ProjectFilteringResult[] = []
|
||||
|
||||
await executeAIScreening(config, projects, userId, roundId, onProgress, async (batchAIResults) => {
|
||||
const batchResults: ProjectFilteringResult[] = []
|
||||
for (const [projectId, aiResult] of batchAIResults) {
|
||||
const passed = aiResult.meetsCriteria && !aiResult.spamRisk
|
||||
const aiAction = config.action || 'FLAG'
|
||||
batchResults.push(
|
||||
computeProjectResult(
|
||||
projectId,
|
||||
[{ ruleId: aiRule.id, ruleName: aiRule.name, passed, action: aiAction, reasoning: aiResult.reasoning }],
|
||||
{ [aiRule.id]: aiResult }
|
||||
)
|
||||
)
|
||||
}
|
||||
allResults.push(...batchResults)
|
||||
if (onResultsBatch) await onResultsBatch(batchResults)
|
||||
})
|
||||
|
||||
return allResults
|
||||
}
|
||||
|
||||
// Multiple AI rules → run all sequentially, then compute (no per-batch streaming)
|
||||
const aiResults = new Map<string, Map<string, AIScreeningResult>>()
|
||||
for (const aiRule of aiRules) {
|
||||
const ruleScreening = aiResults.get(aiRule.id)
|
||||
const screening = ruleScreening?.get(project.id)
|
||||
const config = aiRule.configJson as unknown as AIScreeningConfig
|
||||
const screeningResults = await executeAIScreening(config, projects, userId, roundId, onProgress)
|
||||
aiResults.set(aiRule.id, screeningResults)
|
||||
}
|
||||
|
||||
const results: ProjectFilteringResult[] = []
|
||||
for (const project of projects) {
|
||||
const aiRuleResults: Array<{ ruleId: string; ruleName: string; passed: boolean; action: string; reasoning?: string }> = []
|
||||
const aiScreeningData: Record<string, unknown> = {}
|
||||
|
||||
for (const aiRule of aiRules) {
|
||||
const screening = aiResults.get(aiRule.id)?.get(project.id)
|
||||
if (screening) {
|
||||
const passed = screening.meetsCriteria && !screening.spamRisk
|
||||
const aiConfig = aiRule.configJson as unknown as AIScreeningConfig
|
||||
const aiAction = aiConfig?.action || 'FLAG'
|
||||
ruleResults.push({
|
||||
ruleId: aiRule.id,
|
||||
ruleName: aiRule.name,
|
||||
ruleType: 'AI_SCREENING',
|
||||
passed,
|
||||
action: aiAction,
|
||||
reasoning: screening.reasoning,
|
||||
})
|
||||
|
||||
if (!passed) {
|
||||
if (aiAction === 'REJECT') hasFailed = true
|
||||
else hasFlagged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine overall outcome
|
||||
let outcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED'
|
||||
if (hasFailed) {
|
||||
outcome = 'FILTERED_OUT'
|
||||
} else if (hasFlagged) {
|
||||
outcome = 'FLAGGED'
|
||||
} else {
|
||||
outcome = 'PASSED'
|
||||
}
|
||||
|
||||
// Collect AI screening data
|
||||
const aiScreeningData: Record<string, unknown> = {}
|
||||
for (const aiRule of aiRules) {
|
||||
const screening = aiResults.get(aiRule.id)?.get(project.id)
|
||||
if (screening) {
|
||||
aiRuleResults.push({ ruleId: aiRule.id, ruleName: aiRule.name, passed, action: aiAction, reasoning: screening.reasoning })
|
||||
aiScreeningData[aiRule.id] = screening
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
projectId: project.id,
|
||||
outcome,
|
||||
ruleResults,
|
||||
aiScreeningJson:
|
||||
Object.keys(aiScreeningData).length > 0 ? aiScreeningData : undefined,
|
||||
})
|
||||
results.push(computeProjectResult(project.id, aiRuleResults, aiScreeningData))
|
||||
}
|
||||
|
||||
if (onResultsBatch) await onResultsBatch(results)
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -141,7 +141,16 @@ export interface ProjectWithRelations {
|
||||
teamMembers?: number
|
||||
files?: number
|
||||
}
|
||||
files?: Array<{ fileType: FileType | null; size?: number; pageCount?: number | null }>
|
||||
files?: Array<{
|
||||
fileType: FileType | null
|
||||
size?: number
|
||||
pageCount?: number | null
|
||||
detectedLang?: string
|
||||
langConfidence?: number
|
||||
roundName?: string
|
||||
isCurrentRound?: boolean
|
||||
textContent?: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,6 +206,11 @@ export function toProjectWithRelations(project: {
|
||||
fileType: (f.fileType as FileType) ?? null,
|
||||
size: f.size,
|
||||
pageCount: f.pageCount ?? null,
|
||||
detectedLang: f.detectedLang as string | undefined,
|
||||
langConfidence: f.langConfidence as number | undefined,
|
||||
roundName: f.roundName as string | undefined,
|
||||
isCurrentRound: f.isCurrentRound as boolean | undefined,
|
||||
textContent: f.textContent as string | undefined,
|
||||
})) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ const BATCH_SIZE = 20
|
||||
export async function processEligibilityJob(
|
||||
awardId: string,
|
||||
includeSubmitted: boolean,
|
||||
userId: string
|
||||
userId: string,
|
||||
filteringRoundId?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Mark job as PROCESSING
|
||||
@@ -23,12 +24,60 @@ export async function processEligibilityJob(
|
||||
include: { program: true },
|
||||
})
|
||||
|
||||
// Get projects
|
||||
// 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
|
||||
}>
|
||||
|
||||
if (filteringRoundId) {
|
||||
// Scope to projects that PASSED filtering in the specified round
|
||||
const passedResults = await prisma.filteringResult.findMany({
|
||||
where: { roundId: filteringRoundId, 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: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
competitionCategory: true,
|
||||
country: true,
|
||||
geographicZone: true,
|
||||
tags: true,
|
||||
oceanIssue: true,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const statusFilter = includeSubmitted
|
||||
? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
|
||||
: (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
|
||||
|
||||
const projects = await prisma.project.findMany({
|
||||
projects = await prisma.project.findMany({
|
||||
where: {
|
||||
programId: award.programId,
|
||||
status: { in: [...statusFilter] },
|
||||
@@ -44,6 +93,7 @@ export async function processEligibilityJob(
|
||||
oceanIssue: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
await prisma.specialAward.update({
|
||||
@@ -77,7 +127,7 @@ export async function processEligibilityJob(
|
||||
|
||||
// Phase 2: 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; reasoning: string }> | undefined
|
||||
let aiResults: Map<string, { eligible: boolean; confidence: number; qualityScore: number; reasoning: string }> | undefined
|
||||
|
||||
if (award.criteriaText && award.useAiEligibility) {
|
||||
aiResults = new Map()
|
||||
@@ -90,6 +140,7 @@ export async function processEligibilityJob(
|
||||
aiResults.set(e.projectId, {
|
||||
eligible: e.eligible,
|
||||
confidence: e.confidence,
|
||||
qualityScore: e.qualityScore,
|
||||
reasoning: e.reasoning,
|
||||
})
|
||||
}
|
||||
@@ -123,8 +174,9 @@ export async function processEligibilityJob(
|
||||
projectId: project.id,
|
||||
eligible,
|
||||
method,
|
||||
qualityScore: aiEval?.qualityScore ?? null,
|
||||
aiReasoningJson: aiEval
|
||||
? { confidence: aiEval.confidence, reasoning: aiEval.reasoning }
|
||||
? { confidence: aiEval.confidence, qualityScore: aiEval.qualityScore, reasoning: aiEval.reasoning }
|
||||
: null,
|
||||
}
|
||||
})
|
||||
@@ -144,19 +196,47 @@ export async function processEligibilityJob(
|
||||
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 },
|
||||
|
||||
Reference in New Issue
Block a user