Files
MOPC-Portal/src/server/services/round-finalization.ts
Matt 8f2f054c57
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m10s
fix: remove invalid 'reason' field from ProjectStatusHistory.create
The field doesn't exist on the model, causing finalization to crash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:55:08 +01:00

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,
}
}