import { z } from 'zod' import { TRPCError } from '@trpc/server' import { CompetitionCategory } from '@prisma/client' import { router, adminProcedure, publicProcedure } from '../trpc' import { logAudit } from '../utils/audit' import { createPendingConfirmation, promoteNextWaitlistEntry, } from '../services/finalist-confirmation' import { createNotification, NotificationTypes, } from '../services/in-app-notification' import { sendFinalistConfirmationEmail } from '@/lib/email' import { verifyFinalistToken } from '@/lib/finalist-token' export const finalistRouter = router({ /** 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() 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 } }, }, }) }), /** * 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 } }), /** * 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 24h-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 } }), /** * 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 } }), /** * 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, mentorAssignment: { 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' }, }) // Cascade: drop active mentor assignment (skip if completed or already dropped) const ma = confirmation.project.mentorAssignment let cascadedMentorAssignment = false if (ma && !ma.droppedAt && ma.completionStatus !== 'completed') { 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, }, }) return { ok: true, cascadedMentorAssignment } }), /** * 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 } }), })