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 17:58:31 +02:00
import { router , adminProcedure , 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 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-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 ,
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 ` ,
} )
}
}
await ctx . prisma . $transaction ( [
ctx . prisma . finalistConfirmation . update ( {
where : { id : confirmation.id } ,
data : { status : 'CONFIRMED' , confirmedAt : new Date ( ) } ,
} ) ,
ctx . prisma . attendingMember . createMany ( {
data : input.attendingUserIds.map ( ( userId ) = > ( {
confirmationId : confirmation.id ,
userId ,
needsVisa : input.visaFlags [ userId ] ? ? false ,
} ) ) ,
} ) ,
] )
await logAudit ( {
prisma : ctx.prisma ,
action : 'FINALIST_CONFIRMED' ,
entityType : 'FinalistConfirmation' ,
entityId : confirmation.id ,
detailsJson : {
projectId : confirmation.projectId ,
attendingUserIds : input.attendingUserIds ,
} ,
} )
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 } ,
include : { project : { select : { programId : 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 ` ,
} )
}
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 ,
} ,
} )
// 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
/ * *
* 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 }
} ) ,
/ * *
* 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 ,
} ,
} )
return { confirmationId }
} ) ,
2026-04-28 17:52:22 +02:00
} )