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 { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { CompetitionCategory } from '@prisma/client'
|
import { CompetitionCategory } from '@prisma/client'
|
||||||
import { router, adminProcedure } from '../trpc'
|
import { router, adminProcedure, publicProcedure } from '../trpc'
|
||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
import { createPendingConfirmation } from '../services/finalist-confirmation'
|
import {
|
||||||
|
createPendingConfirmation,
|
||||||
|
promoteNextWaitlistEntry,
|
||||||
|
} from '../services/finalist-confirmation'
|
||||||
import { sendFinalistConfirmationEmail } from '@/lib/email'
|
import { sendFinalistConfirmationEmail } from '@/lib/email'
|
||||||
|
import { verifyFinalistToken } from '@/lib/finalist-token'
|
||||||
|
|
||||||
export const finalistRouter = router({
|
export const finalistRouter = router({
|
||||||
/**
|
/**
|
||||||
@@ -175,4 +179,188 @@ export const finalistRouter = router({
|
|||||||
})
|
})
|
||||||
return { created }
|
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 }
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { CompetitionCategory, PrismaClient } from '@prisma/client'
|
import type { CompetitionCategory, PrismaClient } from '@prisma/client'
|
||||||
import { signFinalistToken } from '@/lib/finalist-token'
|
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
|
* Create a PENDING FinalistConfirmation row with a signed token. Caller is
|
||||||
* responsible for sending the notification email separately.
|
* responsible for sending the notification email separately.
|
||||||
*/
|
*/
|
||||||
export async function createPendingConfirmation(
|
export async function createPendingConfirmation(
|
||||||
prisma: AnyPrisma,
|
prisma: Pick<PrismaClient, 'finalistConfirmation'>,
|
||||||
args: {
|
args: {
|
||||||
projectId: string
|
projectId: string
|
||||||
category: CompetitionCategory
|
category: CompetitionCategory
|
||||||
@@ -38,3 +39,69 @@ export async function createPendingConfirmation(
|
|||||||
})
|
})
|
||||||
return { id, token, deadline }
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -187,3 +187,366 @@ describe('finalist.selectFinalists', () => {
|
|||||||
).rejects.toThrow(/category mismatch/i)
|
).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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user