2026-04-28 17:52:22 +02:00
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { CompetitionCategory } from '@prisma/client'
import { router , adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
2026-04-28 17:55:09 +02:00
import { createPendingConfirmation } from '../services/finalist-confirmation'
import { sendFinalistConfirmationEmail } from '@/lib/email'
2026-04-28 17:52:22 +02:00
export const finalistRouter = router ( {
/ * *
* 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:52:22 +02:00
} )