From fc8e58f9855348ea76ecc016949533e269f12187 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 17 Feb 2026 01:43:28 +0100 Subject: [PATCH] Auto-transition projects to PASSED when all required documents uploaded Add checkRequirementsAndTransition() to round-engine that checks if all required FileRequirements for a round are satisfied by uploaded files. When all are met and the project is PENDING/IN_PROGRESS, it auto- transitions to PASSED. Also adds batchCheckRequirementsAndTransition() for bulk operations. Wired into: - file.adminUploadForRoundRequirement (admin bulk upload) - applicant.saveFileMetadata (applicant self-upload) Non-fatal: failures in the check never break the upload itself. Co-Authored-By: Claude Opus 4.6 --- src/server/routers/applicant.ts | 11 +++ src/server/routers/file.ts | 9 +++ src/server/services/round-engine.ts | 103 ++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index 0cca9fd..70b9401 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -6,6 +6,7 @@ import { getPresignedUrl, generateObjectKey } from '@/lib/minio' import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email' import { logAudit } from '@/server/utils/audit' import { createNotification } from '../services/in-app-notification' +import { checkRequirementsAndTransition } from '../services/round-engine' // Bucket for applicant submissions export const SUBMISSIONS_BUCKET = 'mopc-submissions' @@ -410,6 +411,16 @@ export const applicantRouter = router({ }, }) + // Auto-transition: if uploading against a round requirement, check completion + if (roundId && requirementId) { + await checkRequirementsAndTransition( + projectId, + roundId, + ctx.user.id, + ctx.prisma, + ) + } + return file }), diff --git a/src/server/routers/file.ts b/src/server/routers/file.ts index 5f5adf0..801588e 100644 --- a/src/server/routers/file.ts +++ b/src/server/routers/file.ts @@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server' import { router, protectedProcedure, adminProcedure } from '../trpc' import { getPresignedUrl, generateObjectKey, deleteObject, BUCKET_NAME } from '@/lib/minio' import { logAudit } from '../utils/audit' +import { checkRequirementsAndTransition } from '../services/round-engine' export const fileRouter = router({ /** @@ -1501,6 +1502,14 @@ export const fileRouter = router({ userAgent: ctx.userAgent, }) + // Auto-transition: check if all required documents are now uploaded + await checkRequirementsAndTransition( + input.projectId, + input.roundId, + ctx.user.id, + ctx.prisma, + ) + return { uploadUrl, file } }), diff --git a/src/server/services/round-engine.ts b/src/server/services/round-engine.ts index e2693d5..4d0c126 100644 --- a/src/server/services/round-engine.ts +++ b/src/server/services/round-engine.ts @@ -625,6 +625,109 @@ export async function getProjectRoundState( }) } +// ─── Auto-Transition on Document Completion ───────────────────────────────── + +/** + * Check if a project has fulfilled all required FileRequirements for a round. + * If yes, and the project is currently PENDING, transition it to PASSED. + * + * Called after file uploads (admin bulk upload or applicant upload). + * Non-fatal: errors are logged but never propagated to callers. + */ +export async function checkRequirementsAndTransition( + projectId: string, + roundId: string, + actorId: string, + prisma: PrismaClient | any, +): Promise<{ transitioned: boolean; newState?: string }> { + try { + // Get all required FileRequirements for this round + const requirements = await prisma.fileRequirement.findMany({ + where: { roundId, isRequired: true }, + select: { id: true }, + }) + + // If the round has no file requirements, nothing to check + if (requirements.length === 0) { + return { transitioned: false } + } + + // Check which requirements this project has satisfied (has a file uploaded) + const fulfilledFiles = await prisma.projectFile.findMany({ + where: { + projectId, + roundId, + requirementId: { in: requirements.map((r: { id: string }) => r.id) }, + }, + select: { requirementId: true }, + }) + + const fulfilledIds = new Set( + fulfilledFiles + .map((f: { requirementId: string | null }) => f.requirementId) + .filter(Boolean) + ) + + // Check if all required requirements are met + const allMet = requirements.every((r: { id: string }) => fulfilledIds.has(r.id)) + + if (!allMet) { + return { transitioned: false } + } + + // Check current state — only transition if PENDING or IN_PROGRESS + const currentState = await prisma.projectRoundState.findUnique({ + where: { projectId_roundId: { projectId, roundId } }, + select: { state: true }, + }) + + const eligibleStates = ['PENDING', 'IN_PROGRESS'] + if (!currentState || !eligibleStates.includes(currentState.state)) { + return { transitioned: false } + } + + // All requirements met — transition to PASSED + const result = await transitionProject(projectId, roundId, 'PASSED' as ProjectRoundStateValue, actorId, prisma) + + if (result.success) { + console.log(`[RoundEngine] Auto-transitioned project ${projectId} to PASSED in round ${roundId} (all ${requirements.length} requirements met)`) + return { transitioned: true, newState: 'PASSED' } + } + + return { transitioned: false } + } catch (error) { + // Non-fatal — log and continue + console.error('[RoundEngine] checkRequirementsAndTransition failed:', error) + return { transitioned: false } + } +} + +/** + * Batch version: check all projects in a round and transition any that + * have all required documents uploaded. Useful after bulk upload. + */ +export async function batchCheckRequirementsAndTransition( + roundId: string, + projectIds: string[], + actorId: string, + prisma: PrismaClient | any, +): Promise<{ transitionedCount: number; projectIds: string[] }> { + const transitioned: string[] = [] + + for (const projectId of projectIds) { + const result = await checkRequirementsAndTransition(projectId, roundId, actorId, prisma) + if (result.transitioned) { + transitioned.push(projectId) + } + } + + if (transitioned.length > 0) { + console.log(`[RoundEngine] Batch auto-transition: ${transitioned.length}/${projectIds.length} projects moved to PASSED in round ${roundId}`) + } + + return { transitionedCount: transitioned.length, projectIds: transitioned } +} + // ─── Internals ────────────────────────────────────────────────────────────── function isTerminalState(state: ProjectRoundStateValue): boolean {