All checks were successful
Build and Push Docker Image / build (push) Successful in 9m10s
The field doesn't exist on the model, causing finalization to crash. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
850 lines
29 KiB
TypeScript
850 lines
29 KiB
TypeScript
/**
|
|
* Round Finalization Service
|
|
*
|
|
* Handles the post-close lifecycle of a round:
|
|
* - processRoundClose: auto-sets project states after a round closes
|
|
* - getFinalizationSummary: aggregates data for the finalization review UI
|
|
* - confirmFinalization: single transaction to apply outcomes, advance projects, send emails
|
|
*/
|
|
|
|
import type { PrismaClient, ProjectRoundStateValue, RoundType, Prisma } from '@prisma/client'
|
|
import { transitionProject, isTerminalState } from './round-engine'
|
|
import { logAudit } from '@/server/utils/audit'
|
|
import {
|
|
sendStyledNotificationEmail,
|
|
getRejectionNotificationTemplate,
|
|
} from '@/lib/email'
|
|
import { createBulkNotifications } from '../services/in-app-notification'
|
|
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
|
|
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
export type FinalizationSummary = {
|
|
roundId: string
|
|
roundName: string
|
|
roundType: RoundType
|
|
isGracePeriodActive: boolean
|
|
gracePeriodEndsAt: Date | null
|
|
isFinalized: boolean
|
|
finalizedAt: Date | null
|
|
stats: {
|
|
pending: number
|
|
inProgress: number
|
|
completed: number
|
|
passed: number
|
|
rejected: number
|
|
withdrawn: number
|
|
}
|
|
projects: Array<{
|
|
id: string
|
|
title: string
|
|
teamName: string | null
|
|
category: string | null
|
|
country: string | null
|
|
currentState: ProjectRoundStateValue
|
|
proposedOutcome: ProjectRoundStateValue | null
|
|
evaluationScore?: number | null
|
|
rankPosition?: number | null
|
|
}>
|
|
categoryTargets: {
|
|
startupTarget: number | null
|
|
conceptTarget: number | null
|
|
startupProposed: number
|
|
conceptProposed: number
|
|
}
|
|
nextRound: { id: string; name: string } | null
|
|
accountStats: {
|
|
needsInvite: number
|
|
hasAccount: number
|
|
}
|
|
}
|
|
|
|
export type ConfirmFinalizationResult = {
|
|
advanced: number
|
|
rejected: number
|
|
emailsSent: number
|
|
emailsFailed: number
|
|
}
|
|
|
|
// ─── processRoundClose ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Process project states after a round closes.
|
|
* Auto-transitions projects to COMPLETED/REJECTED and sets proposedOutcome defaults.
|
|
* Called immediately on close (if no grace period) or after grace period expires.
|
|
*/
|
|
export async function processRoundClose(
|
|
roundId: string,
|
|
actorId: string,
|
|
prisma: PrismaClient | any,
|
|
): Promise<{ processed: number }> {
|
|
const round = await prisma.round.findUnique({
|
|
where: { id: roundId },
|
|
include: {
|
|
competition: {
|
|
select: {
|
|
rounds: {
|
|
select: { id: true, name: true, sortOrder: true },
|
|
orderBy: { sortOrder: 'asc' as const },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if (!round) throw new Error(`Round ${roundId} not found`)
|
|
|
|
const projectStates = await prisma.projectRoundState.findMany({
|
|
where: { roundId },
|
|
include: {
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
competitionCategory: true,
|
|
files: { where: { roundId }, select: { id: true, requirementId: true, submissionFileRequirementId: true } },
|
|
assignments: { where: { roundId }, select: { isCompleted: true } },
|
|
filteringResults: { where: { roundId }, select: { outcome: true, finalOutcome: true } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
let processed = 0
|
|
|
|
// Pre-compute pass set for EVALUATION rounds using ranking scores + config.
|
|
// Respects admin drag-reorder overrides stored in reordersJson.
|
|
let evaluationPassSet: Set<string> | null = null
|
|
if ((round.roundType as RoundType) === 'EVALUATION') {
|
|
evaluationPassSet = new Set<string>()
|
|
const snapshot = await prisma.rankingSnapshot.findFirst({
|
|
where: { roundId },
|
|
orderBy: { createdAt: 'desc' as const },
|
|
select: { startupRankingJson: true, conceptRankingJson: true, reordersJson: true },
|
|
})
|
|
if (snapshot) {
|
|
const config = (round.configJson as Record<string, unknown>) ?? {}
|
|
const advanceMode = (config.advanceMode as string) || 'count'
|
|
const advanceScoreThreshold = (config.advanceScoreThreshold as number) ?? 6
|
|
const startupAdvanceCount = (config.startupAdvanceCount as number) ?? 0
|
|
const conceptAdvanceCount = (config.conceptAdvanceCount as number) ?? 0
|
|
|
|
type RankEntry = { projectId: string; avgGlobalScore: number | null; rank: number }
|
|
const startupRanked = (snapshot.startupRankingJson ?? []) as RankEntry[]
|
|
const conceptRanked = (snapshot.conceptRankingJson ?? []) as RankEntry[]
|
|
|
|
// Apply admin drag-reorder overrides (reordersJson is append-only, latest per category wins)
|
|
type ReorderEvent = { category: 'STARTUP' | 'BUSINESS_CONCEPT'; orderedProjectIds: string[] }
|
|
const reorders = (snapshot.reordersJson as ReorderEvent[] | null) ?? []
|
|
const latestStartupReorder = [...reorders].reverse().find((r) => r.category === 'STARTUP')
|
|
const latestConceptReorder = [...reorders].reverse().find((r) => r.category === 'BUSINESS_CONCEPT')
|
|
|
|
// Build effective order: if admin reordered, use that; otherwise use computed rank order
|
|
const effectiveStartup = latestStartupReorder
|
|
? latestStartupReorder.orderedProjectIds
|
|
: [...startupRanked].sort((a, b) => a.rank - b.rank).map((r) => r.projectId)
|
|
const effectiveConcept = latestConceptReorder
|
|
? latestConceptReorder.orderedProjectIds
|
|
: [...conceptRanked].sort((a, b) => a.rank - b.rank).map((r) => r.projectId)
|
|
|
|
// Build score lookup for threshold mode
|
|
const scoreMap = new Map<string, number>()
|
|
for (const r of [...startupRanked, ...conceptRanked]) {
|
|
if (r.avgGlobalScore != null) scoreMap.set(r.projectId, r.avgGlobalScore)
|
|
}
|
|
|
|
if (advanceMode === 'threshold') {
|
|
for (const id of [...effectiveStartup, ...effectiveConcept]) {
|
|
const score = scoreMap.get(id)
|
|
if (score != null && score >= advanceScoreThreshold) {
|
|
evaluationPassSet.add(id)
|
|
}
|
|
}
|
|
} else {
|
|
// 'count' mode — top N per category using effective (possibly reordered) order
|
|
for (let i = 0; i < Math.min(startupAdvanceCount, effectiveStartup.length); i++) {
|
|
evaluationPassSet.add(effectiveStartup[i])
|
|
}
|
|
for (let i = 0; i < Math.min(conceptAdvanceCount, effectiveConcept.length); i++) {
|
|
evaluationPassSet.add(effectiveConcept[i])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const prs of projectStates) {
|
|
// Skip already-terminal states
|
|
if (isTerminalState(prs.state)) {
|
|
// Set proposed outcome to match current state for display
|
|
if (!prs.proposedOutcome) {
|
|
await prisma.projectRoundState.update({
|
|
where: { id: prs.id },
|
|
data: { proposedOutcome: prs.state },
|
|
})
|
|
}
|
|
processed++
|
|
continue
|
|
}
|
|
|
|
let targetState: ProjectRoundStateValue = prs.state
|
|
let proposedOutcome: ProjectRoundStateValue = 'PASSED'
|
|
|
|
switch (round.roundType as RoundType) {
|
|
case 'INTAKE':
|
|
case 'SUBMISSION': {
|
|
// Projects with activity → COMPLETED, purely PENDING → REJECTED
|
|
if (prs.state === 'PENDING') {
|
|
targetState = 'REJECTED' as ProjectRoundStateValue
|
|
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
|
|
} else if (prs.state === 'IN_PROGRESS' || prs.state === 'COMPLETED') {
|
|
if (prs.state === 'IN_PROGRESS') targetState = 'COMPLETED' as ProjectRoundStateValue
|
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'EVALUATION': {
|
|
// Use ranking scores to determine pass/reject
|
|
const hasEvals = prs.project.assignments.some((a: { isCompleted: boolean }) => a.isCompleted)
|
|
const shouldPass = evaluationPassSet?.has(prs.projectId) ?? false
|
|
if (prs.state === 'IN_PROGRESS' || (prs.state === 'PENDING' && hasEvals)) {
|
|
targetState = 'COMPLETED' as ProjectRoundStateValue
|
|
proposedOutcome = (shouldPass ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
|
|
} else if (prs.state === 'PENDING') {
|
|
targetState = 'REJECTED' as ProjectRoundStateValue
|
|
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
|
|
} else if (prs.state === 'COMPLETED') {
|
|
proposedOutcome = (shouldPass ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'FILTERING': {
|
|
// Use FilteringResult to determine outcome for each project
|
|
const fr = prs.project.filteringResults?.[0] as { outcome: string; finalOutcome: string | null } | undefined
|
|
const effectiveOutcome = fr?.finalOutcome || fr?.outcome
|
|
const filterPassed = effectiveOutcome !== 'FILTERED_OUT'
|
|
|
|
if (prs.state === 'COMPLETED') {
|
|
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
|
|
} else if (prs.state === 'IN_PROGRESS') {
|
|
targetState = 'COMPLETED' as ProjectRoundStateValue
|
|
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
|
|
} else if (prs.state === 'PENDING') {
|
|
// PENDING projects in filtering: check FilteringResult
|
|
if (fr) {
|
|
targetState = 'COMPLETED' as ProjectRoundStateValue
|
|
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
|
|
} else {
|
|
// No filtering result at all → reject
|
|
targetState = 'REJECTED' as ProjectRoundStateValue
|
|
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'MENTORING': {
|
|
// Projects already PASSED (pass-through) stay PASSED
|
|
if (prs.state === 'PASSED') {
|
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
|
} else if (prs.state === 'IN_PROGRESS') {
|
|
targetState = 'COMPLETED' as ProjectRoundStateValue
|
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
|
} else if (prs.state === 'COMPLETED') {
|
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
|
} else if (prs.state === 'PENDING') {
|
|
// Pending = never requested mentoring, pass through
|
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
|
targetState = 'COMPLETED' as ProjectRoundStateValue
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'LIVE_FINAL': {
|
|
// All presented projects → COMPLETED
|
|
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
|
|
targetState = 'COMPLETED' as ProjectRoundStateValue
|
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
|
} else if (prs.state === 'COMPLETED') {
|
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'DELIBERATION': {
|
|
// All voted projects → COMPLETED
|
|
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
|
|
targetState = 'COMPLETED' as ProjectRoundStateValue
|
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
|
} else if (prs.state === 'COMPLETED') {
|
|
proposedOutcome = 'PASSED' as ProjectRoundStateValue
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// Transition project if needed (admin override for non-standard paths)
|
|
if (targetState !== prs.state && !isTerminalState(prs.state)) {
|
|
// Need to handle multi-step transitions
|
|
if (prs.state === 'PENDING' && targetState === 'COMPLETED') {
|
|
await transitionProject(prs.projectId, roundId, 'IN_PROGRESS' as ProjectRoundStateValue, actorId, prisma, { adminOverride: true })
|
|
await transitionProject(prs.projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma, { adminOverride: true })
|
|
} else if (prs.state === 'PENDING' && targetState === 'REJECTED') {
|
|
await transitionProject(prs.projectId, roundId, targetState, actorId, prisma, { adminOverride: true })
|
|
} else {
|
|
await transitionProject(prs.projectId, roundId, targetState, actorId, prisma, { adminOverride: true })
|
|
}
|
|
}
|
|
|
|
// Set proposed outcome
|
|
await prisma.projectRoundState.update({
|
|
where: { id: prs.id },
|
|
data: { proposedOutcome },
|
|
})
|
|
|
|
processed++
|
|
}
|
|
|
|
return { processed }
|
|
}
|
|
|
|
// ─── getFinalizationSummary ─────────────────────────────────────────────────
|
|
|
|
export async function getFinalizationSummary(
|
|
roundId: string,
|
|
prisma: PrismaClient | any,
|
|
): Promise<FinalizationSummary> {
|
|
const round = await prisma.round.findUniqueOrThrow({
|
|
where: { id: roundId },
|
|
include: {
|
|
competition: {
|
|
select: {
|
|
rounds: {
|
|
select: { id: true, name: true, sortOrder: true },
|
|
orderBy: { sortOrder: 'asc' as const },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
const now = new Date()
|
|
const isGracePeriodActive = !!(round.gracePeriodEndsAt && new Date(round.gracePeriodEndsAt) > now && !round.finalizedAt)
|
|
const isFinalized = !!round.finalizedAt
|
|
|
|
// Get config for category targets
|
|
const config = (round.configJson as Record<string, unknown>) ?? {}
|
|
|
|
// Find next round
|
|
const rounds = round.competition.rounds
|
|
const currentIdx = rounds.findIndex((r: { id: string }) => r.id === roundId)
|
|
const nextRound = currentIdx >= 0 && currentIdx < rounds.length - 1
|
|
? rounds[currentIdx + 1]
|
|
: null
|
|
|
|
// Get all project states with project details
|
|
const projectStates = await prisma.projectRoundState.findMany({
|
|
where: { roundId },
|
|
include: {
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
teamName: true,
|
|
competitionCategory: true,
|
|
country: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: { createdAt: 'asc' as const },
|
|
})
|
|
|
|
// Compute stats
|
|
const stats = { pending: 0, inProgress: 0, completed: 0, passed: 0, rejected: 0, withdrawn: 0 }
|
|
for (const prs of projectStates) {
|
|
switch (prs.state) {
|
|
case 'PENDING': stats.pending++; break
|
|
case 'IN_PROGRESS': stats.inProgress++; break
|
|
case 'COMPLETED': stats.completed++; break
|
|
case 'PASSED': stats.passed++; break
|
|
case 'REJECTED': stats.rejected++; break
|
|
case 'WITHDRAWN': stats.withdrawn++; break
|
|
}
|
|
}
|
|
|
|
// Get evaluation scores if this is an evaluation round
|
|
let scoreMap = new Map<string, number>()
|
|
let rankMap = new Map<string, number>()
|
|
|
|
if (round.roundType === 'EVALUATION') {
|
|
// Get latest ranking snapshot (per-category fields)
|
|
const snapshot = await prisma.rankingSnapshot.findFirst({
|
|
where: { roundId },
|
|
orderBy: { createdAt: 'desc' as const },
|
|
select: { startupRankingJson: true, conceptRankingJson: true },
|
|
})
|
|
if (snapshot) {
|
|
type RankEntry = { projectId: string; avgGlobalScore?: number; compositeScore?: number; rank?: number }
|
|
const allRanked = [
|
|
...((snapshot.startupRankingJson ?? []) as RankEntry[]),
|
|
...((snapshot.conceptRankingJson ?? []) as RankEntry[]),
|
|
]
|
|
for (const r of allRanked) {
|
|
if (r.avgGlobalScore != null) scoreMap.set(r.projectId, r.avgGlobalScore)
|
|
else if (r.compositeScore != null) scoreMap.set(r.projectId, r.compositeScore)
|
|
if (r.rank != null) rankMap.set(r.projectId, r.rank)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build project list
|
|
const projects = projectStates.map((prs: any) => ({
|
|
id: prs.project.id,
|
|
title: prs.project.title,
|
|
teamName: prs.project.teamName,
|
|
category: prs.project.competitionCategory,
|
|
country: prs.project.country,
|
|
currentState: prs.state as ProjectRoundStateValue,
|
|
proposedOutcome: prs.proposedOutcome as ProjectRoundStateValue | null,
|
|
evaluationScore: scoreMap.get(prs.project.id) ?? null,
|
|
rankPosition: rankMap.get(prs.project.id) ?? null,
|
|
}))
|
|
|
|
// Category target progress
|
|
const startupTarget = (config.startupAdvanceCount as number | undefined) ?? null
|
|
const conceptTarget = (config.conceptAdvanceCount as number | undefined) ?? null
|
|
|
|
let startupProposed = 0
|
|
let conceptProposed = 0
|
|
for (const p of projects) {
|
|
if (p.proposedOutcome === 'PASSED') {
|
|
if (p.category === 'STARTUP') startupProposed++
|
|
else if (p.category === 'BUSINESS_CONCEPT') conceptProposed++
|
|
}
|
|
}
|
|
|
|
// Account stats: count how many advancing projects need invite vs already have accounts
|
|
let needsInvite = 0
|
|
let hasAccount = 0
|
|
const passedProjectIds = projects.filter((p: { proposedOutcome: string | null }) => p.proposedOutcome === 'PASSED').map((p: { id: string }) => p.id)
|
|
if (passedProjectIds.length > 0) {
|
|
const passedProjects = await prisma.project.findMany({
|
|
where: { id: { in: passedProjectIds } },
|
|
select: {
|
|
id: true,
|
|
submittedBy: { select: { passwordHash: true } },
|
|
teamMembers: { select: { user: { select: { passwordHash: true } } } },
|
|
},
|
|
})
|
|
for (const p of passedProjects) {
|
|
// Check team members first, then submittedBy
|
|
const users = p.teamMembers.length > 0
|
|
? p.teamMembers.map((tm: any) => tm.user)
|
|
: p.submittedBy ? [p.submittedBy] : []
|
|
const anyHasPassword = users.some((u: any) => !!u.passwordHash)
|
|
if (anyHasPassword) hasAccount++
|
|
else needsInvite++
|
|
}
|
|
}
|
|
|
|
return {
|
|
roundId,
|
|
roundName: round.name,
|
|
roundType: round.roundType,
|
|
isGracePeriodActive,
|
|
gracePeriodEndsAt: round.gracePeriodEndsAt,
|
|
isFinalized,
|
|
finalizedAt: round.finalizedAt,
|
|
stats,
|
|
projects,
|
|
categoryTargets: {
|
|
startupTarget,
|
|
conceptTarget,
|
|
startupProposed,
|
|
conceptProposed,
|
|
},
|
|
nextRound: nextRound ? { id: nextRound.id, name: nextRound.name } : null,
|
|
accountStats: { needsInvite, hasAccount },
|
|
}
|
|
}
|
|
|
|
// ─── confirmFinalization ────────────────────────────────────────────────────
|
|
|
|
export async function confirmFinalization(
|
|
roundId: string,
|
|
options: {
|
|
targetRoundId?: string
|
|
advancementMessage?: string
|
|
rejectionMessage?: string
|
|
},
|
|
actorId: string,
|
|
prisma: PrismaClient | any,
|
|
): Promise<ConfirmFinalizationResult> {
|
|
// Validate: round is CLOSED, not already finalized, grace period expired
|
|
const round = await prisma.round.findUniqueOrThrow({
|
|
where: { id: roundId },
|
|
include: {
|
|
competition: {
|
|
select: {
|
|
id: true,
|
|
rounds: {
|
|
select: { id: true, name: true, sortOrder: true },
|
|
orderBy: { sortOrder: 'asc' as const },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if (round.status !== 'ROUND_CLOSED') {
|
|
throw new Error(`Round must be ROUND_CLOSED to finalize, got ${round.status}`)
|
|
}
|
|
|
|
if (round.finalizedAt) {
|
|
throw new Error('Round is already finalized')
|
|
}
|
|
|
|
const now = new Date()
|
|
if (round.gracePeriodEndsAt && new Date(round.gracePeriodEndsAt) > now) {
|
|
throw new Error('Cannot finalize: grace period is still active')
|
|
}
|
|
|
|
// Determine target round
|
|
const rounds = round.competition.rounds
|
|
const currentIdx = rounds.findIndex((r: { id: string }) => r.id === roundId)
|
|
const targetRoundId = options.targetRoundId
|
|
?? (currentIdx >= 0 && currentIdx < rounds.length - 1
|
|
? rounds[currentIdx + 1].id
|
|
: undefined)
|
|
|
|
const targetRoundName = targetRoundId
|
|
? rounds.find((r: { id: string }) => r.id === targetRoundId)?.name ?? 'Next Round'
|
|
: 'Next Round'
|
|
|
|
// Execute finalization in a transaction
|
|
const result = await prisma.$transaction(async (tx: any) => {
|
|
const projectStates = await tx.projectRoundState.findMany({
|
|
where: { roundId, proposedOutcome: { not: null } },
|
|
include: {
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
status: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
let advanced = 0
|
|
let rejected = 0
|
|
|
|
for (const prs of projectStates) {
|
|
const proposed = prs.proposedOutcome as ProjectRoundStateValue
|
|
|
|
// Skip if already in the proposed state
|
|
if (prs.state === proposed) {
|
|
if (proposed === 'PASSED') advanced++
|
|
else if (proposed === 'REJECTED') rejected++
|
|
continue
|
|
}
|
|
|
|
// Transition to proposed outcome
|
|
if (proposed === 'PASSED' || proposed === 'REJECTED') {
|
|
// Ensure we're in COMPLETED before transitioning to PASSED/REJECTED
|
|
if (prs.state !== 'COMPLETED' && prs.state !== 'PASSED' && prs.state !== 'REJECTED') {
|
|
// Force through intermediate states
|
|
if (prs.state === 'PENDING') {
|
|
await tx.projectRoundState.update({
|
|
where: { id: prs.id },
|
|
data: { state: 'IN_PROGRESS' },
|
|
})
|
|
}
|
|
if (prs.state === 'PENDING' || prs.state === 'IN_PROGRESS') {
|
|
await tx.projectRoundState.update({
|
|
where: { id: prs.id },
|
|
data: { state: 'COMPLETED' },
|
|
})
|
|
}
|
|
}
|
|
|
|
// Now transition to final state
|
|
await tx.projectRoundState.update({
|
|
where: { id: prs.id },
|
|
data: {
|
|
state: proposed,
|
|
exitedAt: now,
|
|
},
|
|
})
|
|
|
|
if (proposed === 'PASSED') {
|
|
advanced++
|
|
|
|
// Create ProjectRoundState in target round (if exists)
|
|
if (targetRoundId) {
|
|
await tx.projectRoundState.upsert({
|
|
where: {
|
|
projectId_roundId: {
|
|
projectId: prs.projectId,
|
|
roundId: targetRoundId,
|
|
},
|
|
},
|
|
create: {
|
|
projectId: prs.projectId,
|
|
roundId: targetRoundId,
|
|
state: 'PENDING',
|
|
enteredAt: now,
|
|
},
|
|
update: {}, // skip if already exists
|
|
})
|
|
}
|
|
|
|
// Update Project.status to ASSIGNED
|
|
await tx.project.update({
|
|
where: { id: prs.projectId },
|
|
data: { status: 'ASSIGNED' },
|
|
})
|
|
|
|
// Create ProjectStatusHistory
|
|
await tx.projectStatusHistory.create({
|
|
data: {
|
|
projectId: prs.projectId,
|
|
status: 'ASSIGNED',
|
|
changedBy: actorId,
|
|
},
|
|
})
|
|
} else {
|
|
rejected++
|
|
}
|
|
|
|
// Audit log per project
|
|
await tx.decisionAuditLog.create({
|
|
data: {
|
|
eventType: 'finalization.project_outcome',
|
|
entityType: 'ProjectRoundState',
|
|
entityId: prs.id,
|
|
actorId,
|
|
detailsJson: {
|
|
projectId: prs.projectId,
|
|
roundId,
|
|
previousState: prs.state,
|
|
outcome: proposed,
|
|
targetRoundId: proposed === 'PASSED' ? targetRoundId : null,
|
|
} as Prisma.InputJsonValue,
|
|
snapshotJson: {
|
|
timestamp: now.toISOString(),
|
|
emittedBy: 'round-finalization',
|
|
},
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// Mark round as finalized
|
|
await tx.round.update({
|
|
where: { id: roundId },
|
|
data: {
|
|
finalizedAt: now,
|
|
finalizedBy: actorId,
|
|
},
|
|
})
|
|
|
|
// Finalization audit
|
|
await tx.decisionAuditLog.create({
|
|
data: {
|
|
eventType: 'round.finalized',
|
|
entityType: 'Round',
|
|
entityId: roundId,
|
|
actorId,
|
|
detailsJson: {
|
|
roundName: round.name,
|
|
advanced,
|
|
rejected,
|
|
targetRoundId,
|
|
hasCustomAdvancementMessage: !!options.advancementMessage,
|
|
hasCustomRejectionMessage: !!options.rejectionMessage,
|
|
} as Prisma.InputJsonValue,
|
|
snapshotJson: {
|
|
timestamp: now.toISOString(),
|
|
emittedBy: 'round-finalization',
|
|
},
|
|
},
|
|
})
|
|
|
|
return { advanced, rejected }
|
|
})
|
|
|
|
// Send emails outside transaction (non-fatal)
|
|
let emailsSent = 0
|
|
let emailsFailed = 0
|
|
|
|
try {
|
|
// Get all projects that were finalized
|
|
const finalizedStates = await prisma.projectRoundState.findMany({
|
|
where: { roundId, state: { in: ['PASSED', 'REJECTED'] } },
|
|
include: {
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
submittedByEmail: true,
|
|
submittedByUserId: true,
|
|
submittedBy: { select: { id: true, email: true, name: true, passwordHash: true } },
|
|
teamMembers: {
|
|
select: { user: { select: { id: true, email: true, name: true, passwordHash: true } } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Pre-generate invite tokens for passwordless users on advancing projects
|
|
const inviteTokenMap = new Map<string, string>() // userId → token
|
|
const expiryMs = await getInviteExpiryMs(prisma)
|
|
|
|
for (const prs of finalizedStates) {
|
|
if (prs.state !== 'PASSED') continue
|
|
const users = prs.project.teamMembers.length > 0
|
|
? prs.project.teamMembers.map((tm: any) => tm.user)
|
|
: prs.project.submittedBy ? [prs.project.submittedBy] : []
|
|
for (const user of users) {
|
|
if (user && !user.passwordHash && !inviteTokenMap.has(user.id)) {
|
|
const token = generateInviteToken()
|
|
inviteTokenMap.set(user.id, token)
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
inviteToken: token,
|
|
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
const advancedUserIds = new Set<string>()
|
|
const rejectedUserIds = new Set<string>()
|
|
|
|
for (const prs of finalizedStates) {
|
|
type Recipient = { email: string; name: string | null; userId: string | null }
|
|
const recipients: Recipient[] = []
|
|
for (const tm of prs.project.teamMembers) {
|
|
if (tm.user.email) {
|
|
recipients.push({ email: tm.user.email, name: tm.user.name, userId: tm.user.id })
|
|
if (prs.state === 'PASSED') advancedUserIds.add(tm.user.id)
|
|
else rejectedUserIds.add(tm.user.id)
|
|
}
|
|
}
|
|
if (recipients.length === 0 && prs.project.submittedBy?.email) {
|
|
recipients.push({
|
|
email: prs.project.submittedBy.email,
|
|
name: prs.project.submittedBy.name,
|
|
userId: prs.project.submittedBy.id,
|
|
})
|
|
if (prs.state === 'PASSED') advancedUserIds.add(prs.project.submittedBy.id)
|
|
else rejectedUserIds.add(prs.project.submittedBy.id)
|
|
} else if (recipients.length === 0 && prs.project.submittedByEmail) {
|
|
recipients.push({ email: prs.project.submittedByEmail, name: null, userId: null })
|
|
}
|
|
|
|
for (const recipient of recipients) {
|
|
try {
|
|
if (prs.state === 'PASSED') {
|
|
// Build account creation URL for passwordless users
|
|
const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined
|
|
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
|
|
|
|
await sendStyledNotificationEmail(
|
|
recipient.email,
|
|
recipient.name || '',
|
|
'ADVANCEMENT_NOTIFICATION',
|
|
{
|
|
title: 'Your project has advanced!',
|
|
message: '',
|
|
linkUrl: accountUrl || '/applicant',
|
|
metadata: {
|
|
projectName: prs.project.title,
|
|
fromRoundName: round.name,
|
|
toRoundName: targetRoundName,
|
|
customMessage: options.advancementMessage || undefined,
|
|
accountUrl,
|
|
},
|
|
},
|
|
)
|
|
} else {
|
|
await sendStyledNotificationEmail(
|
|
recipient.email,
|
|
recipient.name || '',
|
|
'REJECTION_NOTIFICATION',
|
|
{
|
|
title: `Update on your application: "${prs.project.title}"`,
|
|
message: '',
|
|
metadata: {
|
|
projectName: prs.project.title,
|
|
roundName: round.name,
|
|
customMessage: options.rejectionMessage || undefined,
|
|
},
|
|
},
|
|
)
|
|
}
|
|
emailsSent++
|
|
} catch (err) {
|
|
console.error(`[Finalization] Email failed for ${recipient.email}:`, err)
|
|
emailsFailed++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create in-app notifications
|
|
if (advancedUserIds.size > 0) {
|
|
void createBulkNotifications({
|
|
userIds: [...advancedUserIds],
|
|
type: 'project_advanced',
|
|
title: 'Your project has advanced!',
|
|
message: `Your project has advanced from "${round.name}" to "${targetRoundName}".`,
|
|
linkUrl: '/applicant',
|
|
linkLabel: 'View Dashboard',
|
|
icon: 'Trophy',
|
|
priority: 'high',
|
|
})
|
|
}
|
|
|
|
if (rejectedUserIds.size > 0) {
|
|
void createBulkNotifications({
|
|
userIds: [...rejectedUserIds],
|
|
type: 'project_rejected',
|
|
title: 'Competition Update',
|
|
message: `Your project did not advance past "${round.name}".`,
|
|
linkUrl: '/applicant',
|
|
linkLabel: 'View Dashboard',
|
|
icon: 'Info',
|
|
priority: 'normal',
|
|
})
|
|
}
|
|
} catch (emailError) {
|
|
console.error('[Finalization] Email batch failed (non-fatal):', emailError)
|
|
}
|
|
|
|
// External audit log
|
|
await logAudit({
|
|
userId: actorId,
|
|
action: 'ROUND_FINALIZED',
|
|
entityType: 'Round',
|
|
entityId: roundId,
|
|
detailsJson: {
|
|
roundName: round.name,
|
|
advanced: result.advanced,
|
|
rejected: result.rejected,
|
|
emailsSent,
|
|
emailsFailed,
|
|
},
|
|
})
|
|
|
|
return {
|
|
advanced: result.advanced,
|
|
rejected: result.rejected,
|
|
emailsSent,
|
|
emailsFailed,
|
|
}
|
|
}
|