diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index 8f339b8..457bb52 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -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 } + }), }) diff --git a/src/server/services/finalist-confirmation.ts b/src/server/services/finalist-confirmation.ts index 3446ee9..d1d7f43 100644 --- a/src/server/services/finalist-confirmation.ts +++ b/src/server/services/finalist-confirmation.ts @@ -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 +type AnyPrisma = Pick /** * 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, 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 } +} diff --git a/tests/unit/finalist-confirmation.test.ts b/tests/unit/finalist-confirmation.test.ts index 7572f74..d663fa4 100644 --- a/tests/unit/finalist-confirmation.test.ts +++ b/tests/unit/finalist-confirmation.test.ts @@ -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 + }) +})