Files
MOPC-Portal/src/server/routers/finalist.ts
Matt bdfd99874a feat: auto-create/sync VisaApplication on attendee writes
confirm and adminConfirm now create REQUESTED VisaApplication rows for
every attendee with needsVisa=true, in the same Prisma transaction as
the AttendingMember inserts. editAttendees was extended into a fully
diff-aware sync: existing attendees whose needsVisa flips on get a new
VisaApp; flipping off deletes it; staying true preserves the row (and
its status / notes / dates). Removed attendees cascade automatically
via the FK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:31:28 +02:00

1094 lines
37 KiB
TypeScript

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { CompetitionCategory } from '@prisma/client'
import { router, adminProcedure, protectedProcedure, 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<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 } },
},
})
}),
/**
* 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(async (tx) => {
await tx.finalistConfirmation.update({
where: { id: confirmation.id },
data: { status: 'CONFIRMED', confirmedAt: new Date() },
})
await tx.attendingMember.createMany({
data: input.attendingUserIds.map((userId) => ({
confirmationId: confirmation.id,
userId,
needsVisa: input.visaFlags[userId] ?? false,
})),
})
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' })),
})
}
})
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 }
}),
/**
* 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`,
})
}
}
await ctx.prisma.$transaction(async (tx) => {
await tx.finalistConfirmation.update({
where: { id: confirmation.id },
data: { status: 'CONFIRMED', confirmedAt: new Date() },
})
await tx.attendingMember.createMany({
data: input.attendingUserIds.map((userId) => ({
confirmationId: confirmation.id,
userId,
needsVisa: input.visaFlags[userId] ?? false,
})),
})
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' })),
})
}
})
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 } },
},
})
}),
/**
* 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 }
}),
/**
* 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 } },
},
},
attendingMembers: {
select: {
id: true,
userId: true,
needsVisa: true,
visaApplication: { select: { id: true } },
},
},
},
})
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))
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' })),
})
}
}
})
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 }
}),
})