fix: pipeline progress, message variables, jury invite flow, accept-invite UX
- Pipeline: SUBMISSION rounds count IN_PROGRESS + COMPLETED for progress %
- Round engine: remove phantom SubmissionFileRequirement check blocking auto-transition
- Messages: implement {{userName}}, {{projectName}}, {{roundName}}, {{programName}}, {{deadline}} substitution
- Email preview: show greeting, CTA button, and footer matching actual sent email
- Message composer: add green dot indicator for active rounds in round selector
- User create: generate invite token atomically (prevents stuck INVITED state on email failure)
- Jury invites: use jury-specific email template mentioning round context
- Bulk invite: animated progress bar, batch size hint, success/failure counts
- Accept invite: distinguish server errors (retry button) from expired tokens (redirect)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -826,76 +826,36 @@ export async function checkRequirementsAndTransition(
|
||||
prisma: PrismaClient,
|
||||
): Promise<{ transitioned: boolean; newState?: string }> {
|
||||
try {
|
||||
// Get all required FileRequirements for this round (legacy model)
|
||||
// Get all required FileRequirements for this round
|
||||
// Note: only FileRequirement (admin-managed via UI) is checked.
|
||||
// SubmissionFileRequirement (on SubmissionWindow) has no admin UI and is not checked.
|
||||
const requirements = await prisma.fileRequirement.findMany({
|
||||
where: { roundId, isRequired: true },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
// Also check SubmissionFileRequirement via the round's submissionWindow
|
||||
const round = await prisma.round.findUnique({
|
||||
where: { id: roundId },
|
||||
select: { submissionWindowId: true },
|
||||
})
|
||||
|
||||
let submissionRequirements: Array<{ id: string }> = []
|
||||
if (round?.submissionWindowId) {
|
||||
submissionRequirements = await prisma.submissionFileRequirement.findMany({
|
||||
where: { submissionWindowId: round.submissionWindowId, required: true },
|
||||
select: { id: true },
|
||||
})
|
||||
}
|
||||
|
||||
// If the round has no file requirements at all, nothing to check
|
||||
if (requirements.length === 0 && submissionRequirements.length === 0) {
|
||||
// If the round has no file requirements, nothing to check
|
||||
if (requirements.length === 0) {
|
||||
return { transitioned: false }
|
||||
}
|
||||
|
||||
// Check which legacy requirements this project has satisfied
|
||||
let legacyAllMet = true
|
||||
if (requirements.length > 0) {
|
||||
const fulfilledFiles = await prisma.projectFile.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
roundId,
|
||||
requirementId: { in: requirements.map((r: { id: string }) => r.id) },
|
||||
},
|
||||
select: { requirementId: true },
|
||||
})
|
||||
// Check which requirements this project has satisfied
|
||||
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)
|
||||
)
|
||||
const fulfilledIds = new Set(
|
||||
fulfilledFiles
|
||||
.map((f: { requirementId: string | null }) => f.requirementId)
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
legacyAllMet = requirements.every((r: { id: string }) => fulfilledIds.has(r.id))
|
||||
}
|
||||
|
||||
// Check which SubmissionFileRequirements this project has satisfied
|
||||
let submissionAllMet = true
|
||||
if (submissionRequirements.length > 0) {
|
||||
const fulfilledSubmissionFiles = await prisma.projectFile.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
submissionFileRequirementId: { in: submissionRequirements.map((r: { id: string }) => r.id) },
|
||||
},
|
||||
select: { submissionFileRequirementId: true },
|
||||
})
|
||||
|
||||
const fulfilledSubIds = new Set(
|
||||
fulfilledSubmissionFiles
|
||||
.map((f: { submissionFileRequirementId: string | null }) => f.submissionFileRequirementId)
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
submissionAllMet = submissionRequirements.every((r: { id: string }) => fulfilledSubIds.has(r.id))
|
||||
}
|
||||
|
||||
// All requirements from both models must be met
|
||||
const allMet = legacyAllMet && submissionAllMet
|
||||
|
||||
if (!allMet) {
|
||||
if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) {
|
||||
return { transitioned: false }
|
||||
}
|
||||
|
||||
@@ -919,7 +879,7 @@ export async function checkRequirementsAndTransition(
|
||||
const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
|
||||
|
||||
if (result.success) {
|
||||
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to COMPLETED in round ${roundId} (all ${requirements.length + submissionRequirements.length} requirements met)`)
|
||||
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to COMPLETED in round ${roundId} (all ${requirements.length} requirements met)`)
|
||||
return { transitioned: true, newState: 'COMPLETED' }
|
||||
}
|
||||
|
||||
@@ -944,32 +904,20 @@ export async function batchCheckRequirementsAndTransition(
|
||||
if (projectIds.length === 0) return { transitionedCount: 0, projectIds: [] }
|
||||
|
||||
// Pre-load all requirements for this round in batch (avoids per-project queries)
|
||||
const [requirements, round] = await Promise.all([
|
||||
prisma.fileRequirement.findMany({
|
||||
where: { roundId, isRequired: true },
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.round.findUnique({
|
||||
where: { id: roundId },
|
||||
select: { submissionWindowId: true },
|
||||
}),
|
||||
])
|
||||
// Note: only FileRequirement (admin-managed via UI) is checked.
|
||||
// SubmissionFileRequirement (on SubmissionWindow) has no admin UI and is not checked.
|
||||
const requirements = await prisma.fileRequirement.findMany({
|
||||
where: { roundId, isRequired: true },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
let submissionRequirements: Array<{ id: string }> = []
|
||||
if (round?.submissionWindowId) {
|
||||
submissionRequirements = await prisma.submissionFileRequirement.findMany({
|
||||
where: { submissionWindowId: round.submissionWindowId, required: true },
|
||||
select: { id: true },
|
||||
})
|
||||
}
|
||||
|
||||
// If no requirements at all, nothing to check
|
||||
if (requirements.length === 0 && submissionRequirements.length === 0) {
|
||||
// If no requirements, nothing to check
|
||||
if (requirements.length === 0) {
|
||||
return { transitionedCount: 0, projectIds: [] }
|
||||
}
|
||||
|
||||
// Pre-load all project files and current states in batch
|
||||
type FileRow = { projectId: string; requirementId: string | null; submissionFileRequirementId: string | null }
|
||||
type FileRow = { projectId: string; requirementId: string | null }
|
||||
type StateRow = { projectId: string; state: string }
|
||||
|
||||
const [allFiles, allStates] = await Promise.all([
|
||||
@@ -978,7 +926,7 @@ export async function batchCheckRequirementsAndTransition(
|
||||
projectId: { in: projectIds },
|
||||
roundId,
|
||||
},
|
||||
select: { projectId: true, requirementId: true, submissionFileRequirementId: true },
|
||||
select: { projectId: true, requirementId: true },
|
||||
}) as Promise<FileRow[]>,
|
||||
prisma.projectRoundState.findMany({
|
||||
where: { roundId, projectId: { in: projectIds } },
|
||||
@@ -1004,18 +952,8 @@ export async function batchCheckRequirementsAndTransition(
|
||||
if (!currentState || !eligibleStates.includes(currentState)) continue
|
||||
|
||||
const files = filesByProject.get(projectId) ?? []
|
||||
|
||||
// Check legacy requirements
|
||||
if (requirements.length > 0) {
|
||||
const fulfilledIds = new Set(files.map((f) => f.requirementId).filter(Boolean))
|
||||
if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) continue
|
||||
}
|
||||
|
||||
// Check submission requirements
|
||||
if (submissionRequirements.length > 0) {
|
||||
const fulfilledSubIds = new Set(files.map((f) => f.submissionFileRequirementId).filter(Boolean))
|
||||
if (!submissionRequirements.every((r: { id: string }) => fulfilledSubIds.has(r.id))) continue
|
||||
}
|
||||
const fulfilledIds = new Set(files.map((f) => f.requirementId).filter(Boolean))
|
||||
if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) continue
|
||||
|
||||
toTransition.push(projectId)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user