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:
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user