import { z } from 'zod' import { TRPCError } from '@trpc/server' import { CompetitionCategory } from '@prisma/client' import { router, adminProcedure } from '../trpc' import { logAudit } from '../utils/audit' import { createPendingConfirmation } from '../services/finalist-confirmation' import { sendFinalistConfirmationEmail } from '@/lib/email' 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 }), /** * 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 } }), })