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 ,
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-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 ` ,
} )
}
}
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 ,
} ,
} )
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
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 ) = > {
await tx . finalistConfirmation . update ( {
2026-04-28 19:03:01 +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 19:03:01 +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 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 } ,
include : { project : { select : { programId : true } } } ,
} )
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 ,
} ,
} )
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 ,
} ,
} ,
} ,
} ,
} ,
} )
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
} ,
} )
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 ,
} ,
} )
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-04-28 17:52:22 +02:00
} )