Compare commits

...

4 Commits

Author SHA1 Message Date
Matt
771f35c695 Retroactive auto-PASS for projects with complete documents
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m14s
Wire batchCheckRequirementsAndTransition into round activation and reopen
so pre-existing projects that already have all required docs get auto-
passed. Also adds checkDocumentCompletion endpoint for manual sweeps on
already-active rounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:29:57 +01:00
Matt
fbeec846a3 Pass tag confidence scores to AI assignment for weighted matching
The AI assignment path was receiving project tags as flat strings, losing
the confidence scores from AI tagging. Now both the GPT path and the
fallback algorithm weight tag matches by confidence — a 0.9 tag matters
more than a 0.5 one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:29:46 +01:00
Matt
cfeef9a601 Add auto-pass & advance for intake rounds (no manual marking needed)
For INTAKE, SUBMISSION, and MENTORING rounds, the Advance Projects dialog
now shows a simplified "Advance All" flow that auto-passes all pending
projects and advances them in one click. Backend accepts autoPassPending
flag to bulk-set PENDING→PASSED before advancing. Jury/evaluation rounds
keep the existing per-project selection workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:17:16 +01:00
Matt
fcee8761b9 Hide jury stat card in header for non-jury rounds (INTAKE, FILTERING, etc.)
The jury selector card in the stats bar was still visible on round types
where juries don't apply. Now conditionally rendered based on hasJury,
with the grid adjusting from 4 to 3 columns accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:17:15 +01:00
7 changed files with 271 additions and 95 deletions

View File

