2026-04-28 17:52:22 +02:00
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { CompetitionCategory } from '@prisma/client'
2026-04-28 18:50:52 +02:00
import { router , adminProcedure , protectedProcedure , publicProcedure } from '../trpc'
2026-04-28 17:52:22 +02:00
import { logAudit } from '../utils/audit'
2026-04-28 17:58:31 +02:00
import {
createPendingConfirmation ,
promoteNextWaitlistEntry ,
} from '../services/finalist-confirmation'
2026-04-28 18:37:34 +02:00
import {
createNotification ,
2026-06-04 16:11:17 +02:00
notifyAdmins ,
2026-04-28 18:37:34 +02:00
NotificationTypes ,
} from '../services/in-app-notification'
2026-04-28 17:55:09 +02:00
import { sendFinalistConfirmationEmail } from '@/lib/email'
2026-04-28 17:58:31 +02:00
import { verifyFinalistToken } from '@/lib/finalist-token'
2026-04-29 02:28:51 +02:00
import { ensureLunchPickForAttendingMember } from '../services/lunch-pick-sync'
2026-06-04 15:20:51 +02:00
import {
resetOrCreatePendingConfirmation ,
confirmAttendanceInTx ,
} from '../services/finalist-enrollment'
2026-06-09 15:36:59 +02:00
import { sendManualFinalDocReminders , listFinalistDocumentsForReview , userCanReviewFinals } from '../services/final-documents'
2026-04-28 17:52:22 +02:00
export const finalistRouter = router ( {
2026-04-28 18:07:55 +02:00
/** List all per-category finalist slot quotas for a program. */
listQuotas : adminProcedure
. input ( z . object ( { programId : z.string ( ) } ) )
. query ( async ( { ctx , input } ) = > {
return ctx . prisma . finalistSlotQuota . findMany ( {
where : { programId : input.programId } ,
orderBy : { category : 'asc' } ,
} )
} ) ,
/ * *
* Aggregate counts of confirmations per category for a program . Used by the
* admin slot card to show "X confirmed / Y pending" alongside the quota
* editor .
* /
listCategoryCounts : adminProcedure
. input ( z . object ( { programId : z.string ( ) } ) )
. query ( async ( { ctx , input } ) = > {
const grouped = await ctx . prisma . finalistConfirmation . groupBy ( {
by : [ 'category' , 'status' ] ,
where : { project : { programId : input.programId } } ,
_count : { _all : true } ,
} )
const byCategory = new Map < string , { confirmed : number ; pending : number } > ( )
for ( const g of grouped ) {
const slot = byCategory . get ( g . category ) ? ? { confirmed : 0 , pending : 0 }
if ( g . status === 'CONFIRMED' ) slot . confirmed = g . _count . _all
if ( g . status === 'PENDING' ) slot . pending = g . _count . _all
byCategory . set ( g . category , slot )
}
return Array . from ( byCategory . entries ( ) ) . map ( ( [ category , counts ] ) = > ( {
category : category as CompetitionCategory ,
confirmed : counts.confirmed ,
pending : counts.pending ,
} ) )
} ) ,
/** List the per-category waitlist for a program (rank-ordered). */
listWaitlist : adminProcedure
. input ( z . object ( { programId : z.string ( ) } ) )
. query ( async ( { ctx , input } ) = > {
return ctx . prisma . waitlistEntry . findMany ( {
where : { programId : input.programId } ,
orderBy : [ { category : 'asc' } , { rank : 'asc' } ] ,
include : {
project : { select : { id : true , title : true , country : true } } ,
} ,
} )
} ) ,
2026-04-28 17:52:22 +02:00
/ * *
* Set the finalist slot quota for a category in a program . Mutable mid - flight ,
* but blocked when reducing below the count of already - CONFIRMED finalists in
* that category — admin must un - confirm a team first .
* /
setQuota : adminProcedure
. input (
z . object ( {
programId : z.string ( ) ,
category : z.nativeEnum ( CompetitionCategory ) ,
quota : z.number ( ) . int ( ) . min ( 0 ) . max ( 100 ) ,
} ) ,
)
. mutation ( async ( { ctx , input } ) = > {
const confirmedCount = await ctx . prisma . finalistConfirmation . count ( {
where : {
project : { programId : input.programId } ,
category : input.category ,
status : 'CONFIRMED' ,
} ,
} )
if ( input . quota < confirmedCount ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` Cannot reduce ${ input . category } quota to ${ input . quota } — ${ confirmedCount } teams have already confirmed. Un-confirm one team first, then retry. ` ,
} )
}
const quota = await ctx . prisma . finalistSlotQuota . upsert ( {
where : {
programId_category : {
programId : input.programId ,
category : input.category ,
} ,
} ,
create : {
programId : input.programId ,
category : input.category ,
quota : input.quota ,
} ,
update : { quota : input.quota } ,
} )
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'FINALIST_QUOTA_SET' ,
entityType : 'FinalistSlotQuota' ,
entityId : quota.id ,
detailsJson : {
programId : input.programId ,
category : input.category ,
quota : input.quota ,
previousConfirmedCount : confirmedCount ,
} ,
} )
return quota
} ) ,
2026-04-28 17:55:09 +02:00
/ * *
* Send finalist confirmation emails to a set of selected projects in a
* category . Reads the confirmation window from the round ' s configJson .
* Validates category match + quota before creating any rows .
* /
selectFinalists : adminProcedure
. input (
z . object ( {
programId : z.string ( ) ,
category : z.nativeEnum ( CompetitionCategory ) ,
projectIds : z.array ( z . string ( ) ) . min ( 1 ) ,
roundId : z.string ( ) ,
} ) ,
)
. mutation ( async ( { ctx , input } ) = > {
const round = await ctx . prisma . round . findUniqueOrThrow ( {
where : { id : input.roundId } ,
select : { id : true , configJson : true } ,
} )
const cfg = ( round . configJson ? ? { } ) as { confirmationWindowHours? : number }
const windowHours = cfg . confirmationWindowHours ? ? 24
const projects = await ctx . prisma . project . findMany ( {
where : { id : { in : input . projectIds } , programId : input.programId } ,
select : {
id : true ,
title : true ,
competitionCategory : true ,
teamMembers : {
where : { role : 'LEAD' } ,
take : 1 ,
select : { user : { select : { email : true , name : true } } } ,
} ,
} ,
} )
if ( projects . length !== input . projectIds . length ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : 'One or more project IDs not found in this program' ,
} )
}
const mismatched = projects . filter ( ( p ) = > p . competitionCategory !== input . category )
if ( mismatched . length > 0 ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` Category mismatch: ${ mismatched
. map ( ( p ) = > p . title )
. join ( ', ' ) } are not in $ { input . category } ` ,
} )
}
const quota = await ctx . prisma . finalistSlotQuota . findUnique ( {
where : {
programId_category : {
programId : input.programId ,
category : input.category ,
} ,
} ,
} )
if ( quota && input . projectIds . length > quota . quota ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` Selection exceeds quota: ${ input . projectIds . length } selected, ${ quota . quota } available in ${ input . category } ` ,
} )
}
const baseUrl = ( process . env . NEXTAUTH_URL ? ? 'http://localhost:3000' ) . replace ( /\/$/ , '' )
let created = 0
for ( const project of projects ) {
const { token , deadline } = await createPendingConfirmation ( ctx . prisma , {
projectId : project.id ,
category : input.category ,
windowHours ,
} )
created ++
// Send notification email — never throw inside the loop; log failures.
const lead = project . teamMembers [ 0 ] ? . user
if ( lead ? . email ) {
const confirmUrl = ` ${ baseUrl } /finalist/confirm/ ${ token } `
try {
await sendFinalistConfirmationEmail (
lead . email ,
lead . name ? ? null ,
project . title ,
deadline ,
confirmUrl ,
)
} catch ( err ) {
console . error (
` [finalist.selectFinalists] failed to send email to ${ lead . email } for project ${ project . id } : ` ,
err ,
)
}
}
}
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'FINALIST_SELECT' ,
entityType : 'Program' ,
entityId : input.programId ,
detailsJson : {
category : input.category ,
projectIds : input.projectIds ,
windowHours ,
roundId : input.roundId ,
} ,
} )
return { created }
} ) ,
2026-04-28 17:58:31 +02:00
/ * *
* Look up a confirmation by its public token . Surface the data needed to
* render the confirmation page : project , team members , current state .
* /
getByToken : publicProcedure
. input ( z . object ( { token : z.string ( ) } ) )
. query ( async ( { ctx , input } ) = > {
const payload = verifyFinalistToken ( input . token ) // throws on bad sig / expired
const confirmation = await ctx . prisma . finalistConfirmation . findUnique ( {
where : { id : payload.confirmationId } ,
include : {
project : {
select : {
id : true ,
title : true ,
programId : true ,
competitionCategory : true ,
program : { select : { defaultAttendeeCap : true , name : true } } ,
teamMembers : {
select : {
userId : true ,
role : true ,
user : { select : { id : true , name : true , email : true } } ,
} ,
} ,
} ,
} ,
attendingMembers : { select : { userId : true , needsVisa : true } } ,
} ,
} )
if ( ! confirmation ) throw new TRPCError ( { code : 'NOT_FOUND' } )
if ( confirmation . token !== input . token ) {
throw new TRPCError ( { code : 'UNAUTHORIZED' , message : 'Token mismatch' } )
}
return confirmation
} ) ,
/ * *
* Public confirm . Validates that all selected userIds are team members of
* the project , that the count is within the program ' s defaultAttendeeCap ,
* and that the confirmation is still PENDING . Atomically writes
* status = CONFIRMED + AttendingMember rows .
* /
confirm : publicProcedure
. input (
z . object ( {
token : z.string ( ) ,
attendingUserIds : z.array ( z . string ( ) ) . min ( 1 ) ,
visaFlags : z.record ( z . string ( ) , z . boolean ( ) ) . default ( { } ) ,
} ) ,
)
. mutation ( async ( { ctx , input } ) = > {
const payload = verifyFinalistToken ( input . token )
const confirmation = await ctx . prisma . finalistConfirmation . findUnique ( {
where : { id : payload.confirmationId } ,
include : {
project : {
select : {
id : true ,
2026-06-04 16:11:17 +02:00
title : true ,
2026-04-28 17:58:31 +02:00
programId : true ,
program : { select : { defaultAttendeeCap : true } } ,
teamMembers : { select : { userId : true } } ,
} ,
} ,
} ,
} )
if ( ! confirmation ) throw new TRPCError ( { code : 'NOT_FOUND' } )
if ( confirmation . token !== input . token ) {
throw new TRPCError ( { code : 'UNAUTHORIZED' } )
}
if ( confirmation . status !== 'PENDING' ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` Confirmation is ${ confirmation . status } , not PENDING ` ,
} )
}
const cap = confirmation . project . program . defaultAttendeeCap
if ( input . attendingUserIds . length > cap ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` Selection exceeds attendee cap of ${ cap } ` ,
} )
}
const teamUserIds = new Set ( confirmation . project . teamMembers . map ( ( tm ) = > tm . userId ) )
for ( const uid of input . attendingUserIds ) {
if ( ! teamUserIds . has ( uid ) ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` User ${ uid } is not a team member of this project ` ,
} )
}
}
2026-04-28 19:31:28 +02:00
await ctx . prisma . $transaction ( async ( tx ) = > {
await tx . finalistConfirmation . update ( {
2026-04-28 17:58:31 +02:00
where : { id : confirmation.id } ,
data : { status : 'CONFIRMED' , confirmedAt : new Date ( ) } ,
2026-04-28 19:31:28 +02:00
} )
await tx . attendingMember . createMany ( {
2026-04-28 17:58:31 +02:00
data : input.attendingUserIds.map ( ( userId ) = > ( {
confirmationId : confirmation.id ,
userId ,
needsVisa : input.visaFlags [ userId ] ? ? false ,
} ) ) ,
2026-04-28 19:31:28 +02:00
} )
const visaUsers = input . attendingUserIds . filter (
( uid ) = > input . visaFlags [ uid ] === true ,
)
if ( visaUsers . length > 0 ) {
const created = await tx . attendingMember . findMany ( {
where : { confirmationId : confirmation.id , userId : { in : visaUsers } } ,
select : { id : true } ,
} )
await tx . visaApplication . createMany ( {
data : created.map ( ( m ) = > ( { attendingMemberId : m.id , status : 'REQUESTED' } ) ) ,
} )
}
2026-04-29 02:28:51 +02:00
const allMembers = await tx . attendingMember . findMany ( {
where : { confirmationId : confirmation.id , userId : { in : input . attendingUserIds } } ,
select : { id : true } ,
} )
for ( const m of allMembers ) {
await ensureLunchPickForAttendingMember ( tx , m . id )
}
2026-04-28 19:31:28 +02:00
} )
2026-04-28 17:58:31 +02:00
await logAudit ( {
prisma : ctx.prisma ,
action : 'FINALIST_CONFIRMED' ,
entityType : 'FinalistConfirmation' ,
entityId : confirmation.id ,
detailsJson : {
projectId : confirmation.projectId ,
attendingUserIds : input.attendingUserIds ,
} ,
} )
2026-06-04 16:11:17 +02:00
// Admin alert — best-effort, never throws
try {
await notifyAdmins ( {
type : NotificationTypes . FINALIST_CONFIRMED ,
title : 'Finalist confirmed' ,
message : ` " ${ confirmation . project . title } " ( ${ confirmation . category } ) has confirmed grand-finale attendance. ` ,
linkUrl : '/admin/logistics' ,
metadata : {
projectId : confirmation.projectId ,
projectTitle : confirmation.project.title ,
category : confirmation.category ,
} ,
} )
} catch ( err ) {
console . error ( '[finalist.confirm] failed to send admin notification:' , err )
}
2026-04-28 17:58:31 +02:00
return { ok : true }
} ) ,
/ * *
* Public decline . Captures an optional reason . Triggers waitlist promotion
* for the same category . The freshly - promoted waitlist team gets its own
* fresh 24 h - ish window ( read from the round configJson ; the round id is
* resolved via the project ' s most - recent grand - finale round , since the
* decliner won ' t pass it back ) .
* /
decline : publicProcedure
. input ( z . object ( { token : z.string ( ) , reason : z.string ( ) . max ( 500 ) . optional ( ) } ) )
. mutation ( async ( { ctx , input } ) = > {
const payload = verifyFinalistToken ( input . token )
const confirmation = await ctx . prisma . finalistConfirmation . findUnique ( {
where : { id : payload.confirmationId } ,
2026-06-04 16:11:17 +02:00
include : { project : { select : { programId : true , title : true } } } ,
2026-04-28 17:58:31 +02:00
} )
if ( ! confirmation ) throw new TRPCError ( { code : 'NOT_FOUND' } )
if ( confirmation . token !== input . token ) {
throw new TRPCError ( { code : 'UNAUTHORIZED' } )
}
if ( confirmation . status !== 'PENDING' ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` Confirmation is ${ confirmation . status } , not PENDING ` ,
} )
}
await ctx . prisma . finalistConfirmation . update ( {
where : { id : confirmation.id } ,
data : {
status : 'DECLINED' ,
declinedAt : new Date ( ) ,
declineReason : input.reason ? ? null ,
} ,
} )
await logAudit ( {
prisma : ctx.prisma ,
action : 'FINALIST_DECLINED' ,
entityType : 'FinalistConfirmation' ,
entityId : confirmation.id ,
detailsJson : {
projectId : confirmation.projectId ,
reason : input.reason ? ? null ,
} ,
} )
2026-06-04 16:11:17 +02:00
// Admin alert — best-effort, never throws
try {
await notifyAdmins ( {
type : NotificationTypes . FINALIST_DECLINED ,
title : 'Finalist declined' ,
message : ` " ${ confirmation . project . title } " ( ${ confirmation . category } ) has declined grand-finale attendance. ` ,
linkUrl : '/admin/logistics' ,
metadata : {
projectId : confirmation.projectId ,
projectTitle : confirmation.project.title ,
category : confirmation.category ,
} ,
} )
} catch ( err ) {
console . error ( '[finalist.decline] failed to send admin notification:' , err )
}
2026-04-28 17:58:31 +02:00
// Promote next waitlist entry in same category. windowHours pulled from
// the live grand-finale round in the program (LIVE_FINAL roundType).
const round = await ctx . prisma . round . findFirst ( {
where : {
competition : { programId : confirmation.project.programId } ,
roundType : 'LIVE_FINAL' ,
} ,
orderBy : { sortOrder : 'desc' } ,
select : { configJson : true } ,
} )
const cfg = ( round ? . configJson ? ? { } ) as { confirmationWindowHours? : number }
const windowHours = cfg . confirmationWindowHours ? ? 24
await promoteNextWaitlistEntry ( ctx . prisma , {
programId : confirmation.project.programId ,
category : confirmation.category ,
windowHours ,
} )
return { ok : true }
} ) ,
2026-04-28 18:00:47 +02:00
2026-04-28 19:03:01 +02:00
/ * *
* Admin override : mark a PENDING finalist confirmation as CONFIRMED on
* behalf of the team . Used when teams reply by email instead of clicking
* the magic link . Same validation as the public ` confirm ` ( cap , team
* membership ) but bypasses token verification .
* /
adminConfirm : adminProcedure
. input (
z . object ( {
confirmationId : z.string ( ) ,
attendingUserIds : z.array ( z . string ( ) ) . min ( 1 ) ,
visaFlags : z.record ( z . string ( ) , z . boolean ( ) ) . default ( { } ) ,
} ) ,
)
. mutation ( async ( { ctx , input } ) = > {
const confirmation = await ctx . prisma . finalistConfirmation . findUniqueOrThrow ( {
where : { id : input.confirmationId } ,
include : {
project : {
select : {
id : true ,
programId : true ,
program : { select : { defaultAttendeeCap : true } } ,
teamMembers : { select : { userId : true } } ,
} ,
} ,
} ,
} )
if ( confirmation . status !== 'PENDING' ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` Confirmation is ${ confirmation . status } , not PENDING ` ,
} )
}
const cap = confirmation . project . program . defaultAttendeeCap
if ( input . attendingUserIds . length > cap ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` Selection exceeds attendee cap of ${ cap } ` ,
} )
}
const teamUserIds = new Set ( confirmation . project . teamMembers . map ( ( tm ) = > tm . userId ) )
for ( const id of input . attendingUserIds ) {
if ( ! teamUserIds . has ( id ) ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` User ${ id } is not a team member of this project ` ,
} )
}
}
2026-04-28 19:31:28 +02:00
await ctx . prisma . $transaction ( async ( tx ) = > {
2026-06-04 15:20:51 +02:00
await confirmAttendanceInTx ( tx , {
confirmationId : confirmation.id ,
attendingUserIds : input.attendingUserIds ,
visaFlags : input.visaFlags ,
2026-04-29 02:28:51 +02:00
} )
2026-04-28 19:31:28 +02:00
} )
2026-04-28 19:03:01 +02:00
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'FINALIST_ADMIN_CONFIRM' ,
entityType : 'FinalistConfirmation' ,
entityId : confirmation.id ,
detailsJson : {
projectId : confirmation.projectId ,
attendingUserIds : input.attendingUserIds ,
visaFlags : input.visaFlags ,
} ,
} )
return { ok : true }
} ) ,
/ * *
* Admin override : mark a PENDING finalist confirmation as DECLINED on
* behalf of the team and trigger waitlist promotion . Same effect as the
* public ` decline ` but bypasses token verification .
* /
adminDecline : adminProcedure
. input ( z . object ( { confirmationId : z.string ( ) , reason : z.string ( ) . max ( 500 ) . optional ( ) } ) )
. mutation ( async ( { ctx , input } ) = > {
const confirmation = await ctx . prisma . finalistConfirmation . findUniqueOrThrow ( {
where : { id : input.confirmationId } ,
2026-06-04 16:14:26 +02:00
include : {
project : {
select : {
programId : true ,
title : true ,
teamMembers : {
where : { role : 'LEAD' } ,
take : 1 ,
select : { userId : true } ,
} ,
} ,
} ,
} ,
2026-04-28 19:03:01 +02:00
} )
if ( confirmation . status !== 'PENDING' ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` Confirmation is ${ confirmation . status } , not PENDING ` ,
} )
}
await ctx . prisma . finalistConfirmation . update ( {
where : { id : confirmation.id } ,
data : {
status : 'DECLINED' ,
declinedAt : new Date ( ) ,
declineReason : input.reason ? ? null ,
} ,
} )
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'FINALIST_ADMIN_DECLINE' ,
entityType : 'FinalistConfirmation' ,
entityId : confirmation.id ,
detailsJson : {
projectId : confirmation.projectId ,
reason : input.reason ? ? null ,
} ,
} )
2026-06-04 16:11:17 +02:00
// Admin alert — best-effort, never throws
try {
await notifyAdmins ( {
type : NotificationTypes . FINALIST_DECLINED ,
title : 'Finalist declined (admin)' ,
message : ` " ${ confirmation . project . title } " ( ${ confirmation . category } ) was declined by an admin. ` ,
linkUrl : '/admin/logistics' ,
metadata : {
projectId : confirmation.projectId ,
projectTitle : confirmation.project.title ,
category : confirmation.category ,
} ,
} )
} catch ( err ) {
console . error ( '[finalist.adminDecline] failed to send admin notification:' , err )
}
2026-04-28 19:03:01 +02:00
2026-06-04 16:14:26 +02:00
// Withdrawal notification to team lead — best-effort, never throws
try {
const lead = confirmation . project . teamMembers [ 0 ]
if ( lead ) {
const projectTitle = confirmation . project . title
const reason = input . reason
await createNotification ( {
userId : lead.userId ,
type : NotificationTypes . FINALIST_WITHDRAWN ,
title : 'Grand finale slot withdrawn' ,
message : ` Your team " ${ projectTitle } " is no longer a confirmed finalist. ${ reason ? ' Reason: ' + reason : '' } ` ,
linkUrl : '/applicant' ,
metadata : { projectTitle , reason } ,
} )
}
} catch ( err ) {
console . error ( '[finalist.adminDecline] failed to send withdrawal notification to team:' , err )
}
2026-04-28 19:03:01 +02:00
const round = await ctx . prisma . round . findFirst ( {
where : {
competition : { programId : confirmation.project.programId } ,
roundType : 'LIVE_FINAL' ,
} ,
orderBy : { sortOrder : 'desc' } ,
select : { configJson : true } ,
} )
const cfg = ( round ? . configJson ? ? { } ) as { confirmationWindowHours? : number }
const windowHours = cfg . confirmationWindowHours ? ? 24
await promoteNextWaitlistEntry ( ctx . prisma , {
programId : confirmation.project.programId ,
category : confirmation.category ,
windowHours ,
} )
return { ok : true }
} ) ,
/ * *
* Returns the team - member roster for a given confirmation so the admin
* UI can render an attendee picker . Filtered by program scope so admins
* can only inspect confirmations in programs they manage .
* /
getConfirmationDetail : adminProcedure
. input ( z . object ( { confirmationId : z.string ( ) } ) )
. query ( async ( { ctx , input } ) = > {
return ctx . prisma . finalistConfirmation . findUniqueOrThrow ( {
where : { id : input.confirmationId } ,
include : {
project : {
select : {
id : true ,
title : true ,
program : { select : { defaultAttendeeCap : true } } ,
teamMembers : {
include : {
user : { select : { id : true , name : true , email : true } } ,
} ,
orderBy : { joinedAt : 'asc' } ,
} ,
} ,
} ,
attendingMembers : { select : { userId : true , needsVisa : true } } ,
} ,
} )
} ) ,
2026-04-28 18:00:47 +02:00
/ * *
* Add a project to the waitlist at a specific rank . Existing entries at
* rank >= input . rank shift down by one to make room .
* /
addToWaitlist : adminProcedure
. input (
z . object ( {
programId : z.string ( ) ,
category : z.nativeEnum ( CompetitionCategory ) ,
projectId : z.string ( ) ,
rank : z.number ( ) . int ( ) . min ( 1 ) ,
} ) ,
)
. mutation ( async ( { ctx , input } ) = > {
const project = await ctx . prisma . project . findUniqueOrThrow ( {
where : { id : input.projectId } ,
select : { competitionCategory : true , programId : true } ,
} )
if ( project . programId !== input . programId ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : 'Project does not belong to this program' ,
} )
}
if ( project . competitionCategory !== input . category ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` Project is in ${ project . competitionCategory } , not ${ input . category } ` ,
} )
}
// Use a transaction: shift existing entries first, then insert.
const entry = await ctx . prisma . $transaction ( async ( tx ) = > {
// Shift entries at >= input.rank down by 1 in reverse rank order to
// avoid violating the unique constraint mid-update.
const toShift = await tx . waitlistEntry . findMany ( {
where : {
programId : input.programId ,
category : input.category ,
rank : { gte : input.rank } ,
} ,
orderBy : { rank : 'desc' } ,
select : { id : true , rank : true } ,
} )
for ( const e of toShift ) {
await tx . waitlistEntry . update ( {
where : { id : e.id } ,
data : { rank : e.rank + 1 } ,
} )
}
return tx . waitlistEntry . create ( {
data : {
programId : input.programId ,
category : input.category ,
projectId : input.projectId ,
rank : input.rank ,
status : 'WAITING' ,
} ,
} )
} )
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'WAITLIST_ADD' ,
entityType : 'WaitlistEntry' ,
entityId : entry.id ,
detailsJson : {
programId : input.programId ,
category : input.category ,
projectId : input.projectId ,
rank : input.rank ,
} ,
} )
return entry
} ) ,
/ * *
* Replace the rank order for a category ' s waitlist with the given list .
* orderedProjectIds [ 0 ] becomes rank 1 , etc .
* /
reorderWaitlist : adminProcedure
. input (
z . object ( {
programId : z.string ( ) ,
category : z.nativeEnum ( CompetitionCategory ) ,
orderedProjectIds : z.array ( z . string ( ) ) ,
} ) ,
)
. mutation ( async ( { ctx , input } ) = > {
await ctx . prisma . $transaction ( async ( tx ) = > {
// Move each entry to a temporary very-large rank to avoid unique
// constraint conflicts during the in-place rewrite.
const TEMP_OFFSET = 100 _000
for ( let i = 0 ; i < input . orderedProjectIds . length ; i ++ ) {
await tx . waitlistEntry . updateMany ( {
where : {
programId : input.programId ,
category : input.category ,
projectId : input.orderedProjectIds [ i ] ,
} ,
data : { rank : TEMP_OFFSET + i + 1 } ,
} )
}
// Now write the final ranks
for ( let i = 0 ; i < input . orderedProjectIds . length ; i ++ ) {
await tx . waitlistEntry . updateMany ( {
where : {
programId : input.programId ,
category : input.category ,
projectId : input.orderedProjectIds [ i ] ,
} ,
data : { rank : i + 1 } ,
} )
}
} )
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'WAITLIST_REORDER' ,
entityType : 'Program' ,
entityId : input.programId ,
detailsJson : {
category : input.category ,
orderedProjectIds : input.orderedProjectIds ,
} ,
} )
return { ok : true }
} ) ,
2026-04-28 18:37:34 +02:00
/ * *
* Admin un - confirm : flips a CONFIRMED finalist back to SUPERSEDED . Cascades
* to drop the active mentor assignment ( if any ) , notifies the mentor , and
* audit - logs the override . Used to allow a category quota decrease when
* the new quota would otherwise be below the confirmed count .
* /
unconfirm : adminProcedure
. input (
z . object ( {
confirmationId : z.string ( ) ,
reason : z.string ( ) . min ( 5 ) . max ( 500 ) ,
} ) ,
)
. mutation ( async ( { ctx , input } ) = > {
const confirmation = await ctx . prisma . finalistConfirmation . findUniqueOrThrow ( {
where : { id : input.confirmationId } ,
include : {
project : {
select : {
id : true ,
title : true ,
2026-05-22 16:37:37 +02:00
mentorAssignments : {
where : { droppedAt : null , completionStatus : { not : 'completed' } } ,
2026-04-28 18:37:34 +02:00
select : {
id : true ,
completionStatus : true ,
droppedAt : true ,
mentorId : true ,
} ,
} ,
2026-06-04 16:14:26 +02:00
teamMembers : {
where : { role : 'LEAD' } ,
take : 1 ,
select : { userId : true } ,
} ,
2026-04-28 18:37:34 +02:00
} ,
} ,
} ,
} )
if ( confirmation . status !== 'CONFIRMED' ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` Confirmation is not in CONFIRMED status (current: ${ confirmation . status } ) ` ,
} )
}
await ctx . prisma . finalistConfirmation . update ( {
where : { id : confirmation.id } ,
data : { status : 'SUPERSEDED' } ,
} )
2026-05-22 16:37:37 +02:00
// Cascade: drop ALL active mentor assignments (skip dropped/completed —
// those were filtered out by the include `where` above). With multi-mentor
// (PR8) we propagate the cascade to every active assignment.
const activeAssignments = confirmation . project . mentorAssignments
2026-04-28 18:37:34 +02:00
let cascadedMentorAssignment = false
2026-05-22 16:37:37 +02:00
for ( const ma of activeAssignments ) {
2026-04-28 18:37:34 +02:00
await ctx . prisma . mentorAssignment . update ( {
where : { id : ma.id } ,
data : {
droppedAt : new Date ( ) ,
droppedReason : ` Finalist un-confirmed: ${ input . reason } ` ,
droppedBy : 'finalist_unconfirmed' ,
} ,
} )
cascadedMentorAssignment = true
// Notify mentor — best-effort
try {
await createNotification ( {
userId : ma.mentorId ,
type : NotificationTypes . MENTEE_DROPPED ,
title : 'Mentee finalist slot withdrawn' ,
message : ` Your mentee " ${ confirmation . project . title } " is no longer a confirmed finalist. Your assignment has ended. ` ,
priority : 'high' ,
} )
} catch ( err ) {
console . error ( '[finalist.unconfirm] notify mentor failed:' , err )
}
}
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'FINALIST_UNCONFIRM' ,
entityType : 'FinalistConfirmation' ,
entityId : confirmation.id ,
detailsJson : {
reason : input.reason ,
projectId : confirmation.projectId ,
cascadedMentorAssignment ,
2026-05-22 16:37:37 +02:00
cascadedAssignmentCount : activeAssignments.length ,
2026-04-28 18:37:34 +02:00
} ,
} )
2026-06-04 16:14:26 +02:00
// Withdrawal notification to team lead — best-effort, never throws
try {
const lead = confirmation . project . teamMembers [ 0 ]
if ( lead ) {
const projectTitle = confirmation . project . title
await createNotification ( {
userId : lead.userId ,
type : NotificationTypes . FINALIST_WITHDRAWN ,
title : 'Grand finale slot withdrawn' ,
message : ` Your team " ${ projectTitle } " is no longer a confirmed finalist. ` ,
linkUrl : '/applicant' ,
metadata : { projectTitle } ,
} )
}
} catch ( err ) {
console . error ( '[finalist.unconfirm] failed to send withdrawal notification to team:' , err )
}
2026-04-28 18:37:34 +02:00
return { ok : true , cascadedMentorAssignment }
} ) ,
2026-04-28 18:00:47 +02:00
/ * *
* Manually promote a specific waitlist entry out of rank order . Sends a
* fresh confirmation email + audit - logs the override ( separate from
* automatic cascade ) .
* /
manualPromote : adminProcedure
. input (
z . object ( {
waitlistEntryId : z.string ( ) ,
windowHours : z.number ( ) . int ( ) . min ( 1 ) . max ( 168 ) . default ( 24 ) ,
} ) ,
)
. mutation ( async ( { ctx , input } ) = > {
const entry = await ctx . prisma . waitlistEntry . findUniqueOrThrow ( {
where : { id : input.waitlistEntryId } ,
select : {
id : true ,
projectId : true ,
category : true ,
status : true ,
programId : true ,
} ,
} )
if ( entry . status !== 'WAITING' ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` Waitlist entry is ${ entry . status } , not WAITING ` ,
} )
}
await ctx . prisma . waitlistEntry . update ( {
where : { id : entry.id } ,
data : { status : 'PROMOTED' } ,
} )
const { id : confirmationId , token , deadline } = await createPendingConfirmation (
ctx . prisma ,
{
projectId : entry.projectId ,
category : entry.category ,
windowHours : input.windowHours ,
promotedFromWaitlistEntryId : entry.id ,
} ,
)
// Email send (best-effort)
const project = await ctx . prisma . project . findUnique ( {
where : { id : entry.projectId } ,
select : {
title : true ,
teamMembers : {
where : { role : 'LEAD' } ,
take : 1 ,
select : { user : { select : { email : true , name : true } } } ,
} ,
} ,
} )
const lead = project ? . teamMembers [ 0 ] ? . user
if ( lead ? . email && project ) {
const baseUrl = ( process . env . NEXTAUTH_URL ? ? 'http://localhost:3000' ) . replace ( /\/$/ , '' )
const confirmUrl = ` ${ baseUrl } /finalist/confirm/ ${ token } `
try {
await sendFinalistConfirmationEmail (
lead . email ,
lead . name ? ? null ,
project . title ,
deadline ,
confirmUrl ,
)
} catch ( err ) {
console . error (
` [finalist.manualPromote] failed to send email for project ${ entry . projectId } : ` ,
err ,
)
}
}
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'FINALIST_MANUAL_PROMOTE' ,
entityType : 'WaitlistEntry' ,
entityId : entry.id ,
detailsJson : {
programId : entry.programId ,
category : entry.category ,
projectId : entry.projectId ,
confirmationId ,
windowHours : input.windowHours ,
} ,
} )
2026-06-04 16:11:17 +02:00
// Admin alert — best-effort, never throws
try {
const projectTitle = project ? . title ? ? entry . projectId
await notifyAdmins ( {
type : NotificationTypes . FINALIST_WAITLIST_PROMOTED ,
title : 'Waitlist entry promoted' ,
message : ` " ${ projectTitle } " ( ${ entry . category } ) was manually promoted from the waitlist. ` ,
linkUrl : '/admin/logistics' ,
metadata : {
projectId : entry.projectId ,
projectTitle ,
category : entry.category ,
} ,
} )
} catch ( err ) {
console . error ( '[finalist.manualPromote] failed to send admin notification:' , err )
}
2026-04-28 18:00:47 +02:00
return { confirmationId }
} ) ,
2026-04-28 18:50:52 +02:00
/ * *
* Team lead replaces the AttendingMember roster for a CONFIRMED finalist
* confirmation . Diff - based : rows for users who stay are kept ( preserving
* their FlightDetail ) ; removed users are deleted ( cascading FlightDetail ) ;
* added users get fresh rows . Closed once ` attendeeEditCutoffHours ` ( default
* 48 ) before the LIVE_FINAL round ' s ` windowOpenAt ` .
* /
editAttendees : protectedProcedure
. input (
z . object ( {
confirmationId : z.string ( ) ,
attendingUserIds : z.array ( z . string ( ) ) . min ( 1 ) ,
visaFlags : z.record ( z . string ( ) , z . boolean ( ) ) . default ( { } ) ,
} ) ,
)
. mutation ( async ( { ctx , input } ) = > {
const confirmation = await ctx . prisma . finalistConfirmation . findUniqueOrThrow ( {
where : { id : input.confirmationId } ,
include : {
project : {
select : {
id : true ,
programId : true ,
program : { select : { defaultAttendeeCap : true } } ,
teamMembers : { select : { userId : true , role : true } } ,
} ,
} ,
2026-04-28 19:31:28 +02:00
attendingMembers : {
select : {
id : true ,
userId : true ,
needsVisa : true ,
visaApplication : { select : { id : true } } ,
} ,
} ,
2026-04-28 18:50:52 +02:00
} ,
} )
const callerMembership = confirmation . project . teamMembers . find (
( tm ) = > tm . userId === ctx . user . id ,
)
if ( ! callerMembership ) {
throw new TRPCError ( {
code : 'FORBIDDEN' ,
message : 'You are not a team member of this project' ,
} )
}
if ( callerMembership . role !== 'LEAD' ) {
throw new TRPCError ( {
code : 'FORBIDDEN' ,
message : 'Only the team lead can edit attendees' ,
} )
}
if ( confirmation . status !== 'CONFIRMED' ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : 'Confirmation must be in CONFIRMED status to edit attendees' ,
} )
}
// Cap check
const cap = confirmation . project . program . defaultAttendeeCap
if ( input . attendingUserIds . length > cap ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` Selection exceeds attendee cap of ${ cap } ` ,
} )
}
// Team membership check for the new roster
const teamUserIds = new Set ( confirmation . project . teamMembers . map ( ( tm ) = > tm . userId ) )
for ( const id of input . attendingUserIds ) {
if ( ! teamUserIds . has ( id ) ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` User ${ id } is not a team member ` ,
} )
}
}
// Cutoff check — uses the LIVE_FINAL round's windowOpenAt + cfg.attendeeEditCutoffHours
const round = await ctx . prisma . round . findFirst ( {
where : {
competition : { programId : confirmation.project.programId } ,
roundType : 'LIVE_FINAL' ,
} ,
orderBy : { sortOrder : 'desc' } ,
select : { windowOpenAt : true , configJson : true } ,
} )
if ( round ? . windowOpenAt ) {
const cfg = ( round . configJson ? ? { } ) as { attendeeEditCutoffHours? : number }
const cutoffHours = cfg . attendeeEditCutoffHours ? ? 48
const cutoffAt = new Date ( round . windowOpenAt . getTime ( ) - cutoffHours * 3 _600_000 )
if ( Date . now ( ) > cutoffAt . getTime ( ) ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` Attendee edits closed ${ cutoffHours } h before the grand finale (cutoff was ${ cutoffAt . toISOString ( ) } ) ` ,
} )
}
}
// Diff: keep users in both, delete removed, create added
const desiredIds = new Set ( input . attendingUserIds )
const existingByUser = new Map (
confirmation . attendingMembers . map ( ( m ) = > [ m . userId , m ] as const ) ,
)
const toDelete = confirmation . attendingMembers . filter ( ( m ) = > ! desiredIds . has ( m . userId ) )
const toCreate = input . attendingUserIds . filter ( ( id ) = > ! existingByUser . has ( id ) )
const toUpdate = input . attendingUserIds . filter ( ( id ) = > existingByUser . has ( id ) )
2026-04-28 19:31:28 +02:00
await ctx . prisma . $transaction ( async ( tx ) = > {
if ( toDelete . length > 0 ) {
// FK cascade removes any VisaApplication rows tied to deleted attendees
await tx . attendingMember . deleteMany ( {
where : { id : { in : toDelete . map ( ( m ) = > m . id ) } } ,
} )
}
// Diff visa flips for users that stay
const visaToDelete : string [ ] = [ ]
for ( const userId of toUpdate ) {
const existing = existingByUser . get ( userId ) !
const wantsVisa = input . visaFlags [ userId ] === true
await tx . attendingMember . update ( {
where : { id : existing.id } ,
data : { needsVisa : wantsVisa } ,
} )
if ( existing . visaApplication && ! wantsVisa ) {
visaToDelete . push ( existing . visaApplication . id )
} else if ( ! existing . visaApplication && wantsVisa ) {
await tx . visaApplication . create ( {
data : { attendingMemberId : existing.id , status : 'REQUESTED' } ,
} )
}
}
if ( visaToDelete . length > 0 ) {
await tx . visaApplication . deleteMany ( { where : { id : { in : visaToDelete } } } )
}
if ( toCreate . length > 0 ) {
await tx . attendingMember . createMany ( {
data : toCreate.map ( ( userId ) = > ( {
confirmationId : confirmation.id ,
userId ,
needsVisa : input.visaFlags [ userId ] ? ? false ,
} ) ) ,
} )
const newVisaUsers = toCreate . filter ( ( id ) = > input . visaFlags [ id ] === true )
if ( newVisaUsers . length > 0 ) {
const created = await tx . attendingMember . findMany ( {
where : { confirmationId : confirmation.id , userId : { in : newVisaUsers } } ,
select : { id : true } ,
} )
await tx . visaApplication . createMany ( {
data : created.map ( ( m ) = > ( { attendingMemberId : m.id , status : 'REQUESTED' } ) ) ,
} )
}
2026-04-29 02:28:51 +02:00
const newMembers = await tx . attendingMember . findMany ( {
where : { confirmationId : confirmation.id , userId : { in : toCreate } } ,
select : { id : true } ,
} )
for ( const m of newMembers ) {
await ensureLunchPickForAttendingMember ( tx , m . id )
}
2026-04-28 19:31:28 +02:00
}
} )
2026-04-28 18:50:52 +02:00
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'FINALIST_EDIT_ATTENDEES' ,
entityType : 'FinalistConfirmation' ,
entityId : confirmation.id ,
detailsJson : {
attendingUserIds : input.attendingUserIds ,
visaFlags : input.visaFlags ,
added : toCreate ,
removed : toDelete.map ( ( m ) = > m . userId ) ,
} ,
} )
return { ok : true }
} ) ,
2026-06-04 15:20:51 +02:00
2026-06-04 15:29:26 +02:00
/ * *
* List all MENTORING - round projects as enrollment candidates , grouped by
* competitionCategory . Each candidate includes team members , inLiveFinal flag ,
* confirmationStatus , and per - category quota + confirmed / pending counts .
* Drives the Finalist Enrollment Card on the LIVE_FINAL round Overview page .
* /
listEnrollmentCandidates : adminProcedure
. input ( z . object ( { programId : z.string ( ) } ) )
. query ( async ( { ctx , input } ) = > {
// Resolve program (for attendeeCap) and its competitions' rounds
const program = await ctx . prisma . program . findUniqueOrThrow ( {
where : { id : input.programId } ,
select : { defaultAttendeeCap : true } ,
} )
// Find the MENTORING and LIVE_FINAL rounds within this program's competitions
const rounds = await ctx . prisma . round . findMany ( {
where : {
competition : { programId : input.programId } ,
roundType : { in : [ 'MENTORING' , 'LIVE_FINAL' ] } ,
} ,
select : { id : true , roundType : true } ,
orderBy : { sortOrder : 'asc' } ,
} )
const mentoringRound = rounds . find ( ( r ) = > r . roundType === 'MENTORING' ) ? ? null
const liveFinalRound = rounds . find ( ( r ) = > r . roundType === 'LIVE_FINAL' ) ? ? null
if ( ! mentoringRound ) {
return {
liveFinalRoundId : liveFinalRound?.id ? ? null ,
attendeeCap : program.defaultAttendeeCap ,
categories : [ ] ,
}
}
// Load all PRS in the MENTORING round with full project + team data
const states = await ctx . prisma . projectRoundState . findMany ( {
where : { roundId : mentoringRound.id } ,
select : {
project : {
select : {
id : true ,
title : true ,
teamName : true ,
country : true ,
competitionCategory : true ,
finalistConfirmation : { select : { status : true } } ,
projectRoundStates : {
where : { roundId : liveFinalRound?.id ? ? '' } ,
select : { projectId : true } ,
take : 1 ,
} ,
teamMembers : {
select : {
userId : true ,
role : true ,
user : { select : { name : true , email : true } } ,
} ,
} ,
} ,
} ,
} ,
orderBy : [ { project : { title : 'asc' } } ] ,
} )
// Aggregate confirmed/pending counts per category (mirror listCategoryCounts)
const grouped = await ctx . prisma . finalistConfirmation . groupBy ( {
by : [ 'category' , 'status' ] ,
where : { project : { programId : input.programId } } ,
_count : { _all : true } ,
} )
const countsByCategory = new Map < string , { confirmed : number ; pending : number } > ( )
for ( const g of grouped ) {
const slot = countsByCategory . get ( g . category ) ? ? { confirmed : 0 , pending : 0 }
if ( g . status === 'CONFIRMED' ) slot . confirmed = g . _count . _all
if ( g . status === 'PENDING' ) slot . pending = g . _count . _all
countsByCategory . set ( g . category , slot )
}
// Load quotas for this program
const quotas = await ctx . prisma . finalistSlotQuota . findMany ( {
where : { programId : input.programId } ,
select : { category : true , quota : true } ,
} )
const quotaByCategory = new Map ( quotas . map ( ( q ) = > [ q . category as string , q . quota ] ) )
// Group candidates by competitionCategory
const categoryMap = new Map <
string ,
{
category : string
quota : number | null
confirmedCount : number
pendingCount : number
candidates : Array < {
projectId : string
title : string
teamName : string | null
country : string | null
inLiveFinal : boolean
confirmationStatus : string | null
teamMembers : Array < { userId : string ; name : string | null ; role : string ; email : string } >
} >
}
> ( )
for ( const s of states ) {
const p = s . project
const cat = ( p . competitionCategory as string ) ? ? 'UNKNOWN'
if ( ! categoryMap . has ( cat ) ) {
const counts = countsByCategory . get ( cat ) ? ? { confirmed : 0 , pending : 0 }
categoryMap . set ( cat , {
category : cat ,
quota : quotaByCategory.get ( cat ) ? ? null ,
confirmedCount : counts.confirmed ,
pendingCount : counts.pending ,
candidates : [ ] ,
} )
}
const inLiveFinal = p . projectRoundStates . length > 0
categoryMap . get ( cat ) ! . candidates . push ( {
projectId : p.id ,
title : p.title ,
teamName : p.teamName ,
country : p.country ,
inLiveFinal ,
confirmationStatus : p.finalistConfirmation?.status ? ? null ,
teamMembers : p.teamMembers.map ( ( tm ) = > ( {
userId : tm.userId ,
name : tm.user.name ,
role : tm.role ,
email : tm.user.email ,
} ) ) ,
} )
}
return {
liveFinalRoundId : liveFinalRound?.id ? ? null ,
attendeeCap : program.defaultAttendeeCap ,
categories : Array.from ( categoryMap . values ( ) ) ,
}
} ) ,
2026-06-04 15:20:51 +02:00
/ * *
* Unified finalist enrollment : advances a set of projects into the LIVE_FINAL
* round ( creates ProjectRoundState , skipDuplicates ) AND creates / resets their
* FinalistConfirmation in one atomic step .
*
* Two attendee modes per project :
* - EMAIL : sends the self - confirm link to the team lead ( never throws in loop )
* - ADMIN_CONFIRM : validates + writes attendees immediately ( CONFIRMED status )
*
* Returns { enrolled , emailed , adminConfirmed , skipped } .
* /
enrollFinalists : adminProcedure
. input (
z . object ( {
programId : z.string ( ) ,
roundId : z.string ( ) , // the LIVE_FINAL round
enrollments : z
. array (
z . object ( {
projectId : z.string ( ) ,
mode : z.enum ( [ 'EMAIL' , 'ADMIN_CONFIRM' ] ) ,
attendingUserIds : z.array ( z . string ( ) ) . optional ( ) ,
visaFlags : z.record ( z . string ( ) , z . boolean ( ) ) . optional ( ) ,
} ) ,
)
. min ( 1 ) ,
} ) ,
)
. mutation ( async ( { ctx , input } ) = > {
2026-06-04 15:49:13 +02:00
// Resolve the LIVE_FINAL round + confirmationWindowHours. Validate the
// round belongs to the target program so an admin can't inject projects
// into another edition's round.
2026-06-04 15:20:51 +02:00
const round = await ctx . prisma . round . findUniqueOrThrow ( {
where : { id : input.roundId } ,
2026-06-04 15:49:13 +02:00
select : {
id : true ,
configJson : true ,
competition : { select : { programId : true } } ,
} ,
2026-06-04 15:20:51 +02:00
} )
2026-06-04 15:49:13 +02:00
if ( round . competition . programId !== input . programId ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : 'Round does not belong to this program' ,
} )
}
2026-06-04 15:20:51 +02:00
const cfg = ( round . configJson ? ? { } ) as { confirmationWindowHours? : number }
const windowHours = cfg . confirmationWindowHours ? ? 24
// Validate all projects belong to this program
const projectIds = input . enrollments . map ( ( e ) = > e . projectId )
const projects = await ctx . prisma . project . findMany ( {
where : { id : { in : projectIds } , programId : input.programId } ,
select : {
id : true ,
title : true ,
competitionCategory : true ,
program : { select : { defaultAttendeeCap : true } } ,
teamMembers : {
select : {
userId : true ,
role : true ,
user : { select : { email : true , name : true } } ,
} ,
} ,
} ,
} )
if ( projects . length !== projectIds . length ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : 'One or more project IDs not found in this program' ,
} )
}
const projectMap = new Map ( projects . map ( ( p ) = > [ p . id , p ] ) )
const baseUrl = ( process . env . NEXTAUTH_URL ? ? 'http://localhost:3000' ) . replace ( /\/$/ , '' )
2026-06-04 15:49:13 +02:00
// Pre-validate every ADMIN_CONFIRM enrollment up front so a bad entry in
// a multi-team batch fails before any project is partially written.
2026-06-04 15:20:51 +02:00
for ( const enrollment of input . enrollments ) {
2026-06-04 15:49:13 +02:00
if ( enrollment . mode !== 'ADMIN_CONFIRM' ) continue
2026-06-04 15:20:51 +02:00
const project = projectMap . get ( enrollment . projectId ) !
const cap = project . program . defaultAttendeeCap
2026-06-04 15:49:13 +02:00
const attendingUserIds = enrollment . attendingUserIds ? ? [ ]
if ( attendingUserIds . length === 0 ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` ADMIN_CONFIRM mode requires attendingUserIds for project ${ project . id } ` ,
} )
}
if ( attendingUserIds . length > cap ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : ` Selection exceeds attendee cap of ${ cap } for project ${ project . id } ` ,
} )
}
const teamUserIds = new Set ( project . teamMembers . map ( ( tm ) = > tm . userId ) )
for ( const uid of attendingUserIds ) {
if ( ! teamUserIds . has ( uid ) ) {
2026-06-04 15:20:51 +02:00
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
2026-06-04 15:49:13 +02:00
message : ` User ${ uid } is not a team member of project ${ project . id } ` ,
2026-06-04 15:20:51 +02:00
} )
}
}
2026-06-04 15:49:13 +02:00
}
let enrolled = 0
let emailed = 0
let adminConfirmed = 0
const skipped : Array < { projectId : string ; reason : string } > = [ ]
for ( const enrollment of input . enrollments ) {
const project = projectMap . get ( enrollment . projectId ) !
2026-06-04 15:20:51 +02:00
// Step 1: Create ProjectRoundState in LIVE_FINAL round (idempotent)
await ctx . prisma . projectRoundState . createMany ( {
data : [ { projectId : enrollment.projectId , roundId : input.roundId } ] ,
skipDuplicates : true ,
} )
// Step 2: Create or reset the finalist confirmation
const category = project . competitionCategory as CompetitionCategory
const confirmResult = await resetOrCreatePendingConfirmation ( ctx . prisma , {
projectId : enrollment.projectId ,
category ,
windowHours ,
} )
if ( confirmResult . alreadyConfirmed ) {
skipped . push ( { projectId : enrollment.projectId , reason : 'ALREADY_CONFIRMED' } )
enrolled ++
continue
}
enrolled ++
// Step 3: Mode-specific handling
if ( enrollment . mode === 'EMAIL' ) {
// Send confirmation email to team lead (best-effort — never throw in loop)
const lead = project . teamMembers . find ( ( tm ) = > tm . role === 'LEAD' ) ? . user
if ( lead ? . email ) {
const confirmUrl = ` ${ baseUrl } /finalist/confirm/ ${ confirmResult . token } `
try {
await sendFinalistConfirmationEmail (
lead . email ,
lead . name ? ? null ,
project . title ,
confirmResult . deadline ,
confirmUrl ,
)
emailed ++
} catch ( err ) {
console . error (
` [finalist.enrollFinalists] failed to send email to ${ lead . email } for project ${ enrollment . projectId } : ` ,
err ,
)
}
}
} else {
// ADMIN_CONFIRM: write attendees + visa + lunch rows immediately
const attendingUserIds = enrollment . attendingUserIds !
const visaFlags = enrollment . visaFlags ? ? { }
await ctx . prisma . $transaction ( async ( tx ) = > {
await confirmAttendanceInTx ( tx , {
confirmationId : confirmResult.id ,
attendingUserIds ,
visaFlags ,
} )
} )
adminConfirmed ++
}
// Step 4: Audit per enrollment
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'FINALIST_ENROLL' ,
entityType : 'Project' ,
entityId : enrollment.projectId ,
detailsJson : {
projectId : enrollment.projectId ,
mode : enrollment.mode ,
roundId : input.roundId ,
programId : input.programId ,
} ,
} )
}
return { enrolled , emailed , adminConfirmed , skipped }
} ) ,
2026-06-04 15:23:50 +02:00
/ * *
* Reverse enrollment : removes a project from the LIVE_FINAL round and
* deletes its FinalistConfirmation ( cascade removes AttendingMember ,
* FlightDetail , VisaApplication , and MemberLunchPick rows ) .
*
* Mentor assignments ( tied to the MENTORING round ) are intentionally
* left untouched . Safe to call even if the project was never enrolled
* ( deleteMany is a no - op when no rows match ) .
* /
unenroll : adminProcedure
. input (
z . object ( {
projectId : z.string ( ) ,
roundId : z.string ( ) , // the LIVE_FINAL round
} ) ,
)
. mutation ( async ( { ctx , input } ) = > {
2026-06-04 15:49:13 +02:00
// Guard: the project and round must belong to the same program, so a
// mismatched (projectId, roundId) pair from different editions can't be
// used to delete the wrong project's confirmation or round membership.
const project = await ctx . prisma . project . findUniqueOrThrow ( {
where : { id : input.projectId } ,
select : { programId : true } ,
} )
const round = await ctx . prisma . round . findUniqueOrThrow ( {
where : { id : input.roundId } ,
select : { competition : { select : { programId : true } } } ,
} )
if ( project . programId !== round . competition . programId ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : 'Project and round belong to different programs' ,
} )
}
2026-06-04 16:14:26 +02:00
// Step 1: Capture the CONFIRMED confirmation (if any) BEFORE deleting,
// so we can notify the team lead. We only notify when the team had
// already confirmed (CONFIRMED status) — not for PENDING or absent rows.
const confirmedConfirmation = await ctx . prisma . finalistConfirmation . findFirst ( {
where : { projectId : input.projectId , status : 'CONFIRMED' } ,
select : {
project : {
select : {
title : true ,
teamMembers : {
where : { role : 'LEAD' } ,
take : 1 ,
select : { userId : true } ,
} ,
} ,
} ,
} ,
} )
// Step 2: Delete the FinalistConfirmation (cascade removes AttendingMember
2026-06-04 15:23:50 +02:00
// / FlightDetail / VisaApplication / MemberLunchPick).
// deleteMany is no-op-safe when no row exists.
await ctx . prisma . finalistConfirmation . deleteMany ( {
where : { projectId : input.projectId } ,
} )
2026-06-04 16:14:26 +02:00
// Step 3: Delete the LIVE_FINAL ProjectRoundState.
2026-06-04 15:23:50 +02:00
await ctx . prisma . projectRoundState . deleteMany ( {
where : { projectId : input.projectId , roundId : input.roundId } ,
} )
2026-06-04 16:14:26 +02:00
// Step 4: Audit log
2026-06-04 15:23:50 +02:00
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'FINALIST_UNENROLL' ,
entityType : 'Project' ,
entityId : input.projectId ,
detailsJson : {
projectId : input.projectId ,
roundId : input.roundId ,
} ,
} )
2026-06-04 16:14:26 +02:00
// Step 5: Withdrawal notification — only when a CONFIRMED row existed.
// Best-effort, never throws.
if ( confirmedConfirmation ) {
try {
const lead = confirmedConfirmation . project . teamMembers [ 0 ]
if ( lead ) {
const projectTitle = confirmedConfirmation . project . title
await createNotification ( {
userId : lead.userId ,
type : NotificationTypes . FINALIST_WITHDRAWN ,
title : 'Grand finale slot withdrawn' ,
message : ` Your team " ${ projectTitle } " is no longer a confirmed finalist. ` ,
linkUrl : '/applicant' ,
metadata : { projectTitle } ,
} )
}
} catch ( err ) {
console . error ( '[finalist.unenroll] failed to send withdrawal notification to team:' , err )
}
}
2026-06-04 15:23:50 +02:00
return { ok : true }
} ) ,
2026-06-09 15:26:50 +02:00
/** Manually remind finalist teams to upload their Grand Final documents. */
sendDocumentReminders : adminProcedure
. input ( z . object ( { programId : z.string ( ) , projectIds : z.array ( z . string ( ) ) . optional ( ) } ) )
. mutation ( async ( { ctx , input } ) = > {
const result = await sendManualFinalDocReminders ( ctx . prisma , {
programId : input.programId ,
projectIds : input.projectIds ,
actorId : ctx.user.id ,
} )
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'FINALIST_DOCS_REMINDER_SENT' ,
entityType : 'Program' ,
entityId : input.programId ,
detailsJson : { sent : result.sent , projectIds : input.projectIds ? ? 'all-missing' } ,
} )
return result
} ) ,
2026-06-09 15:36:59 +02:00
/** Read-only review of all finalists' grand-final documents (admins + finale jury). */
listReviewDocuments : protectedProcedure
. input ( z . object ( { programId : z.string ( ) } ) )
. query ( async ( { ctx , input } ) = > {
const allowed = await userCanReviewFinals ( ctx . prisma , ctx . user . id , ctx . user . role , input . programId )
if ( ! allowed ) throw new TRPCError ( { code : 'FORBIDDEN' , message : 'You do not have access to the finalist documents review.' } )
return listFinalistDocumentsForReview ( ctx . prisma , input . programId )
} ) ,
2026-04-28 17:52:22 +02:00
} )