Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
/**
|
|
|
|
|
* Round Engine Service
|
|
|
|
|
*
|
|
|
|
|
* State machine for round lifecycle transitions, operating on Round +
|
|
|
|
|
* ProjectRoundState. Parallels stage-engine.ts but for the Competition/Round
|
|
|
|
|
* architecture.
|
|
|
|
|
*
|
|
|
|
|
* Key invariants:
|
|
|
|
|
* - Round transitions follow: ROUND_DRAFT → ROUND_ACTIVE → ROUND_CLOSED → ROUND_ARCHIVED
|
|
|
|
|
* - Project transitions within an active round only
|
|
|
|
|
* - All mutations are transactional with dual audit trail
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type { PrismaClient, ProjectRoundStateValue, Prisma } from '@prisma/client'
|
|
|
|
|
import { logAudit } from '@/server/utils/audit'
|
|
|
|
|
import { safeValidateRoundConfig } from '@/types/competition-configs'
|
|
|
|
|
import { expireIntentsForRound } from './assignment-intent'
|
2026-03-03 19:14:41 +01:00
|
|
|
import { processRoundClose } from './round-finalization'
|
feat(mentor): defer all assignment emails until round opens + per-project bulk UI
Email policy
- mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress
outbound email entirely when the project's MENTORING round is still
ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications
still fire), but notificationSentAt and teamIntroducedAt remain null so
activateRound can pick them up later.
- activateRound, when activating a MENTORING round, now does a coalesced
mentor-side email pass in addition to the existing team-side intro pass.
Every (mentorId) bucket of pending assignments in this round gets exactly
one combined email; the row stamps prevent duplicates on re-activation.
- The "send immediately" path is preserved for assignments made while the
round is already ROUND_ACTIVE — mentors and teams stay in the loop in
real time, but staging during draft is silent.
Per-project bulk UI
- The /admin/projects/[id]/mentor manual picker now has a checkbox column,
header select-all, and a primary-tinted action toolbar that appears when
one or more candidates are selected. Submitting calls mentor.bulkAssign
with the single projectId so the cartesian server path handles dedup,
coalesced emails, and team intros uniformly with the round-page bulk.
2026-05-26 14:48:38 +02:00
|
|
|
import {
|
|
|
|
|
sendMentorBulkAssignmentEmail,
|
|
|
|
|
sendTeamMentorIntroductionEmail,
|
|
|
|
|
} from '@/lib/email'
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
|
|
|
|
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export type RoundTransitionResult = {
|
|
|
|
|
success: boolean
|
|
|
|
|
round?: { id: string; status: string }
|
|
|
|
|
errors?: string[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type ProjectRoundTransitionResult = {
|
|
|
|
|
success: boolean
|
|
|
|
|
projectRoundState?: {
|
|
|
|
|
id: string
|
|
|
|
|
projectId: string
|
|
|
|
|
roundId: string
|
|
|
|
|
state: ProjectRoundStateValue
|
|
|
|
|
}
|
|
|
|
|
errors?: string[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type BatchProjectTransitionResult = {
|
|
|
|
|
succeeded: string[]
|
|
|
|
|
failed: Array<{ projectId: string; errors: string[] }>
|
|
|
|
|
total: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const BATCH_SIZE = 50
|
|
|
|
|
|
|
|
|
|
// ─── Valid Transition Maps ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
|
|
|
|
|
ROUND_DRAFT: ['ROUND_ACTIVE'],
|
|
|
|
|
ROUND_ACTIVE: ['ROUND_CLOSED'],
|
2026-02-16 12:06:07 +01:00
|
|
|
ROUND_CLOSED: ['ROUND_ACTIVE', 'ROUND_ARCHIVED'],
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
ROUND_ARCHIVED: [],
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 12:59:35 +01:00
|
|
|
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
|
2026-03-03 19:14:41 +01:00
|
|
|
PENDING: ['IN_PROGRESS', 'REJECTED', 'WITHDRAWN'],
|
|
|
|
|
IN_PROGRESS: ['COMPLETED', 'REJECTED', 'WITHDRAWN'],
|
|
|
|
|
COMPLETED: ['PASSED', 'REJECTED'],
|
|
|
|
|
PASSED: ['IN_PROGRESS', 'WITHDRAWN'],
|
2026-02-19 12:59:35 +01:00
|
|
|
REJECTED: ['PENDING'], // re-include
|
|
|
|
|
WITHDRAWN: ['PENDING'], // re-include
|
|
|
|
|
}
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
// ─── Round-Level Transitions ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Activate a round: ROUND_DRAFT → ROUND_ACTIVE
|
|
|
|
|
* Guards: configJson is valid, competition is not ARCHIVED
|
|
|
|
|
* Side effects: expire pending intents from previous round (if any)
|
|
|
|
|
*/
|
|
|
|
|
export async function activateRound(
|
|
|
|
|
roundId: string,
|
|
|
|
|
actorId: string,
|
2026-03-10 12:47:06 +01:00
|
|
|
prisma: PrismaClient,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
): Promise<RoundTransitionResult> {
|
|
|
|
|
try {
|
|
|
|
|
const round = await prisma.round.findUnique({
|
|
|
|
|
where: { id: roundId },
|
|
|
|
|
include: { competition: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!round) {
|
|
|
|
|
return { success: false, errors: [`Round ${roundId} not found`] }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check valid transition
|
|
|
|
|
if (round.status !== 'ROUND_DRAFT') {
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
errors: [`Cannot activate round: current status is ${round.status}, expected ROUND_DRAFT`],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Guard: competition must not be ARCHIVED
|
|
|
|
|
if (round.competition.status === 'ARCHIVED') {
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
errors: ['Cannot activate round: competition is ARCHIVED'],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Guard: configJson must be valid
|
|
|
|
|
if (round.configJson) {
|
|
|
|
|
const validation = safeValidateRoundConfig(
|
|
|
|
|
round.roundType,
|
|
|
|
|
round.configJson as Record<string, unknown>,
|
|
|
|
|
)
|
|
|
|
|
if (!validation.success) {
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
errors: [`Invalid round config: ${validation.error.message}`],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 13:48:12 +01:00
|
|
|
// If activating before the scheduled start, snap windowOpenAt to now
|
|
|
|
|
const now = new Date()
|
|
|
|
|
const windowData: Record<string, Date> = {}
|
|
|
|
|
if (round.windowOpenAt && new Date(round.windowOpenAt) > now) {
|
|
|
|
|
windowData.windowOpenAt = now
|
|
|
|
|
}
|
|
|
|
|
// If no windowOpenAt was set at all, also set it to now
|
|
|
|
|
if (!round.windowOpenAt) {
|
|
|
|
|
windowData.windowOpenAt = now
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 12:47:06 +01:00
|
|
|
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
const result = await tx.round.update({
|
|
|
|
|
where: { id: roundId },
|
2026-02-17 13:48:12 +01:00
|
|
|
data: { status: 'ROUND_ACTIVE', ...windowData },
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await tx.decisionAuditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
eventType: 'round.activated',
|
|
|
|
|
entityType: 'Round',
|
|
|
|
|
entityId: roundId,
|
|
|
|
|
actorId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
roundName: round.name,
|
|
|
|
|
roundType: round.roundType,
|
|
|
|
|
competitionId: round.competitionId,
|
|
|
|
|
previousStatus: 'ROUND_DRAFT',
|
|
|
|
|
},
|
|
|
|
|
snapshotJson: {
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
emittedBy: 'round-engine',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-16 12:38:28 +01:00
|
|
|
// Audit log outside transaction to avoid FK violations poisoning the tx
|
|
|
|
|
await logAudit({
|
|
|
|
|
userId: actorId,
|
|
|
|
|
action: 'ROUND_ACTIVATE',
|
|
|
|
|
entityType: 'Round',
|
|
|
|
|
entityId: roundId,
|
|
|
|
|
detailsJson: { name: round.name, roundType: round.roundType },
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-17 09:29:57 +01:00
|
|
|
// Retroactive check: auto-PASS any projects that already have all required docs uploaded
|
|
|
|
|
// Non-fatal — runs after activation so it never blocks the transition
|
|
|
|
|
try {
|
|
|
|
|
const projectStates = await prisma.projectRoundState.findMany({
|
|
|
|
|
where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
|
|
|
|
|
select: { projectId: true },
|
|
|
|
|
})
|
|
|
|
|
if (projectStates.length > 0) {
|
|
|
|
|
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
|
|
|
|
|
const result = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
|
|
|
|
|
if (result.transitionedCount > 0) {
|
2026-03-03 19:14:41 +01:00
|
|
|
console.log(`[RoundEngine] On activation: auto-completed ${result.transitionedCount} projects with complete documents`)
|
2026-02-17 09:29:57 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (retroError) {
|
|
|
|
|
console.error('[RoundEngine] Retroactive document check failed (non-fatal):', retroError)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
// Mentoring pass-through: for MENTORING rounds with passThroughIfNoRequest,
|
|
|
|
|
// auto-set all PENDING projects to PASSED (they pass through unless they request mentoring)
|
|
|
|
|
if (round.roundType === 'MENTORING') {
|
|
|
|
|
try {
|
|
|
|
|
const mentoringConfig = safeValidateRoundConfig('MENTORING', round.configJson as Record<string, unknown>)
|
|
|
|
|
if (mentoringConfig.success && mentoringConfig.data.passThroughIfNoRequest) {
|
|
|
|
|
const pendingProjects = await prisma.projectRoundState.findMany({
|
|
|
|
|
where: { roundId, state: 'PENDING' },
|
|
|
|
|
select: { id: true, projectId: true, metadataJson: true },
|
|
|
|
|
})
|
|
|
|
|
let passedCount = 0
|
|
|
|
|
for (const prs of pendingProjects) {
|
|
|
|
|
const meta = (prs.metadataJson as Record<string, unknown>) ?? {}
|
|
|
|
|
// Only pass-through projects that haven't requested mentoring
|
|
|
|
|
if (!meta.mentoringRequested) {
|
|
|
|
|
await prisma.projectRoundState.update({
|
|
|
|
|
where: { id: prs.id },
|
|
|
|
|
data: { state: 'PASSED' },
|
|
|
|
|
})
|
|
|
|
|
passedCount++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (passedCount > 0) {
|
|
|
|
|
console.log(`[RoundEngine] Mentoring pass-through: set ${passedCount} projects to PASSED`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (mentoringError) {
|
|
|
|
|
console.error('[RoundEngine] Mentoring pass-through failed (non-fatal):', mentoringError)
|
|
|
|
|
}
|
feat(mentor): bulk assignment + coalesced emails + team intros on round open
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
toolbar that appears when 1+ rows are selected with an "Assign mentor…"
CTA and Clear. Dialog lists the mentor pool with search (name/email/
country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
one mentor to many projects in a transaction; idempotent on the per-pair
`(projectId, mentorId)` unique; per-project in-app notifications still
fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
so the page reflects the new state without a refresh.
Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
assigned project + workspace links) used by `mentor.bulkAssign` and
`mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
now emails mentors at the end of the batch, one combined email per
mentor regardless of how many projects they received.
Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
name + email and a link to the workspace, so teams can reach out
directly.
- `activateRound` (round-engine) fires the introduction for every project
in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
fire the introduction immediately when the project's MENTORING round is
already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
(migration 20260526114936) — independent from `notificationSentAt` so
pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
|
|
|
|
feat(mentor): defer all assignment emails until round opens + per-project bulk UI
Email policy
- mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress
outbound email entirely when the project's MENTORING round is still
ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications
still fire), but notificationSentAt and teamIntroducedAt remain null so
activateRound can pick them up later.
- activateRound, when activating a MENTORING round, now does a coalesced
mentor-side email pass in addition to the existing team-side intro pass.
Every (mentorId) bucket of pending assignments in this round gets exactly
one combined email; the row stamps prevent duplicates on re-activation.
- The "send immediately" path is preserved for assignments made while the
round is already ROUND_ACTIVE — mentors and teams stay in the loop in
real time, but staging during draft is silent.
Per-project bulk UI
- The /admin/projects/[id]/mentor manual picker now has a checkbox column,
header select-all, and a primary-tinted action toolbar that appears when
one or more candidates are selected. Submitting calls mentor.bulkAssign
with the single projectId so the cartesian server path handles dedup,
coalesced emails, and team intros uniformly with the round-page bulk.
2026-05-26 14:48:38 +02:00
|
|
|
// Mentor-side coalesced emails on round open. Picks up every assignment
|
|
|
|
|
// for projects in this round whose notificationSentAt is null (i.e.
|
|
|
|
|
// assignments made while the round was still in draft), groups by
|
|
|
|
|
// mentor, and sends a single combined email per mentor listing all
|
|
|
|
|
// their projects in this round.
|
|
|
|
|
try {
|
|
|
|
|
const pendingAssignments = await prisma.mentorAssignment.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
droppedAt: null,
|
|
|
|
|
notificationSentAt: null,
|
|
|
|
|
project: { projectRoundStates: { some: { roundId } } },
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
mentorId: true,
|
|
|
|
|
mentor: { select: { name: true, email: true } },
|
|
|
|
|
project: { select: { id: true, title: true } },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
const perMentor = new Map<
|
|
|
|
|
string,
|
|
|
|
|
{
|
|
|
|
|
email: string | null
|
|
|
|
|
name: string | null
|
|
|
|
|
assignmentIds: string[]
|
|
|
|
|
projects: { id: string; title: string }[]
|
|
|
|
|
}
|
|
|
|
|
>()
|
|
|
|
|
for (const a of pendingAssignments) {
|
|
|
|
|
if (!a.mentor?.email) continue
|
|
|
|
|
const bucket = perMentor.get(a.mentorId) ?? {
|
|
|
|
|
email: a.mentor.email,
|
|
|
|
|
name: a.mentor.name,
|
|
|
|
|
assignmentIds: [],
|
|
|
|
|
projects: [],
|
|
|
|
|
}
|
|
|
|
|
bucket.assignmentIds.push(a.id)
|
|
|
|
|
bucket.projects.push({ id: a.project.id, title: a.project.title })
|
|
|
|
|
perMentor.set(a.mentorId, bucket)
|
|
|
|
|
}
|
|
|
|
|
for (const bucket of perMentor.values()) {
|
|
|
|
|
if (bucket.projects.length === 0 || !bucket.email) continue
|
|
|
|
|
await sendMentorBulkAssignmentEmail(
|
|
|
|
|
bucket.email,
|
|
|
|
|
bucket.name,
|
|
|
|
|
bucket.projects,
|
|
|
|
|
)
|
|
|
|
|
await prisma.mentorAssignment.updateMany({
|
|
|
|
|
where: { id: { in: bucket.assignmentIds } },
|
|
|
|
|
data: { notificationSentAt: new Date() },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if (perMentor.size > 0) {
|
|
|
|
|
console.log(
|
|
|
|
|
`[RoundEngine] MENTORING round open: notified ${perMentor.size} mentor(s) about their assignments`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
} catch (mentorEmailError) {
|
|
|
|
|
console.error(
|
|
|
|
|
'[RoundEngine] Mentor-side coalesced notification failed (non-fatal):',
|
|
|
|
|
mentorEmailError,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
feat(mentor): bulk assignment + coalesced emails + team intros on round open
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
toolbar that appears when 1+ rows are selected with an "Assign mentor…"
CTA and Clear. Dialog lists the mentor pool with search (name/email/
country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
one mentor to many projects in a transaction; idempotent on the per-pair
`(projectId, mentorId)` unique; per-project in-app notifications still
fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
so the page reflects the new state without a refresh.
Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
assigned project + workspace links) used by `mentor.bulkAssign` and
`mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
now emails mentors at the end of the batch, one combined email per
mentor regardless of how many projects they received.
Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
name + email and a link to the workspace, so teams can reach out
directly.
- `activateRound` (round-engine) fires the introduction for every project
in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
fire the introduction immediately when the project's MENTORING round is
already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
(migration 20260526114936) — independent from `notificationSentAt` so
pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
|
|
|
// Introduce teams to their mentors via email when the round opens.
|
|
|
|
|
// Idempotent via MentorAssignment.teamIntroducedAt — separate from the
|
|
|
|
|
// mentor-side notificationSentAt so the team email fires even when the
|
|
|
|
|
// mentor was assigned (and notified) before the round opened.
|
|
|
|
|
try {
|
|
|
|
|
const projectsToIntroduce = await prisma.project.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
projectRoundStates: { some: { roundId } },
|
|
|
|
|
mentorAssignments: {
|
|
|
|
|
some: { droppedAt: null, teamIntroducedAt: null },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
title: true,
|
|
|
|
|
mentorAssignments: {
|
|
|
|
|
where: { droppedAt: null },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
teamIntroducedAt: true,
|
|
|
|
|
mentor: { select: { name: true, email: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
teamMembers: {
|
|
|
|
|
select: { user: { select: { name: true, email: true } } },
|
|
|
|
|
},
|
|
|
|
|
submittedByEmail: true,
|
|
|
|
|
submittedBy: { select: { name: true } },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
for (const p of projectsToIntroduce) {
|
|
|
|
|
const mentors = p.mentorAssignments
|
|
|
|
|
.filter((a) => a.mentor?.email)
|
|
|
|
|
.map((a) => ({
|
|
|
|
|
name: a.mentor.name,
|
|
|
|
|
email: a.mentor.email,
|
|
|
|
|
}))
|
|
|
|
|
if (mentors.length === 0) continue
|
|
|
|
|
|
|
|
|
|
// Build a unique recipient set: team-member users with emails,
|
|
|
|
|
// plus the original submitter (in case they're not on the team yet).
|
|
|
|
|
const recipients = new Map<string, { name: string | null }>()
|
|
|
|
|
for (const tm of p.teamMembers) {
|
|
|
|
|
if (tm.user?.email) {
|
|
|
|
|
recipients.set(tm.user.email, { name: tm.user.name })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
p.submittedByEmail &&
|
|
|
|
|
!recipients.has(p.submittedByEmail)
|
|
|
|
|
) {
|
|
|
|
|
recipients.set(p.submittedByEmail, {
|
|
|
|
|
name: p.submittedBy?.name ?? null,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [email, { name }] of recipients) {
|
|
|
|
|
await sendTeamMentorIntroductionEmail(email, name, p.title, p.id, mentors)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Stamp every mentor-assignment row so re-activation doesn't re-send.
|
|
|
|
|
const idsToStamp = p.mentorAssignments
|
|
|
|
|
.filter((a) => a.teamIntroducedAt == null)
|
|
|
|
|
.map((a) => a.id)
|
|
|
|
|
if (idsToStamp.length > 0) {
|
|
|
|
|
await prisma.mentorAssignment.updateMany({
|
|
|
|
|
where: { id: { in: idsToStamp } },
|
|
|
|
|
data: { teamIntroducedAt: new Date() },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (projectsToIntroduce.length > 0) {
|
|
|
|
|
console.log(
|
|
|
|
|
`[RoundEngine] MENTORING round open: introduced mentors for ${projectsToIntroduce.length} project(s)`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
} catch (introError) {
|
|
|
|
|
console.error('[RoundEngine] Team-mentor introduction failed (non-fatal):', introError)
|
|
|
|
|
}
|
2026-03-03 19:14:41 +01:00
|
|
|
}
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
round: { id: updated.id, status: updated.status },
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[RoundEngine] activateRound failed:', error)
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
errors: [error instanceof Error ? error.message : 'Unknown error during round activation'],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Close a round: ROUND_ACTIVE → ROUND_CLOSED
|
|
|
|
|
* Guards: all submission windows closed (if submission/mentoring round)
|
|
|
|
|
* Side effects: expire all INTENT_PENDING for this round
|
|
|
|
|
*/
|
|
|
|
|
export async function closeRound(
|
|
|
|
|
roundId: string,
|
|
|
|
|
actorId: string,
|
2026-03-10 12:47:06 +01:00
|
|
|
prisma: PrismaClient,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
): Promise<RoundTransitionResult> {
|
|
|
|
|
try {
|
|
|
|
|
const round = await prisma.round.findUnique({
|
|
|
|
|
where: { id: roundId },
|
|
|
|
|
include: { submissionWindow: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!round) {
|
|
|
|
|
return { success: false, errors: [`Round ${roundId} not found`] }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (round.status !== 'ROUND_ACTIVE') {
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
errors: [`Cannot close round: current status is ${round.status}, expected ROUND_ACTIVE`],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Guard: submission window must be closed/locked for submission/mentoring rounds
|
|
|
|
|
if (
|
|
|
|
|
(round.roundType === 'SUBMISSION' || round.roundType === 'MENTORING') &&
|
|
|
|
|
round.submissionWindow
|
|
|
|
|
) {
|
|
|
|
|
const sw = round.submissionWindow
|
|
|
|
|
if (sw.windowCloseAt && new Date() < sw.windowCloseAt && !sw.isLocked) {
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
errors: ['Cannot close round: linked submission window is still open'],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 12:47:06 +01:00
|
|
|
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
const result = await tx.round.update({
|
|
|
|
|
where: { id: roundId },
|
|
|
|
|
data: { status: 'ROUND_CLOSED' },
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-19 12:59:35 +01:00
|
|
|
// Expire pending intents (using the transaction client)
|
|
|
|
|
await expireIntentsForRound(roundId, actorId, tx)
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
|
2026-02-17 13:55:44 +01:00
|
|
|
// Auto-close any preceding active rounds (lower sortOrder, same competition)
|
|
|
|
|
const precedingActiveRounds = await tx.round.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
competitionId: round.competitionId,
|
|
|
|
|
sortOrder: { lt: round.sortOrder },
|
|
|
|
|
status: 'ROUND_ACTIVE',
|
|
|
|
|
},
|
|
|
|
|
orderBy: { sortOrder: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
for (const prev of precedingActiveRounds) {
|
|
|
|
|
await tx.round.update({
|
|
|
|
|
where: { id: prev.id },
|
|
|
|
|
data: { status: 'ROUND_CLOSED' },
|
|
|
|
|
})
|
|
|
|
|
await tx.decisionAuditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
eventType: 'round.closed',
|
|
|
|
|
entityType: 'Round',
|
|
|
|
|
entityId: prev.id,
|
|
|
|
|
actorId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
roundName: prev.name,
|
|
|
|
|
roundType: prev.roundType,
|
|
|
|
|
previousStatus: 'ROUND_ACTIVE',
|
|
|
|
|
closedBy: 'cascade',
|
|
|
|
|
triggeringRoundId: roundId,
|
|
|
|
|
},
|
|
|
|
|
snapshotJson: {
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
emittedBy: 'round-engine',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
await tx.decisionAuditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
eventType: 'round.closed',
|
|
|
|
|
entityType: 'Round',
|
|
|
|
|
entityId: roundId,
|
|
|
|
|
actorId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
roundName: round.name,
|
|
|
|
|
roundType: round.roundType,
|
|
|
|
|
previousStatus: 'ROUND_ACTIVE',
|
2026-02-17 13:55:44 +01:00
|
|
|
cascadeClosed: precedingActiveRounds.map((r: any) => r.name),
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
},
|
|
|
|
|
snapshotJson: {
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
emittedBy: 'round-engine',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-16 12:38:28 +01:00
|
|
|
// Audit log outside transaction to avoid FK violations poisoning the tx
|
|
|
|
|
await logAudit({
|
|
|
|
|
userId: actorId,
|
|
|
|
|
action: 'ROUND_CLOSE',
|
|
|
|
|
entityType: 'Round',
|
|
|
|
|
entityId: roundId,
|
|
|
|
|
detailsJson: { name: round.name, roundType: round.roundType },
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
// Grace period / immediate finalization processing
|
|
|
|
|
try {
|
|
|
|
|
const config = round.configJson ? (round.configJson as Record<string, unknown>) : {}
|
|
|
|
|
const gracePeriodHours = (config.gracePeriodHours as number) ?? 0
|
|
|
|
|
|
|
|
|
|
if (gracePeriodHours > 0) {
|
|
|
|
|
const gracePeriodEndsAt = new Date(Date.now() + gracePeriodHours * 60 * 60 * 1000)
|
|
|
|
|
await prisma.round.update({
|
|
|
|
|
where: { id: roundId },
|
|
|
|
|
data: { gracePeriodEndsAt },
|
|
|
|
|
})
|
|
|
|
|
console.log(`[RoundEngine] Grace period set for round ${roundId}: ${gracePeriodHours}h (until ${gracePeriodEndsAt.toISOString()})`)
|
|
|
|
|
} else {
|
|
|
|
|
await processRoundClose(roundId, actorId, prisma)
|
|
|
|
|
console.log(`[RoundEngine] Processed round close for ${roundId} (no grace period)`)
|
|
|
|
|
}
|
|
|
|
|
} catch (processError) {
|
|
|
|
|
console.error('[RoundEngine] processRoundClose after close failed (non-fatal):', processError)
|
|
|
|
|
}
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
round: { id: updated.id, status: updated.status },
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[RoundEngine] closeRound failed:', error)
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
errors: [error instanceof Error ? error.message : 'Unknown error during round close'],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Archive a round: ROUND_CLOSED → ROUND_ARCHIVED
|
|
|
|
|
* No guards.
|
|
|
|
|
*/
|
|
|
|
|
export async function archiveRound(
|
|
|
|
|
roundId: string,
|
|
|
|
|
actorId: string,
|
2026-03-10 12:47:06 +01:00
|
|
|
prisma: PrismaClient,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
): Promise<RoundTransitionResult> {
|
|
|
|
|
try {
|
|
|
|
|
const round = await prisma.round.findUnique({ where: { id: roundId } })
|
|
|
|
|
|
|
|
|
|
if (!round) {
|
|
|
|
|
return { success: false, errors: [`Round ${roundId} not found`] }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (round.status !== 'ROUND_CLOSED') {
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
errors: [`Cannot archive round: current status is ${round.status}, expected ROUND_CLOSED`],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 12:47:06 +01:00
|
|
|
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
const result = await tx.round.update({
|
|
|
|
|
where: { id: roundId },
|
|
|
|
|
data: { status: 'ROUND_ARCHIVED' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await tx.decisionAuditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
eventType: 'round.archived',
|
|
|
|
|
entityType: 'Round',
|
|
|
|
|
entityId: roundId,
|
|
|
|
|
actorId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
roundName: round.name,
|
|
|
|
|
previousStatus: 'ROUND_CLOSED',
|
|
|
|
|
},
|
|
|
|
|
snapshotJson: {
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
emittedBy: 'round-engine',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-16 12:38:28 +01:00
|
|
|
// Audit log outside transaction to avoid FK violations poisoning the tx
|
|
|
|
|
await logAudit({
|
|
|
|
|
userId: actorId,
|
|
|
|
|
action: 'ROUND_ARCHIVE',
|
|
|
|
|
entityType: 'Round',
|
|
|
|
|
entityId: roundId,
|
|
|
|
|
detailsJson: { name: round.name },
|
|
|
|
|
})
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
round: { id: updated.id, status: updated.status },
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[RoundEngine] archiveRound failed:', error)
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
errors: [error instanceof Error ? error.message : 'Unknown error during round archive'],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 12:06:07 +01:00
|
|
|
/**
|
|
|
|
|
* Reopen a round: ROUND_CLOSED → ROUND_ACTIVE
|
|
|
|
|
* Side effects: any subsequent rounds in the same competition that are
|
|
|
|
|
* ROUND_ACTIVE will be paused (set to ROUND_CLOSED) to prevent two
|
|
|
|
|
* active rounds overlapping.
|
|
|
|
|
*/
|
|
|
|
|
export async function reopenRound(
|
|
|
|
|
roundId: string,
|
|
|
|
|
actorId: string,
|
2026-03-10 12:47:06 +01:00
|
|
|
prisma: PrismaClient,
|
2026-02-16 12:06:07 +01:00
|
|
|
): Promise<RoundTransitionResult & { pausedRounds?: string[] }> {
|
|
|
|
|
try {
|
|
|
|
|
const round = await prisma.round.findUnique({
|
|
|
|
|
where: { id: roundId },
|
|
|
|
|
include: { competition: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!round) {
|
|
|
|
|
return { success: false, errors: [`Round ${roundId} not found`] }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (round.status !== 'ROUND_CLOSED') {
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
errors: [`Cannot reopen round: current status is ${round.status}, expected ROUND_CLOSED`],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 12:47:06 +01:00
|
|
|
const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
2026-02-16 12:06:07 +01:00
|
|
|
// Pause any subsequent active rounds in the same competition
|
|
|
|
|
const subsequentActiveRounds = await tx.round.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
competitionId: round.competitionId,
|
|
|
|
|
sortOrder: { gt: round.sortOrder },
|
|
|
|
|
status: 'ROUND_ACTIVE',
|
|
|
|
|
},
|
|
|
|
|
select: { id: true, name: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (subsequentActiveRounds.length > 0) {
|
|
|
|
|
await tx.round.updateMany({
|
|
|
|
|
where: { id: { in: subsequentActiveRounds.map((r: any) => r.id) } },
|
|
|
|
|
data: { status: 'ROUND_CLOSED' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit each paused round
|
|
|
|
|
for (const paused of subsequentActiveRounds) {
|
|
|
|
|
await tx.decisionAuditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
eventType: 'round.paused',
|
|
|
|
|
entityType: 'Round',
|
|
|
|
|
entityId: paused.id,
|
|
|
|
|
actorId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
roundName: paused.name,
|
|
|
|
|
reason: `Paused because prior round "${round.name}" was reopened`,
|
|
|
|
|
previousStatus: 'ROUND_ACTIVE',
|
|
|
|
|
},
|
|
|
|
|
snapshotJson: {
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
emittedBy: 'round-engine',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 15:20:43 +01:00
|
|
|
// Reopen this round — clear windowCloseAt so the voting window check
|
|
|
|
|
// doesn't reject submissions with "Voting window has closed".
|
2026-02-16 12:06:07 +01:00
|
|
|
const updated = await tx.round.update({
|
|
|
|
|
where: { id: roundId },
|
2026-03-02 15:20:43 +01:00
|
|
|
data: { status: 'ROUND_ACTIVE', windowCloseAt: null },
|
2026-02-16 12:06:07 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await tx.decisionAuditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
eventType: 'round.reopened',
|
|
|
|
|
entityType: 'Round',
|
|
|
|
|
entityId: roundId,
|
|
|
|
|
actorId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
roundName: round.name,
|
|
|
|
|
previousStatus: 'ROUND_CLOSED',
|
|
|
|
|
pausedRounds: subsequentActiveRounds.map((r: any) => r.name),
|
|
|
|
|
},
|
|
|
|
|
snapshotJson: {
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
emittedBy: 'round-engine',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
updated,
|
|
|
|
|
pausedRounds: subsequentActiveRounds.map((r: any) => r.name),
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-16 12:38:28 +01:00
|
|
|
// Audit log outside transaction to avoid FK violations poisoning the tx
|
|
|
|
|
await logAudit({
|
|
|
|
|
userId: actorId,
|
|
|
|
|
action: 'ROUND_REOPEN',
|
|
|
|
|
entityType: 'Round',
|
|
|
|
|
entityId: roundId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
name: round.name,
|
|
|
|
|
pausedRounds: result.pausedRounds,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-17 09:29:57 +01:00
|
|
|
// Retroactive check: auto-PASS any projects that already have all required docs
|
|
|
|
|
try {
|
|
|
|
|
const projectStates = await prisma.projectRoundState.findMany({
|
|
|
|
|
where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
|
|
|
|
|
select: { projectId: true },
|
|
|
|
|
})
|
|
|
|
|
if (projectStates.length > 0) {
|
|
|
|
|
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
|
|
|
|
|
const batchResult = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
|
|
|
|
|
if (batchResult.transitionedCount > 0) {
|
|
|
|
|
console.log(`[RoundEngine] On reopen: auto-passed ${batchResult.transitionedCount} projects with complete documents`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (retroError) {
|
|
|
|
|
console.error('[RoundEngine] Retroactive document check on reopen failed (non-fatal):', retroError)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 12:06:07 +01:00
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
round: { id: result.updated.id, status: result.updated.status },
|
|
|
|
|
pausedRounds: result.pausedRounds,
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[RoundEngine] reopenRound failed:', error)
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
errors: [error instanceof Error ? error.message : 'Unknown error during round reopen'],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
// ─── Project-Level Transitions ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Transition a project within a round.
|
|
|
|
|
* Upserts ProjectRoundState: create if not exists, update if exists.
|
|
|
|
|
* Validate: round must be ROUND_ACTIVE.
|
|
|
|
|
* Dual audit trail (DecisionAuditLog + logAudit).
|
|
|
|
|
*/
|
|
|
|
|
export async function transitionProject(
|
|
|
|
|
projectId: string,
|
|
|
|
|
roundId: string,
|
|
|
|
|
newState: ProjectRoundStateValue,
|
|
|
|
|
actorId: string,
|
2026-03-10 12:47:06 +01:00
|
|
|
prisma: PrismaClient,
|
2026-02-19 12:59:35 +01:00
|
|
|
options?: { adminOverride?: boolean },
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
): Promise<ProjectRoundTransitionResult> {
|
|
|
|
|
try {
|
|
|
|
|
const round = await prisma.round.findUnique({ where: { id: roundId } })
|
|
|
|
|
|
|
|
|
|
if (!round) {
|
|
|
|
|
return { success: false, errors: [`Round ${roundId} not found`] }
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
if (round.status !== 'ROUND_ACTIVE' && round.status !== 'ROUND_CLOSED') {
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
return {
|
|
|
|
|
success: false,
|
2026-03-03 19:14:41 +01:00
|
|
|
errors: [`Round is ${round.status}, must be ROUND_ACTIVE or ROUND_CLOSED to transition projects`],
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify project exists
|
|
|
|
|
const project = await prisma.project.findUnique({ where: { id: projectId } })
|
|
|
|
|
if (!project) {
|
|
|
|
|
return { success: false, errors: [`Project ${projectId} not found`] }
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 12:47:06 +01:00
|
|
|
const result = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
const now = new Date()
|
|
|
|
|
|
|
|
|
|
// Upsert ProjectRoundState
|
|
|
|
|
const existing = await tx.projectRoundState.findUnique({
|
|
|
|
|
where: { projectId_roundId: { projectId, roundId } },
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-19 12:59:35 +01:00
|
|
|
// Enforce project state transition whitelist (unless admin override)
|
|
|
|
|
if (existing && !options?.adminOverride) {
|
|
|
|
|
const currentState = existing.state as string
|
|
|
|
|
const allowed = VALID_PROJECT_TRANSITIONS[currentState] ?? []
|
|
|
|
|
if (!allowed.includes(newState)) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Invalid project transition: ${currentState} → ${newState}. Allowed: ${allowed.join(', ') || 'none (terminal state)'}`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
let prs
|
|
|
|
|
if (existing) {
|
|
|
|
|
prs = await tx.projectRoundState.update({
|
|
|
|
|
where: { id: existing.id },
|
|
|
|
|
data: {
|
|
|
|
|
state: newState,
|
|
|
|
|
exitedAt: isTerminalState(newState) ? now : null,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
prs = await tx.projectRoundState.create({
|
|
|
|
|
data: {
|
|
|
|
|
projectId,
|
|
|
|
|
roundId,
|
|
|
|
|
state: newState,
|
|
|
|
|
enteredAt: now,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dual audit trail
|
|
|
|
|
await tx.decisionAuditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
eventType: 'project_round.transitioned',
|
|
|
|
|
entityType: 'ProjectRoundState',
|
|
|
|
|
entityId: prs.id,
|
|
|
|
|
actorId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
projectId,
|
|
|
|
|
roundId,
|
|
|
|
|
previousState: existing?.state ?? null,
|
|
|
|
|
newState,
|
|
|
|
|
} as Prisma.InputJsonValue,
|
|
|
|
|
snapshotJson: {
|
|
|
|
|
timestamp: now.toISOString(),
|
|
|
|
|
emittedBy: 'round-engine',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-16 12:38:28 +01:00
|
|
|
return { prs, previousState: existing?.state ?? null }
|
|
|
|
|
})
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
|
2026-02-16 12:38:28 +01:00
|
|
|
// Audit log outside transaction to avoid FK violations poisoning the tx
|
|
|
|
|
await logAudit({
|
|
|
|
|
userId: actorId,
|
|
|
|
|
action: 'PROJECT_ROUND_TRANSITION',
|
|
|
|
|
entityType: 'ProjectRoundState',
|
|
|
|
|
entityId: result.prs.id,
|
|
|
|
|
detailsJson: { projectId, roundId, newState, previousState: result.previousState },
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
projectRoundState: {
|
2026-02-16 12:38:28 +01:00
|
|
|
id: result.prs.id,
|
|
|
|
|
projectId: result.prs.projectId,
|
|
|
|
|
roundId: result.prs.roundId,
|
|
|
|
|
state: result.prs.state,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[RoundEngine] transitionProject failed:', error)
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
errors: [error instanceof Error ? error.message : 'Unknown error during project transition'],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Batch transition projects in batches of BATCH_SIZE.
|
|
|
|
|
* Each project is processed independently.
|
|
|
|
|
*/
|
|
|
|
|
export async function batchTransitionProjects(
|
|
|
|
|
projectIds: string[],
|
|
|
|
|
roundId: string,
|
|
|
|
|
newState: ProjectRoundStateValue,
|
|
|
|
|
actorId: string,
|
2026-03-10 12:47:06 +01:00
|
|
|
prisma: PrismaClient,
|
2026-02-19 12:59:35 +01:00
|
|
|
options?: { adminOverride?: boolean },
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
): Promise<BatchProjectTransitionResult> {
|
|
|
|
|
const succeeded: string[] = []
|
|
|
|
|
const failed: Array<{ projectId: string; errors: string[] }> = []
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < projectIds.length; i += BATCH_SIZE) {
|
|
|
|
|
const batch = projectIds.slice(i, i + BATCH_SIZE)
|
|
|
|
|
|
|
|
|
|
const batchPromises = batch.map(async (projectId) => {
|
2026-02-19 12:59:35 +01:00
|
|
|
const result = await transitionProject(projectId, roundId, newState, actorId, prisma, options)
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
succeeded.push(projectId)
|
|
|
|
|
} else {
|
|
|
|
|
failed.push({
|
|
|
|
|
projectId,
|
|
|
|
|
errors: result.errors ?? ['Transition failed'],
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await Promise.all(batchPromises)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { succeeded, failed, total: projectIds.length }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Query Helpers ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getProjectRoundStates(
|
|
|
|
|
roundId: string,
|
2026-03-10 12:47:06 +01:00
|
|
|
prisma: PrismaClient,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
) {
|
2026-02-23 20:38:43 +01:00
|
|
|
const states = await prisma.projectRoundState.findMany({
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
where: { roundId },
|
|
|
|
|
include: {
|
|
|
|
|
project: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
title: true,
|
|
|
|
|
teamName: true,
|
|
|
|
|
competitionCategory: true,
|
2026-02-18 22:47:20 +01:00
|
|
|
country: true,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
status: true,
|
2026-02-23 20:38:43 +01:00
|
|
|
assignments: {
|
|
|
|
|
where: { roundId },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
isCompleted: true,
|
|
|
|
|
evaluation: { select: { status: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { enteredAt: 'desc' },
|
|
|
|
|
})
|
2026-02-23 20:38:43 +01:00
|
|
|
|
|
|
|
|
// Compute evaluation progress per project
|
|
|
|
|
return states.map((ps: any) => {
|
|
|
|
|
const assignments = ps.project?.assignments ?? []
|
|
|
|
|
const totalAssignments = assignments.length
|
|
|
|
|
const submittedCount = assignments.filter(
|
|
|
|
|
(a: any) => a.evaluation?.status === 'SUBMITTED'
|
|
|
|
|
).length
|
|
|
|
|
return {
|
|
|
|
|
...ps,
|
|
|
|
|
totalAssignments,
|
|
|
|
|
submittedCount,
|
|
|
|
|
project: {
|
|
|
|
|
...ps.project,
|
|
|
|
|
assignments: undefined, // strip raw assignments from response
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
})
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getProjectRoundState(
|
|
|
|
|
projectId: string,
|
|
|
|
|
roundId: string,
|
2026-03-10 12:47:06 +01:00
|
|
|
prisma: PrismaClient,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
) {
|
|
|
|
|
return prisma.projectRoundState.findUnique({
|
|
|
|
|
where: { projectId_roundId: { projectId, roundId } },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 01:43:28 +01:00
|
|
|
// ─── 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,
|
2026-03-10 12:47:06 +01:00
|
|
|
prisma: PrismaClient,
|
2026-02-17 01:43:28 +01:00
|
|
|
): Promise<{ transitioned: boolean; newState?: string }> {
|
|
|
|
|
try {
|
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>
2026-03-31 13:47:42 -04:00
|
|
|
// 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.
|
2026-02-17 01:43:28 +01:00
|
|
|
const requirements = await prisma.fileRequirement.findMany({
|
|
|
|
|
where: { roundId, isRequired: true },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
|
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>
2026-03-31 13:47:42 -04:00
|
|
|
// If the round has no file requirements, nothing to check
|
|
|
|
|
if (requirements.length === 0) {
|
2026-02-17 01:43:28 +01:00
|
|
|
return { transitioned: false }
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-03-31 13:47:42 -04:00
|
|
|
// 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 },
|
|
|
|
|
})
|
2026-02-17 01:43:28 +01:00
|
|
|
|
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>
2026-03-31 13:47:42 -04:00
|
|
|
const fulfilledIds = new Set(
|
|
|
|
|
fulfilledFiles
|
|
|
|
|
.map((f: { requirementId: string | null }) => f.requirementId)
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
)
|
2026-02-17 01:43:28 +01:00
|
|
|
|
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>
2026-03-31 13:47:42 -04:00
|
|
|
if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) {
|
2026-02-17 01:43:28 +01:00
|
|
|
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 }
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
// If PENDING, first transition to IN_PROGRESS so the state machine path is valid
|
|
|
|
|
if (currentState.state === 'PENDING') {
|
|
|
|
|
await triggerInProgressOnActivity(projectId, roundId, actorId, prisma)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// All requirements met — transition to COMPLETED (finalization will set PASSED/REJECTED)
|
|
|
|
|
const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
|
2026-02-17 01:43:28 +01:00
|
|
|
|
|
|
|
|
if (result.success) {
|
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>
2026-03-31 13:47:42 -04:00
|
|
|
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to COMPLETED in round ${roundId} (all ${requirements.length} requirements met)`)
|
2026-03-03 19:14:41 +01:00
|
|
|
return { transitioned: true, newState: 'COMPLETED' }
|
2026-02-17 01:43:28 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-03-10 12:47:06 +01:00
|
|
|
prisma: PrismaClient,
|
2026-02-17 01:43:28 +01:00
|
|
|
): Promise<{ transitionedCount: number; projectIds: string[] }> {
|
2026-03-07 16:18:24 +01:00
|
|
|
if (projectIds.length === 0) return { transitionedCount: 0, projectIds: [] }
|
|
|
|
|
|
|
|
|
|
// Pre-load all requirements for this round in batch (avoids per-project queries)
|
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>
2026-03-31 13:47:42 -04:00
|
|
|
// 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 },
|
|
|
|
|
})
|
2026-03-07 16:18:24 +01:00
|
|
|
|
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>
2026-03-31 13:47:42 -04:00
|
|
|
// If no requirements, nothing to check
|
|
|
|
|
if (requirements.length === 0) {
|
2026-03-07 16:18:24 +01:00
|
|
|
return { transitionedCount: 0, projectIds: [] }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pre-load all project files and current states in batch
|
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>
2026-03-31 13:47:42 -04:00
|
|
|
type FileRow = { projectId: string; requirementId: string | null }
|
2026-03-07 16:18:24 +01:00
|
|
|
type StateRow = { projectId: string; state: string }
|
|
|
|
|
|
|
|
|
|
const [allFiles, allStates] = await Promise.all([
|
|
|
|
|
prisma.projectFile.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
projectId: { in: projectIds },
|
|
|
|
|
roundId,
|
|
|
|
|
},
|
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>
2026-03-31 13:47:42 -04:00
|
|
|
select: { projectId: true, requirementId: true },
|
2026-03-07 16:18:24 +01:00
|
|
|
}) as Promise<FileRow[]>,
|
|
|
|
|
prisma.projectRoundState.findMany({
|
|
|
|
|
where: { roundId, projectId: { in: projectIds } },
|
|
|
|
|
select: { projectId: true, state: true },
|
|
|
|
|
}) as Promise<StateRow[]>,
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
// Build per-project lookup maps
|
|
|
|
|
const filesByProject = new Map<string, FileRow[]>()
|
|
|
|
|
for (const f of allFiles) {
|
|
|
|
|
const arr = filesByProject.get(f.projectId) ?? []
|
|
|
|
|
arr.push(f)
|
|
|
|
|
filesByProject.set(f.projectId, arr)
|
|
|
|
|
}
|
|
|
|
|
const stateByProject = new Map(allStates.map((s) => [s.projectId, s.state]))
|
|
|
|
|
|
|
|
|
|
// Determine which projects have all requirements met and are eligible for transition
|
|
|
|
|
const eligibleStates = ['PENDING', 'IN_PROGRESS']
|
|
|
|
|
const toTransition: string[] = []
|
2026-02-17 01:43:28 +01:00
|
|
|
|
|
|
|
|
for (const projectId of projectIds) {
|
2026-03-07 16:18:24 +01:00
|
|
|
const currentState = stateByProject.get(projectId)
|
|
|
|
|
if (!currentState || !eligibleStates.includes(currentState)) continue
|
|
|
|
|
|
|
|
|
|
const files = filesByProject.get(projectId) ?? []
|
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>
2026-03-31 13:47:42 -04:00
|
|
|
const fulfilledIds = new Set(files.map((f) => f.requirementId).filter(Boolean))
|
|
|
|
|
if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) continue
|
2026-03-07 16:18:24 +01:00
|
|
|
|
|
|
|
|
toTransition.push(projectId)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Transition eligible projects (still uses transitionProject for state machine correctness)
|
|
|
|
|
const transitioned: string[] = []
|
|
|
|
|
for (const projectId of toTransition) {
|
|
|
|
|
const currentState = stateByProject.get(projectId)
|
|
|
|
|
// If PENDING, first move to IN_PROGRESS
|
|
|
|
|
if (currentState === 'PENDING') {
|
|
|
|
|
await triggerInProgressOnActivity(projectId, roundId, actorId, prisma)
|
|
|
|
|
}
|
|
|
|
|
const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
|
|
|
|
|
if (result.success) {
|
2026-02-17 01:43:28 +01:00
|
|
|
transitioned.push(projectId)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (transitioned.length > 0) {
|
2026-03-03 19:14:41 +01:00
|
|
|
console.log(`[RoundEngine] Batch auto-transition: ${transitioned.length}/${projectIds.length} projects moved to COMPLETED in round ${roundId}`)
|
2026-02-17 01:43:28 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { transitionedCount: transitioned.length, projectIds: transitioned }
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
// ─── Auto-Transition Hooks ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Trigger PENDING → IN_PROGRESS when a project has activity.
|
|
|
|
|
* Non-fatal: if the project is not PENDING, this is a no-op.
|
|
|
|
|
*/
|
|
|
|
|
export async function triggerInProgressOnActivity(
|
|
|
|
|
projectId: string,
|
|
|
|
|
roundId: string,
|
|
|
|
|
actorId: string,
|
2026-03-10 12:47:06 +01:00
|
|
|
prisma: PrismaClient,
|
2026-03-03 19:14:41 +01:00
|
|
|
): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const prs = await prisma.projectRoundState.findUnique({
|
|
|
|
|
where: { projectId_roundId: { projectId, roundId } },
|
|
|
|
|
select: { state: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!prs || prs.state !== 'PENDING') return
|
|
|
|
|
|
|
|
|
|
const result = await transitionProject(projectId, roundId, 'IN_PROGRESS' as ProjectRoundStateValue, actorId, prisma)
|
|
|
|
|
if (result.success) {
|
|
|
|
|
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to IN_PROGRESS in round ${roundId}`)
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[RoundEngine] triggerInProgressOnActivity failed (non-fatal):', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if all jury assignments for a project in an evaluation round are completed.
|
|
|
|
|
* If yes, transition from IN_PROGRESS → COMPLETED.
|
|
|
|
|
*/
|
|
|
|
|
export async function checkEvaluationCompletionAndTransition(
|
|
|
|
|
projectId: string,
|
|
|
|
|
roundId: string,
|
|
|
|
|
actorId: string,
|
2026-03-10 12:47:06 +01:00
|
|
|
prisma: PrismaClient,
|
2026-03-03 19:14:41 +01:00
|
|
|
): Promise<{ transitioned: boolean }> {
|
|
|
|
|
try {
|
|
|
|
|
const prs = await prisma.projectRoundState.findUnique({
|
|
|
|
|
where: { projectId_roundId: { projectId, roundId } },
|
|
|
|
|
select: { state: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!prs || prs.state !== 'IN_PROGRESS') return { transitioned: false }
|
|
|
|
|
|
|
|
|
|
// Check all assignments for this project in this round
|
|
|
|
|
const assignments = await prisma.assignment.findMany({
|
|
|
|
|
where: { projectId, roundId },
|
|
|
|
|
select: { isCompleted: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (assignments.length === 0) return { transitioned: false }
|
|
|
|
|
|
|
|
|
|
const allCompleted = assignments.every((a: { isCompleted: boolean }) => a.isCompleted)
|
|
|
|
|
if (!allCompleted) return { transitioned: false }
|
|
|
|
|
|
|
|
|
|
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 ${assignments.length} evaluations done)`)
|
|
|
|
|
return { transitioned: true }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { transitioned: false }
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[RoundEngine] checkEvaluationCompletionAndTransition failed (non-fatal):', error)
|
|
|
|
|
return { transitioned: false }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
// ─── Internals ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
export function isTerminalState(state: ProjectRoundStateValue): boolean {
|
|
|
|
|
return ['PASSED', 'REJECTED', 'WITHDRAWN'].includes(state)
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
}
|