feat: public confirm/decline procedures with waitlist auto-promotion

- finalist.getByToken: public lookup of a confirmation by signed token,
  with all the data the public page needs (project, team members, current
  state). Throws on expired/tampered tokens.
- finalist.confirm: validates team membership of every selected user,
  checks against program.defaultAttendeeCap, atomically writes
  status=CONFIRMED + AttendingMember rows in a transaction.
- finalist.decline: captures optional reason, then promotes the next
  WAITING waitlist entry in the same category (no-op if waitlist empty).
  Resolves the new windowHours from the LIVE_FINAL round configJson.
- promoteNextWaitlistEntry service: encapsulates the cascade (mark
  PROMOTED, create fresh PENDING confirmation, send email).
This commit is contained in:
Matt
2026-04-28 17:58:31 +02:00
parent 895be93678
commit 19ef364c71
3 changed files with 622 additions and 4 deletions

View File

@@ -1,10 +1,14 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { CompetitionCategory } from '@prisma/client'
import { router, adminProcedure } from '../trpc'
import { router, adminProcedure, publicProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import { createPendingConfirmation } from '../services/finalist-confirmation'
import {
createPendingConfirmation,
promoteNextWaitlistEntry,
} from '../services/finalist-confirmation'
import { sendFinalistConfirmationEmail } from '@/lib/email'
import { verifyFinalistToken } from '@/lib/finalist-token'
export const finalistRouter = router({
/**
@@ -175,4 +179,188 @@ export const finalistRouter = router({
})
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 }
}),
})