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,14 +1,15 @@
import type { CompetitionCategory, PrismaClient } from '@prisma/client'
import { signFinalistToken } from '@/lib/finalist-token'
import { sendFinalistConfirmationEmail } from '@/lib/email'
type AnyPrisma = Pick<PrismaClient, 'finalistConfirmation'>
type AnyPrisma = Pick<PrismaClient, 'finalistConfirmation' | 'waitlistEntry' | 'project'>
/**
* Create a PENDING FinalistConfirmation row with a signed token. Caller is
* responsible for sending the notification email separately.
*/
export async function createPendingConfirmation(
prisma: AnyPrisma,
prisma: Pick<PrismaClient, 'finalistConfirmation'>,
args: {
projectId: string
category: CompetitionCategory
@@ -38,3 +39,69 @@ export async function createPendingConfirmation(
})
return { id, token, deadline }
}
/**
* Promote the lowest-ranked WAITING waitlist entry in the given category to
* PROMOTED, create a fresh PENDING confirmation for the project, and send
* the notification email. No-op if no WAITING entry exists.
*/
export async function promoteNextWaitlistEntry(
prisma: AnyPrisma,
args: { programId: string; category: CompetitionCategory; windowHours: number },
): Promise<{ promoted: boolean; entryId?: string; confirmationId?: string }> {
const entry = await prisma.waitlistEntry.findFirst({
where: {
programId: args.programId,
category: args.category,
status: 'WAITING',
},
orderBy: { rank: 'asc' },
})
if (!entry) return { promoted: false }
await prisma.waitlistEntry.update({
where: { id: entry.id },
data: { status: 'PROMOTED' },
})
const { id: confirmationId, token, deadline } = await createPendingConfirmation(prisma, {
projectId: entry.projectId,
category: args.category,
windowHours: args.windowHours,
promotedFromWaitlistEntryId: entry.id,
})
// Send email — log and continue on failure.
const project = await 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(
`[promoteNextWaitlistEntry] failed to send email for project ${entry.projectId}:`,
err,
)
}
}
return { promoted: true, entryId: entry.id, confirmationId }
}