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 }
}),
})

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 }
}

View File

@@ -187,3 +187,366 @@ describe('finalist.selectFinalists', () => {
).rejects.toThrow(/category mismatch/i)
})
})
describe('finalist.confirm and decline (public)', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await prisma.attendingMember.deleteMany({
where: { confirmation: { project: { programId } } },
})
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
await prisma.waitlistEntry.deleteMany({ where: { programId } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
async function setupPendingConfirmation(programName: string) {
const program = await createTestProgram({ name: programName })
programIds.push(program.id)
const lead = await prisma.user.create({
data: {
id: uid('user'),
email: `lead_${uid()}@test.local`,
name: 'Team Lead',
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
})
userIds.push(lead.id)
const teammate = await prisma.user.create({
data: {
id: uid('user'),
email: `mate_${uid()}@test.local`,
name: 'Teammate',
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
})
userIds.push(teammate.id)
const project = await createTestProject(program.id, {
title: 'Confirmable Project',
competitionCategory: 'STARTUP',
})
await prisma.teamMember.createMany({
data: [
{ projectId: project.id, userId: lead.id, role: 'LEAD' },
{ projectId: project.id, userId: teammate.id, role: 'MEMBER' },
],
})
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
const round = await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
configJson: { confirmationWindowHours: 24 },
})
return { program, lead, teammate, project, round }
}
it('confirm with valid token + valid attendees succeeds', async () => {
const { program, lead, teammate, project } = await setupPendingConfirmation(
`confirm-ok-${uid()}`,
)
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const adminCaller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const round = await prisma.round.findFirstOrThrow({
where: { competition: { programId: program.id } },
})
await adminCaller.selectFinalists({
programId: program.id,
category: 'STARTUP',
projectIds: [project.id],
roundId: round.id,
})
const confirmation = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { projectId: project.id },
})
const publicCaller = finalistRouter.createCaller({
session: null,
prisma,
ip: '127.0.0.1',
userAgent: 'vitest',
} as never)
await publicCaller.confirm({
token: confirmation.token,
attendingUserIds: [lead.id, teammate.id],
visaFlags: { [teammate.id]: true },
})
const updated = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { id: confirmation.id },
include: { attendingMembers: true },
})
expect(updated.status).toBe('CONFIRMED')
expect(updated.attendingMembers).toHaveLength(2)
const visaForTeammate = updated.attendingMembers.find((a) => a.userId === teammate.id)
expect(visaForTeammate?.needsVisa).toBe(true)
const visaForLead = updated.attendingMembers.find((a) => a.userId === lead.id)
expect(visaForLead?.needsVisa).toBe(false)
})
it('confirm rejects userIds not in the project team', async () => {
const { program, project } = await setupPendingConfirmation(`confirm-bad-${uid()}`)
const outsider = await prisma.user.create({
data: {
id: uid('user'),
email: `outsider_${uid()}@test.local`,
name: 'Outsider',
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
})
userIds.push(outsider.id)
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const adminCaller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const round = await prisma.round.findFirstOrThrow({
where: { competition: { programId: program.id } },
})
await adminCaller.selectFinalists({
programId: program.id,
category: 'STARTUP',
projectIds: [project.id],
roundId: round.id,
})
const confirmation = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { projectId: project.id },
})
const publicCaller = finalistRouter.createCaller({
session: null,
prisma,
ip: '127.0.0.1',
userAgent: 'vitest',
} as never)
await expect(
publicCaller.confirm({
token: confirmation.token,
attendingUserIds: [outsider.id],
visaFlags: {},
}),
).rejects.toThrow(/not a team member/i)
})
it('confirm rejects when attendee count > program.defaultAttendeeCap', async () => {
const { program, lead, teammate, project } = await setupPendingConfirmation(
`confirm-cap-${uid()}`,
)
await prisma.program.update({ where: { id: program.id }, data: { defaultAttendeeCap: 1 } })
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const adminCaller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const round = await prisma.round.findFirstOrThrow({
where: { competition: { programId: program.id } },
})
await adminCaller.selectFinalists({
programId: program.id,
category: 'STARTUP',
projectIds: [project.id],
roundId: round.id,
})
const confirmation = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { projectId: project.id },
})
const publicCaller = finalistRouter.createCaller({
session: null,
prisma,
ip: '127.0.0.1',
userAgent: 'vitest',
} as never)
await expect(
publicCaller.confirm({
token: confirmation.token,
attendingUserIds: [lead.id, teammate.id],
visaFlags: {},
}),
).rejects.toThrow(/attendee cap/i)
})
it('decline marks the confirmation DECLINED with optional reason', async () => {
const { program, project } = await setupPendingConfirmation(`decline-${uid()}`)
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const adminCaller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const round = await prisma.round.findFirstOrThrow({
where: { competition: { programId: program.id } },
})
await adminCaller.selectFinalists({
programId: program.id,
category: 'STARTUP',
projectIds: [project.id],
roundId: round.id,
})
const confirmation = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { projectId: project.id },
})
const publicCaller = finalistRouter.createCaller({
session: null,
prisma,
ip: '127.0.0.1',
userAgent: 'vitest',
} as never)
await publicCaller.decline({ token: confirmation.token, reason: 'team disbanded' })
const updated = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { id: confirmation.id },
})
expect(updated.status).toBe('DECLINED')
expect(updated.declineReason).toBe('team disbanded')
expect(updated.declinedAt).not.toBeNull()
})
it('decline triggers next waitlist entry promotion', async () => {
const { program, project } = await setupPendingConfirmation(`decline-cascade-${uid()}`)
// Create a waitlist entry for a different project in the same category
const backupProject = await createTestProject(program.id, {
title: 'Backup',
competitionCategory: 'STARTUP',
})
const backupLead = await prisma.user.create({
data: {
id: uid('user'),
email: `backup_${uid()}@test.local`,
name: 'Backup Lead',
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
})
userIds.push(backupLead.id)
await prisma.teamMember.create({
data: { projectId: backupProject.id, userId: backupLead.id, role: 'LEAD' },
})
const waitlistEntry = await prisma.waitlistEntry.create({
data: {
programId: program.id,
projectId: backupProject.id,
category: 'STARTUP',
rank: 1,
status: 'WAITING',
},
})
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const adminCaller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const round = await prisma.round.findFirstOrThrow({
where: { competition: { programId: program.id } },
})
await adminCaller.selectFinalists({
programId: program.id,
category: 'STARTUP',
projectIds: [project.id],
roundId: round.id,
})
const original = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { projectId: project.id },
})
const publicCaller = finalistRouter.createCaller({
session: null,
prisma,
ip: '127.0.0.1',
userAgent: 'vitest',
} as never)
await publicCaller.decline({ token: original.token })
// Backup project should now have a PENDING confirmation
const promoted = await prisma.finalistConfirmation.findUnique({
where: { projectId: backupProject.id },
})
expect(promoted).not.toBeNull()
expect(promoted?.status).toBe('PENDING')
expect(promoted?.promotedFromWaitlistEntryId).toBe(waitlistEntry.id)
const updatedEntry = await prisma.waitlistEntry.findUniqueOrThrow({
where: { id: waitlistEntry.id },
})
expect(updatedEntry.status).toBe('PROMOTED')
})
it('decline succeeds even when waitlist is empty', async () => {
const { program, project } = await setupPendingConfirmation(`decline-empty-${uid()}`)
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const adminCaller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const round = await prisma.round.findFirstOrThrow({
where: { competition: { programId: program.id } },
})
await adminCaller.selectFinalists({
programId: program.id,
category: 'STARTUP',
projectIds: [project.id],
roundId: round.id,
})
const confirmation = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { projectId: project.id },
})
const publicCaller = finalistRouter.createCaller({
session: null,
prisma,
ip: '127.0.0.1',
userAgent: 'vitest',
} as never)
await expect(publicCaller.decline({ token: confirmation.token })).resolves.toEqual({
ok: true,
})
})
it('getByToken rejects expired tokens', async () => {
const { program, project } = await setupPendingConfirmation(`confirm-expired-${uid()}`)
// Manually create a confirmation with a past deadline + signed-expired token
const { signFinalistToken } = await import('../../src/lib/finalist-token')
const id = `cmfc_expired_${uid()}`
const expiredExp = Math.floor(Date.now() / 1000) - 60
const token = signFinalistToken({ confirmationId: id, exp: expiredExp })
await prisma.finalistConfirmation.create({
data: {
id,
projectId: project.id,
category: 'STARTUP',
status: 'PENDING',
deadline: new Date(Date.now() - 60_000),
token,
},
})
const publicCaller = finalistRouter.createCaller({
session: null,
prisma,
ip: '127.0.0.1',
userAgent: 'vitest',
} as never)
await expect(publicCaller.getByToken({ token })).rejects.toThrow(/expired/i)
// Cleanup
await prisma.finalistConfirmation.delete({ where: { id } })
void program
})
})