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:
parent
e547d2bd03
commit
fc8e58f985
|
|
@ -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
|
||||
}),
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}),
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue