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