@@ -326,7 +326,10 @@ export default function RoundDetailPage() {
onSuccess: (data) => {
utils.round.getById.invalidate({ id: roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
toast.success(`Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`)
const msg = data.autoPassedCount
? `Passed ${data.autoPassedCount} and advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`
: `Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`
toast.success(msg)
setAdvanceDialogOpen(false)
},
onError: (err) => toast.error(err.message),
@@ -378,6 +381,7 @@ export default function RoundDetailPage() {
const isEvaluation = round?.roundType === 'EVALUATION'
const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
const hasAwards = hasJury
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
@@ -582,7 +586,7 @@ export default function RoundDetailPage() {
</motion.div>
{/* ===== STATS BAR — Accent-bordered cards ===== */}
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
<div className={cn("grid gap-3 grid-cols-2", hasJury ? "sm:grid-cols-4" : "sm:grid-cols-3")}>
{/* Projects */}
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-[#557f8c] hover:shadow-md transition-shadow">
@@ -605,56 +609,58 @@ export default function RoundDetailPage() {
</Card>
</AnimatedCard>
{/* Jury (with inline group selector) */}
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-purple-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2.5 mb-1" data-jury-select>
<div className="rounded-full bg-purple-50 p-1.5">
<Users className="h-4 w-4 text-purple-500" />
{/* Jury (with inline group selector) — only for jury-relevant rounds */}
{hasJury && (
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-purple-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2.5 mb-1" data-jury-select>
<div className="rounded-full bg-purple-50 p-1.5">
<Users className="h-4 w-4 text-purple-500" />
</div>
<span className="text-sm font-medium text-muted-foreground">Jury</span>
</div>
<span className="text-sm font-medium text-muted-foreground">Jury</span>
</div>
{juryGroups && juryGroups.length > 0 ? (
<Select
value={round.juryGroupId ?? '__none__'}
onValueChange={(value) => {
assignJuryMutation.mutate({
id: roundId,
juryGroupId: value === '__none__' ? null : value,
})
}}
disabled={assignJuryMutation.isPending}
>
<SelectTrigger className="h-8 text-xs mt-1">
<SelectValue placeholder="Select jury group..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No jury assigned</SelectItem>
{juryGroups.map((jg: any) => (
<SelectItem key={jg.id} value={jg.id}>
{jg.name} ({jg._count?.members ?? 0} members)
</SelectItem>
))}
</SelectContent>
</Select>
) : juryGroup ? (
<>
<p className="text-3xl font-bold mt-2">{juryMemberCount}</p>
<p className="text-xs text-muted-foreground truncate">{juryGroup.name}</p>
</>
) : (
<>
<p className="text-3xl font-bold mt-2 text-muted-foreground">&mdash;</p>
<p className="text-xs text-muted-foreground">No jury groups yet</p>
</>
)}
</CardContent>
</Card>
</AnimatedCard>
{juryGroups && juryGroups.length > 0 ? (
<Select
value={round.juryGroupId ?? '__none__'}
onValueChange={(value) => {
assignJuryMutation.mutate({
id: roundId,
juryGroupId: value === '__none__' ? null : value,
})
}}
disabled={assignJuryMutation.isPending}
>
<SelectTrigger className="h-8 text-xs mt-1">
<SelectValue placeholder="Select jury group..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No jury assigned</SelectItem>
{juryGroups.map((jg: any) => (
<SelectItem key={jg.id} value={jg.id}>
{jg.name} ({jg._count?.members ?? 0} members)
</SelectItem>
))}
</SelectContent>
</Select>
) : juryGroup ? (
<>
<p className="text-3xl font-bold mt-2">{juryMemberCount}</p>
<p className="text-xs text-muted-foreground truncate">{juryGroup.name}</p>
</>
) : (
<>
<p className="text-3xl font-bold mt-2 text-muted-foreground">&mdash;</p>
<p className="text-xs text-muted-foreground">No jury groups yet</p>
</>
)}
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Window */}
<AnimatedCard index={2}>
<AnimatedCard index={hasJury ? 2 : 1}>
<Card className="border-l-4 border-l-emerald-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2.5">
@@ -687,7 +693,7 @@ export default function RoundDetailPage() {
</AnimatedCard>
{/* Advancement */}
<AnimatedCard index={3}>
<AnimatedCard index={hasJury ? 3 : 2}>
<Card className="border-l-4 border-l-amber-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2.5">
@@ -967,28 +973,28 @@ export default function RoundDetailPage() {
{/* Advance projects (always visible when projects exist) */}
{projectCount > 0 && (
<button
onClick={() => passedCount > 0
onClick={() => (isSimpleAdvance || passedCount > 0)
? setAdvanceDialogOpen(true)
: toast.info('Mark projects as "Passed" first in the Projects tab')}
className={cn(
'flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left',
passedCount > 0
(isSimpleAdvance || passedCount > 0)
? 'border-l-4 border-l-emerald-500 bg-emerald-50/30'
: 'border-dashed opacity-60',
)}
>
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', passedCount > 0 ? 'text-emerald-600' : 'text-muted-foreground')} />
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', (isSimpleAdvance || passedCount > 0) ? 'text-emerald-600' : 'text-muted-foreground')} />
<div>
<p className="text-sm font-medium">Advance Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
{passedCount > 0
? `Move ${passedCount} passed project(s) to the next round`
: 'Mark projects as "Passed" first, then advance'}
{isSimpleAdvance
? `Advance all ${projectCount} project(s) to the next round`
: passedCount > 0
? `Move ${passedCount} passed project(s) to the next round`
: 'Mark projects as "Passed" first, then advance'}
</p>
</div>
{passedCount > 0 && (
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{passedCount}</Badge>
)}
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{isSimpleAdvance ? projectCount : passedCount}</Badge>
</button>
)}
@@ -1096,6 +1102,7 @@ export default function RoundDetailPage() {
open={advanceDialogOpen}
onOpenChange={setAdvanceDialogOpen}
roundId={roundId}
roundType={round?.roundType}
projectStates={projectStates}
config={config}
advanceMutation={advanceMutation}
@@ -2500,6 +2507,7 @@ function AdvanceProjectsDialog({
open,
onOpenChange,
roundId,
roundType,
projectStates,
config,
advanceMutation,
@@ -2509,12 +2517,15 @@ function AdvanceProjectsDialog({
open: boolean
onOpenChange: (open: boolean) => void
roundId: string
roundType?: string
projectStates: any[] | undefined
config: Record<string, unknown>
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string }) => void; isPending: boolean }
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string; autoPassPending?: boolean }) => void; isPending: boolean }
competitionRounds?: Array<{ id: string; name: string; sortOrder: number; roundType: string }>
currentSortOrder?: number
}) {
// For non-jury rounds (INTAKE, SUBMISSION, MENTORING), offer a simpler "advance all" flow
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(roundType ?? '')
// Target round selector
const availableTargets = useMemo(() =>
(competitionRounds ?? [])
@@ -2528,9 +2539,11 @@ function AdvanceProjectsDialog({
if (open && !targetRoundId && availableTargets.length > 0) {
setTargetRoundId(availableTargets[0].id)
}
const allProjects = projectStates ?? []
const pendingCount = allProjects.filter((ps: any) => ps.state === 'PENDING').length
const passedProjects = useMemo(() =>
(projectStates ?? []).filter((ps: any) => ps.state === 'PASSED'),
[projectStates])
allProjects.filter((ps: any) => ps.state === 'PASSED'),
[allProjects])
const startups = useMemo(() =>
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'STARTUP'),
@@ -2583,14 +2596,23 @@ function AdvanceProjectsDialog({
})
}
const handleAdvance = () => {
const ids = Array.from(selected)
if (ids.length === 0) return
advanceMutation.mutate({
roundId,
projectIds: ids,
...(targetRoundId ? { targetRoundId } : {}),
})
const handleAdvance = (autoPass?: boolean) => {
if (autoPass) {
// Auto-pass all pending then advance all
advanceMutation.mutate({
roundId,
autoPassPending: true,
...(targetRoundId ? { targetRoundId } : {}),
})
} else {
const ids = Array.from(selected)
if (ids.length === 0) return
advanceMutation.mutate({
roundId,
projectIds: ids,
...(targetRoundId ? { targetRoundId } : {}),
})
}
onOpenChange(false)
setSelected(new Set())
setTargetRoundId('')
@@ -2655,14 +2677,18 @@ function AdvanceProjectsDialog({
)
}
const totalProjectCount = allProjects.length
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Advance Projects</DialogTitle>
<DialogDescription>
Select which passed projects to advance.
{selected.size} of {passedProjects.length} selected.
{isSimpleAdvance
? `Move all ${totalProjectCount} projects to the next round.`
: `Select which passed projects to advance. ${selected.size} of ${passedProjects.length} selected.`
}
</DialogDescription>
</DialogHeader>
@@ -2690,21 +2716,50 @@ function AdvanceProjectsDialog({
</div>
)}
<div className="flex-1 overflow-y-auto space-y-4 py-2">
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')}
{other.length > 0 && renderCategorySection('Other / Uncategorized', other, 0, 'bg-gray-100 text-gray-700')}
</div>
{isSimpleAdvance ? (
/* Simple mode for INTAKE/SUBMISSION/MENTORING — no per-project selection needed */
<div className="py-4 space-y-3">
<div className="rounded-lg border bg-muted/30 p-4 text-center space-y-1">
<p className="text-3xl font-bold">{totalProjectCount}</p>
<p className="text-sm text-muted-foreground">projects will be advanced</p>
</div>
{pendingCount > 0 && (
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2">
<p className="text-xs text-blue-700">
{pendingCount} pending project{pendingCount !== 1 ? 's' : ''} will be automatically marked as passed and advanced.
{passedProjects.length > 0 && ` ${passedProjects.length} already passed.`}
</p>
</div>
)}
</div>
) : (
/* Detailed mode for jury/evaluation rounds — per-project selection */
<div className="flex-1 overflow-y-auto space-y-4 py-2">
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')}
{other.length > 0 && renderCategorySection('Other / Uncategorized', other, 0, 'bg-gray-100 text-gray-700')}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={handleClose}>Cancel</Button>
<Button
onClick={handleAdvance}
disabled={selected.size === 0 || advanceMutation.isPending}
>
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
</Button>
{isSimpleAdvance ? (
<Button
onClick={() => handleAdvance(true)}
disabled={totalProjectCount === 0 || advanceMutation.isPending}
>
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Advance All {totalProjectCount} Project{totalProjectCount !== 1 ? 's' : ''}
</Button>
) : (
<Button
onClick={() => handleAdvance()}
disabled={selected.size === 0 || advanceMutation.isPending}
>
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -74,10 +74,22 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
description: true,
tags: true,
teamName: true,
projectTags: {
select: { tag: { select: { name: true } }, confidence: true },
},
_count: { select: { assignments: { where: { roundId } } } },
},
})
// Enrich projects with tag confidence data for AI matching
const projectsWithConfidence = projects.map((p) => ({
...p,
tagConfidences: p.projectTags.map((pt) => ({
name: pt.tag.name,
confidence: pt.confidence,
})),
}))
const existingAssignments = await prisma.assignment.findMany({
where: { roundId },
select: { userId: true, projectId: true },
@@ -124,7 +136,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
const result = await generateAIAssignments(
jurors,
projects,
projectsWithConfidence,
constraints,
userId,
roundId,

View File

@@ -243,10 +243,11 @@ export const roundRouter = router({
roundId: z.string(),
targetRoundId: z.string().optional(),
projectIds: z.array(z.string()).optional(),
autoPassPending: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, targetRoundId, projectIds } = input
const { roundId, targetRoundId, projectIds, autoPassPending } = input
// Get current round with competition context
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
@@ -280,6 +281,16 @@ export const roundRouter = router({
targetRound = nextRound
}
// Auto-pass all PENDING projects first (for intake/bulk workflows)
let autoPassedCount = 0
if (autoPassPending) {
const result = await ctx.prisma.projectRoundState.updateMany({
where: { roundId, state: 'PENDING' },
data: { state: 'PASSED' },
})
autoPassedCount = result.count
}
// Determine which projects to advance
let idsToAdvance: string[]
if (projectIds && projectIds.length > 0) {
@@ -346,6 +357,7 @@ export const roundRouter = router({
toRound: targetRound.name,
targetRoundId: targetRound.id,
projectCount: idsToAdvance.length,
autoPassedCount,
projectIds: idsToAdvance,
},
ipAddress: ctx.ip,
@@ -354,6 +366,7 @@ export const roundRouter = router({
return {
advancedCount: idsToAdvance.length,
autoPassedCount,
targetRoundId: targetRound.id,
targetRoundName: targetRound.name,
}

View File

@@ -263,4 +263,41 @@ export const roundEngineRouter = router({
return { success: true, removedCount: deleted.count }
}),
/**
* Retroactive document check: auto-PASS any PENDING/IN_PROGRESS projects
* that already have all required documents uploaded for this round.
* Useful for rounds activated before the auto-transition feature was deployed.
*/
checkDocumentCompletion: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const { batchCheckRequirementsAndTransition } = await import('../services/round-engine')
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: {
roundId: input.roundId,
state: { in: ['PENDING', 'IN_PROGRESS'] },
},
select: { projectId: true },
})
if (projectStates.length === 0) {
return { transitionedCount: 0, checkedCount: 0, projectIds: [] }
}
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
const result = await batchCheckRequirementsAndTransition(
input.roundId,
projectIds,
ctx.user.id,
ctx.prisma,
)
return {
transitionedCount: result.transitionedCount,
checkedCount: projectIds.length,
projectIds: result.projectIds,
}
}),
})

View File

@@ -38,7 +38,7 @@ const ASSIGNMENT_SYSTEM_PROMPT = `You are an expert jury assignment optimizer fo
Match jurors to projects based on expertise alignment, workload balance, and coverage requirements.
## Matching Criteria (Weighted)
- Expertise Match (50%): How well juror tags/expertise align with project topics
- Expertise Match (50%): How well juror tags/expertise align with project topics. Project tags include a confidence score (0-1) — weight higher-confidence tags more heavily as they are more reliably assigned. A tag with confidence 0.9 is a strong signal; one with 0.5 is uncertain.
- Workload Balance (30%): Distribute assignments evenly; prefer jurors below capacity
- Minimum Target (20%): Prioritize jurors who haven't reached their minimum assignment count
@@ -99,6 +99,7 @@ interface ProjectForAssignment {
title: string
description?: string | null
tags: string[]
tagConfidences?: Array<{ name: string; confidence: number }>
teamName?: string | null
_count?: {
assignments: number
@@ -539,7 +540,7 @@ export function generateFallbackAssignments(
return {
juror,
score: calculateExpertiseScore(juror.expertiseTags, project.tags),
score: calculateExpertiseScore(juror.expertiseTags, project.tags, project.tagConfidences),
loadScore: calculateLoadScore(currentLoad, maxLoad),
underMinBonus: calculateUnderMinBonus(currentLoad, minTarget),
}
@@ -586,24 +587,44 @@ export function generateFallbackAssignments(
/**
* Calculate expertise match score based on tag overlap
* When tagConfidences are available, weights matches by confidence
*/
function calculateExpertiseScore(
jurorTags: string[],
projectTags: string[]
projectTags: string[],
tagConfidences?: Array<{ name: string; confidence: number }>
): number {
if (jurorTags.length === 0 || projectTags.length === 0) {
return 0.5 // Neutral score if no tags
}
const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase()))
// If we have confidence data, use weighted scoring
if (tagConfidences && tagConfidences.length > 0) {
let weightedMatches = 0
let totalWeight = 0
for (const tc of tagConfidences) {
totalWeight += tc.confidence
if (jurorTagsLower.has(tc.name.toLowerCase())) {
weightedMatches += tc.confidence
}
}
if (totalWeight === 0) return 0.5
const weightedRatio = weightedMatches / totalWeight
const hasExpertise = weightedMatches > 0 ? 0.2 : 0
return Math.min(1, weightedRatio * 0.8 + hasExpertise)
}
// Fallback: unweighted matching using flat tags
const matchingTags = projectTags.filter((t) =>
jurorTagsLower.has(t.toLowerCase())
)
// Score based on percentage of project tags matched
const matchRatio = matchingTags.length / projectTags.length
// Boost for having expertise, even if not all match
const hasExpertise = matchingTags.length > 0 ? 0.2 : 0
return Math.min(1, matchRatio * 0.8 + hasExpertise)

View File

@@ -52,7 +52,7 @@ export interface AnonymizedProject {
anonymousId: string
title: string
description: string | null
tags: string[]
tags: Array<{ name: string; confidence: number }>
teamName: string | null
}
@@ -209,6 +209,7 @@ interface ProjectInput {
title: string
description?: string | null
tags: string[]
tagConfidences?: Array<{ name: string; confidence: number }>
teamName?: string | null
}
@@ -253,7 +254,9 @@ export function anonymizeForAI(
description: project.description
? truncateAndSanitize(project.description, DESCRIPTION_LIMITS.ASSIGNMENT)
: null,
tags: project.tags,
tags: project.tagConfidences && project.tagConfidences.length > 0
? project.tagConfidences
: project.tags.map((t) => ({ name: t, confidence: 1.0 })),
teamName: project.teamName ? `Team ${index + 1}` : null,
}
}
@@ -524,7 +527,7 @@ export function validateAnonymization(data: AnonymizationResult): boolean {
if (!checkText(project.title)) return false
if (!checkText(project.description)) return false
for (const tag of project.tags) {
if (!checkText(tag)) return false
if (!checkText(typeof tag === 'string' ? tag : tag.name)) return false
}
}

View File

@@ -143,6 +143,24 @@ export async function activateRound(
detailsJson: { name: round.name, roundType: round.roundType },
})
// Retroactive check: auto-PASS any projects that already have all required docs uploaded
// Non-fatal — runs after activation so it never blocks the transition
try {
const projectStates = await prisma.projectRoundState.findMany({
where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
select: { projectId: true },
})
if (projectStates.length > 0) {
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
const result = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
if (result.transitionedCount > 0) {
console.log(`[RoundEngine] On activation: auto-passed ${result.transitionedCount} projects with complete documents`)
}
}
} catch (retroError) {
console.error('[RoundEngine] Retroactive document check failed (non-fatal):', retroError)
}
return {
success: true,
round: { id: updated.id, status: updated.status },
@@ -429,6 +447,23 @@ export async function reopenRound(
},
})
// Retroactive check: auto-PASS any projects that already have all required docs
try {
const projectStates = await prisma.projectRoundState.findMany({
where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
select: { projectId: true },
})
if (projectStates.length > 0) {
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
const batchResult = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
if (batchResult.transitionedCount > 0) {
console.log(`[RoundEngine] On reopen: auto-passed ${batchResult.transitionedCount} projects with complete documents`)
}
}
} catch (retroError) {
console.error('[RoundEngine] Retroactive document check on reopen failed (non-fatal):', retroError)
}
return {
success: true,
round: { id: result.updated.id, status: result.updated.status },