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 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-17 01:43:28 +01:00 committed by Matt
parent e547d2bd03
commit fc8e58f985
3 changed files with 123 additions and 0 deletions

View File

@ -6,6 +6,7 @@ import { getPresignedUrl, generateObjectKey } from '@/lib/minio'
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email' import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
import { logAudit } from '@/server/utils/audit' import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification' import { createNotification } from '../services/in-app-notification'
import { checkRequirementsAndTransition } from '../services/round-engine'
// Bucket for applicant submissions // Bucket for applicant submissions
export const SUBMISSIONS_BUCKET = 'mopc-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 return file
}), }),

View File

@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getPresignedUrl, generateObjectKey, deleteObject, BUCKET_NAME } from '@/lib/minio' import { getPresignedUrl, generateObjectKey, deleteObject, BUCKET_NAME } from '@/lib/minio'
import { logAudit } from '../utils/audit' import { logAudit } from '../utils/audit'
import { checkRequirementsAndTransition } from '../services/round-engine'
export const fileRouter = router({ export const fileRouter = router({
/** /**
@ -1501,6 +1502,14 @@ export const fileRouter = router({
userAgent: ctx.userAgent, 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 } return { uploadUrl, file }
}), }),

View File

@ -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 ────────────────────────────────────────────────────────────── // ─── Internals ──────────────────────────────────────────────────────────────
function isTerminalState(state: ProjectRoundStateValue): boolean { function isTerminalState(state: ProjectRoundStateValue): boolean {