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>
1094 lines
37 KiB
TypeScript
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 }
|
|
}),
|
|
})
|