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 { 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
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue