feat(finalist): unified enrollFinalists (round membership + confirmation + email/admin-confirm)
- Add `finalist.enrollFinalists` adminProcedure: creates ProjectRoundState in LIVE_FINAL round (skipDuplicates) + resets/creates FinalistConfirmation in one step, with EMAIL and ADMIN_CONFIRM attendee modes. - Extract `confirmAttendanceInTx` helper into finalist-enrollment.ts; reuse from both adminConfirm and enrollFinalists (DRY refactor, all tests green). - Add 4 tests covering EMAIL mode, ADMIN_CONFIRM mode, re-enroll safety, and over-cap rejection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,10 @@ import {
|
|||||||
import { sendFinalistConfirmationEmail } from '@/lib/email'
|
import { sendFinalistConfirmationEmail } from '@/lib/email'
|
||||||
import { verifyFinalistToken } from '@/lib/finalist-token'
|
import { verifyFinalistToken } from '@/lib/finalist-token'
|
||||||
import { ensureLunchPickForAttendingMember } from '../services/lunch-pick-sync'
|
import { ensureLunchPickForAttendingMember } from '../services/lunch-pick-sync'
|
||||||
|
import {
|
||||||
|
resetOrCreatePendingConfirmation,
|
||||||
|
confirmAttendanceInTx,
|
||||||
|
} from '../services/finalist-enrollment'
|
||||||
|
|
||||||
export const finalistRouter = router({
|
export const finalistRouter = router({
|
||||||
/** List all per-category finalist slot quotas for a program. */
|
/** List all per-category finalist slot quotas for a program. */
|
||||||
@@ -490,36 +494,11 @@ export const finalistRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ctx.prisma.$transaction(async (tx) => {
|
await ctx.prisma.$transaction(async (tx) => {
|
||||||
await tx.finalistConfirmation.update({
|
await confirmAttendanceInTx(tx, {
|
||||||
where: { id: confirmation.id },
|
|
||||||
data: { status: 'CONFIRMED', confirmedAt: new Date() },
|
|
||||||
})
|
|
||||||
await tx.attendingMember.createMany({
|
|
||||||
data: input.attendingUserIds.map((userId) => ({
|
|
||||||
confirmationId: confirmation.id,
|
confirmationId: confirmation.id,
|
||||||
userId,
|
attendingUserIds: input.attendingUserIds,
|
||||||
needsVisa: input.visaFlags[userId] ?? false,
|
visaFlags: input.visaFlags,
|
||||||
})),
|
|
||||||
})
|
})
|
||||||
const visaUsers = input.attendingUserIds.filter(
|
|
||||||
(uid) => input.visaFlags[uid] === true,
|
|
||||||
)
|
|
||||||
if (visaUsers.length > 0) {
|
|
||||||
const created = await tx.attendingMember.findMany({
|
|
||||||
where: { confirmationId: confirmation.id, userId: { in: visaUsers } },
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
await tx.visaApplication.createMany({
|
|
||||||
data: created.map((m) => ({ attendingMemberId: m.id, status: 'REQUESTED' })),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const allMembers = await tx.attendingMember.findMany({
|
|
||||||
where: { confirmationId: confirmation.id, userId: { in: input.attendingUserIds } },
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
for (const m of allMembers) {
|
|
||||||
await ensureLunchPickForAttendingMember(tx, m.id)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
await logAudit({
|
await logAudit({
|
||||||
prisma: ctx.prisma,
|
prisma: ctx.prisma,
|
||||||
@@ -1116,4 +1095,182 @@ export const finalistRouter = router({
|
|||||||
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified finalist enrollment: advances a set of projects into the LIVE_FINAL
|
||||||
|
* round (creates ProjectRoundState, skipDuplicates) AND creates/resets their
|
||||||
|
* FinalistConfirmation in one atomic step.
|
||||||
|
*
|
||||||
|
* Two attendee modes per project:
|
||||||
|
* - EMAIL: sends the self-confirm link to the team lead (never throws in loop)
|
||||||
|
* - ADMIN_CONFIRM: validates + writes attendees immediately (CONFIRMED status)
|
||||||
|
*
|
||||||
|
* Returns { enrolled, emailed, adminConfirmed, skipped }.
|
||||||
|
*/
|
||||||
|
enrollFinalists: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
programId: z.string(),
|
||||||
|
roundId: z.string(), // the LIVE_FINAL round
|
||||||
|
enrollments: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
mode: z.enum(['EMAIL', 'ADMIN_CONFIRM']),
|
||||||
|
attendingUserIds: z.array(z.string()).optional(),
|
||||||
|
visaFlags: z.record(z.string(), z.boolean()).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Resolve the LIVE_FINAL round + confirmationWindowHours
|
||||||
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { id: true, configJson: true },
|
||||||
|
})
|
||||||
|
const cfg = (round.configJson ?? {}) as { confirmationWindowHours?: number }
|
||||||
|
const windowHours = cfg.confirmationWindowHours ?? 24
|
||||||
|
|
||||||
|
// Validate all projects belong to this program
|
||||||
|
const projectIds = input.enrollments.map((e) => e.projectId)
|
||||||
|
const projects = await ctx.prisma.project.findMany({
|
||||||
|
where: { id: { in: projectIds }, programId: input.programId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
competitionCategory: true,
|
||||||
|
program: { select: { defaultAttendeeCap: true } },
|
||||||
|
teamMembers: {
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
role: true,
|
||||||
|
user: { select: { email: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (projects.length !== projectIds.length) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'One or more project IDs not found in this program',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectMap = new Map(projects.map((p) => [p.id, p]))
|
||||||
|
const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
|
||||||
|
|
||||||
|
let enrolled = 0
|
||||||
|
let emailed = 0
|
||||||
|
let adminConfirmed = 0
|
||||||
|
const skipped: Array<{ projectId: string; reason: string }> = []
|
||||||
|
|
||||||
|
for (const enrollment of input.enrollments) {
|
||||||
|
const project = projectMap.get(enrollment.projectId)!
|
||||||
|
const cap = project.program.defaultAttendeeCap
|
||||||
|
|
||||||
|
// ADMIN_CONFIRM pre-validation: validate attendingUserIds before touching DB
|
||||||
|
if (enrollment.mode === 'ADMIN_CONFIRM') {
|
||||||
|
const attendingUserIds = enrollment.attendingUserIds ?? []
|
||||||
|
if (attendingUserIds.length === 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: `ADMIN_CONFIRM mode requires attendingUserIds for project ${project.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (attendingUserIds.length > cap) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: `Selection exceeds attendee cap of ${cap} for project ${project.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const teamUserIds = new Set(project.teamMembers.map((tm) => tm.userId))
|
||||||
|
for (const uid of attendingUserIds) {
|
||||||
|
if (!teamUserIds.has(uid)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: `User ${uid} is not a team member of project ${project.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Create ProjectRoundState in LIVE_FINAL round (idempotent)
|
||||||
|
await ctx.prisma.projectRoundState.createMany({
|
||||||
|
data: [{ projectId: enrollment.projectId, roundId: input.roundId }],
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 2: Create or reset the finalist confirmation
|
||||||
|
const category = project.competitionCategory as CompetitionCategory
|
||||||
|
const confirmResult = await resetOrCreatePendingConfirmation(ctx.prisma, {
|
||||||
|
projectId: enrollment.projectId,
|
||||||
|
category,
|
||||||
|
windowHours,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (confirmResult.alreadyConfirmed) {
|
||||||
|
skipped.push({ projectId: enrollment.projectId, reason: 'ALREADY_CONFIRMED' })
|
||||||
|
enrolled++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
enrolled++
|
||||||
|
|
||||||
|
// Step 3: Mode-specific handling
|
||||||
|
if (enrollment.mode === 'EMAIL') {
|
||||||
|
// Send confirmation email to team lead (best-effort — never throw in loop)
|
||||||
|
const lead = project.teamMembers.find((tm) => tm.role === 'LEAD')?.user
|
||||||
|
if (lead?.email) {
|
||||||
|
const confirmUrl = `${baseUrl}/finalist/confirm/${confirmResult.token}`
|
||||||
|
try {
|
||||||
|
await sendFinalistConfirmationEmail(
|
||||||
|
lead.email,
|
||||||
|
lead.name ?? null,
|
||||||
|
project.title,
|
||||||
|
confirmResult.deadline,
|
||||||
|
confirmUrl,
|
||||||
|
)
|
||||||
|
emailed++
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[finalist.enrollFinalists] failed to send email to ${lead.email} for project ${enrollment.projectId}:`,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ADMIN_CONFIRM: write attendees + visa + lunch rows immediately
|
||||||
|
const attendingUserIds = enrollment.attendingUserIds!
|
||||||
|
const visaFlags = enrollment.visaFlags ?? {}
|
||||||
|
|
||||||
|
await ctx.prisma.$transaction(async (tx) => {
|
||||||
|
await confirmAttendanceInTx(tx, {
|
||||||
|
confirmationId: confirmResult.id,
|
||||||
|
attendingUserIds,
|
||||||
|
visaFlags,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
adminConfirmed++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Audit per enrollment
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'FINALIST_ENROLL',
|
||||||
|
entityType: 'Project',
|
||||||
|
entityId: enrollment.projectId,
|
||||||
|
detailsJson: {
|
||||||
|
projectId: enrollment.projectId,
|
||||||
|
mode: enrollment.mode,
|
||||||
|
roundId: input.roundId,
|
||||||
|
programId: input.programId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { enrolled, emailed, adminConfirmed, skipped }
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { CompetitionCategory, Prisma, PrismaClient } from '@prisma/client'
|
import type { CompetitionCategory, Prisma, PrismaClient } from '@prisma/client'
|
||||||
import { signFinalistToken } from '@/lib/finalist-token'
|
import { signFinalistToken } from '@/lib/finalist-token'
|
||||||
|
import { ensureLunchPickForAttendingMember } from './lunch-pick-sync'
|
||||||
|
|
||||||
type TxClient = PrismaClient | Prisma.TransactionClient
|
type TxClient = PrismaClient | Prisma.TransactionClient
|
||||||
|
|
||||||
@@ -64,3 +65,49 @@ export async function resetOrCreatePendingConfirmation(
|
|||||||
})
|
})
|
||||||
return { id, token, deadline, alreadyConfirmed: false }
|
return { id, token, deadline, alreadyConfirmed: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared confirm transaction: atomically writes CONFIRMED status + attendee
|
||||||
|
* rows + visa applications + lunch picks.
|
||||||
|
* Called from both `adminConfirm` and `enrollFinalists` (ADMIN_CONFIRM mode).
|
||||||
|
*
|
||||||
|
* Must be called inside a Prisma $transaction block — `tx` is the transaction
|
||||||
|
* client, NOT the top-level prisma client.
|
||||||
|
*/
|
||||||
|
export async function confirmAttendanceInTx(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
args: {
|
||||||
|
confirmationId: string
|
||||||
|
attendingUserIds: string[]
|
||||||
|
visaFlags: Record<string, boolean>
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
await tx.finalistConfirmation.update({
|
||||||
|
where: { id: args.confirmationId },
|
||||||
|
data: { status: 'CONFIRMED', confirmedAt: new Date() },
|
||||||
|
})
|
||||||
|
await tx.attendingMember.createMany({
|
||||||
|
data: args.attendingUserIds.map((userId) => ({
|
||||||
|
confirmationId: args.confirmationId,
|
||||||
|
userId,
|
||||||
|
needsVisa: args.visaFlags[userId] ?? false,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
const visaUsers = args.attendingUserIds.filter((uid) => args.visaFlags[uid] === true)
|
||||||
|
if (visaUsers.length > 0) {
|
||||||
|
const created = await tx.attendingMember.findMany({
|
||||||
|
where: { confirmationId: args.confirmationId, userId: { in: visaUsers } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
await tx.visaApplication.createMany({
|
||||||
|
data: created.map((m) => ({ attendingMemberId: m.id, status: 'REQUESTED' })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const allMembers = await tx.attendingMember.findMany({
|
||||||
|
where: { confirmationId: args.confirmationId, userId: { in: args.attendingUserIds } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
for (const m of allMembers) {
|
||||||
|
await ensureLunchPickForAttendingMember(tx, m.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,30 @@
|
|||||||
import { afterAll, describe, expect, it } from 'vitest'
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
import { prisma } from '../setup'
|
import { prisma, createCaller } from '../setup'
|
||||||
import { createTestProgram, createTestProject, cleanupTestData, uid } from '../helpers'
|
import {
|
||||||
|
createTestUser,
|
||||||
|
createTestProgram,
|
||||||
|
createTestProject,
|
||||||
|
createTestCompetition,
|
||||||
|
createTestRound,
|
||||||
|
cleanupTestData,
|
||||||
|
uid,
|
||||||
|
} from '../helpers'
|
||||||
import { resetOrCreatePendingConfirmation } from '../../src/server/services/finalist-enrollment'
|
import { resetOrCreatePendingConfirmation } from '../../src/server/services/finalist-enrollment'
|
||||||
|
import { finalistRouter } from '../../src/server/routers/finalist'
|
||||||
|
|
||||||
|
async function createApplicantUser(role: 'LEAD' | 'MEMBER' = 'MEMBER') {
|
||||||
|
const id = uid('user')
|
||||||
|
return prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
email: `${id}@test.local`,
|
||||||
|
name: `Test ${role}`,
|
||||||
|
role: 'APPLICANT',
|
||||||
|
roles: ['APPLICANT'],
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe('resetOrCreatePendingConfirmation', () => {
|
describe('resetOrCreatePendingConfirmation', () => {
|
||||||
const programIds: string[] = []
|
const programIds: string[] = []
|
||||||
@@ -58,3 +81,239 @@ describe('resetOrCreatePendingConfirmation', () => {
|
|||||||
expect(res.alreadyConfirmed).toBe(true)
|
expect(res.alreadyConfirmed).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── finalist.enrollFinalists ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('finalist.enrollFinalists', () => {
|
||||||
|
const programIds: string[] = []
|
||||||
|
const userIds: string[] = []
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of programIds) {
|
||||||
|
await prisma.attendingMember.deleteMany({ where: { confirmation: { project: { programId: id } } } })
|
||||||
|
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId: id } } })
|
||||||
|
await prisma.projectRoundState.deleteMany({ where: { round: { competition: { programId: id } } } })
|
||||||
|
await cleanupTestData(id, [])
|
||||||
|
}
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function setupEnrollFixture(programName: string) {
|
||||||
|
const program = await createTestProgram({ name: programName, defaultAttendeeCap: 3 })
|
||||||
|
const competition = await createTestCompetition(program.id)
|
||||||
|
const mentoringRound = await createTestRound(competition.id, {
|
||||||
|
roundType: 'MENTORING',
|
||||||
|
sortOrder: 60,
|
||||||
|
})
|
||||||
|
const liveFinalRound = await createTestRound(competition.id, {
|
||||||
|
roundType: 'LIVE_FINAL',
|
||||||
|
sortOrder: 70,
|
||||||
|
configJson: { confirmationWindowHours: 24 },
|
||||||
|
})
|
||||||
|
const project = await createTestProject(program.id, {
|
||||||
|
title: 'Enroll Test Project',
|
||||||
|
competitionCategory: 'STARTUP',
|
||||||
|
})
|
||||||
|
const lead = await createApplicantUser('LEAD')
|
||||||
|
const member = await createApplicantUser('MEMBER')
|
||||||
|
await prisma.teamMember.createMany({
|
||||||
|
data: [
|
||||||
|
{ projectId: project.id, userId: lead.id, role: 'LEAD' },
|
||||||
|
{ projectId: project.id, userId: member.id, role: 'MEMBER' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
// Put the project in the MENTORING round (as candidates)
|
||||||
|
await prisma.projectRoundState.create({
|
||||||
|
data: { projectId: project.id, roundId: mentoringRound.id },
|
||||||
|
})
|
||||||
|
return { program, competition, mentoringRound, liveFinalRound, project, lead, member }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('EMAIL mode: creates PRS in LIVE_FINAL + PENDING confirmation, no attendees', async () => {
|
||||||
|
const admin = await createTestUser('SUPER_ADMIN')
|
||||||
|
userIds.push(admin.id)
|
||||||
|
|
||||||
|
const { program, liveFinalRound, project, lead, member } = await setupEnrollFixture(
|
||||||
|
`enroll-email-${uid()}`,
|
||||||
|
)
|
||||||
|
programIds.push(program.id)
|
||||||
|
userIds.push(lead.id, member.id)
|
||||||
|
|
||||||
|
const caller = createCaller(finalistRouter, {
|
||||||
|
id: admin.id,
|
||||||
|
email: admin.email,
|
||||||
|
role: 'SUPER_ADMIN',
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await caller.enrollFinalists({
|
||||||
|
programId: program.id,
|
||||||
|
roundId: liveFinalRound.id,
|
||||||
|
enrollments: [{ projectId: project.id, mode: 'EMAIL' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.enrolled).toBe(1)
|
||||||
|
expect(result.skipped).toHaveLength(0)
|
||||||
|
|
||||||
|
const prs = await prisma.projectRoundState.findFirst({
|
||||||
|
where: { projectId: project.id, roundId: liveFinalRound.id },
|
||||||
|
})
|
||||||
|
expect(prs).not.toBeNull()
|
||||||
|
|
||||||
|
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({
|
||||||
|
where: { projectId: project.id },
|
||||||
|
})
|
||||||
|
expect(conf.status).toBe('PENDING')
|
||||||
|
|
||||||
|
const attendeeCount = await prisma.attendingMember.count({
|
||||||
|
where: { confirmationId: conf.id },
|
||||||
|
})
|
||||||
|
expect(attendeeCount).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ADMIN_CONFIRM mode: CONFIRMED with attendee + visa + lunch rows', async () => {
|
||||||
|
const admin = await createTestUser('SUPER_ADMIN')
|
||||||
|
userIds.push(admin.id)
|
||||||
|
|
||||||
|
const { program, liveFinalRound, project, lead, member } = await setupEnrollFixture(
|
||||||
|
`enroll-adminconfirm-${uid()}`,
|
||||||
|
)
|
||||||
|
programIds.push(program.id)
|
||||||
|
userIds.push(lead.id, member.id)
|
||||||
|
|
||||||
|
const caller = createCaller(finalistRouter, {
|
||||||
|
id: admin.id,
|
||||||
|
email: admin.email,
|
||||||
|
role: 'SUPER_ADMIN',
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await caller.enrollFinalists({
|
||||||
|
programId: program.id,
|
||||||
|
roundId: liveFinalRound.id,
|
||||||
|
enrollments: [
|
||||||
|
{
|
||||||
|
projectId: project.id,
|
||||||
|
mode: 'ADMIN_CONFIRM',
|
||||||
|
attendingUserIds: [lead.id, member.id],
|
||||||
|
visaFlags: { [member.id]: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.enrolled).toBe(1)
|
||||||
|
expect(result.adminConfirmed).toBe(1)
|
||||||
|
expect(result.skipped).toHaveLength(0)
|
||||||
|
|
||||||
|
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({
|
||||||
|
where: { projectId: project.id },
|
||||||
|
})
|
||||||
|
expect(conf.status).toBe('CONFIRMED')
|
||||||
|
|
||||||
|
const attendeeCount = await prisma.attendingMember.count({
|
||||||
|
where: { confirmationId: conf.id },
|
||||||
|
})
|
||||||
|
expect(attendeeCount).toBe(2)
|
||||||
|
|
||||||
|
const visaCount = await prisma.visaApplication.count({
|
||||||
|
where: { attendingMember: { confirmationId: conf.id } },
|
||||||
|
})
|
||||||
|
expect(visaCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-enrolling a DECLINED project resets it without crashing and keeps one PRS row', async () => {
|
||||||
|
const admin = await createTestUser('SUPER_ADMIN')
|
||||||
|
userIds.push(admin.id)
|
||||||
|
|
||||||
|
const { program, liveFinalRound, project, lead, member } = await setupEnrollFixture(
|
||||||
|
`enroll-reinvite-${uid()}`,
|
||||||
|
)
|
||||||
|
programIds.push(program.id)
|
||||||
|
userIds.push(lead.id, member.id)
|
||||||
|
|
||||||
|
// Pre-create a DECLINED confirmation
|
||||||
|
await prisma.finalistConfirmation.create({
|
||||||
|
data: {
|
||||||
|
projectId: project.id,
|
||||||
|
category: 'STARTUP',
|
||||||
|
status: 'DECLINED',
|
||||||
|
deadline: new Date(Date.now() - 1000),
|
||||||
|
token: `tok_${uid()}`,
|
||||||
|
declinedAt: new Date(),
|
||||||
|
declineReason: 'schedule conflict',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pre-create a PRS row (simulating prior enrollment)
|
||||||
|
await prisma.projectRoundState.create({
|
||||||
|
data: { projectId: project.id, roundId: liveFinalRound.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
const caller = createCaller(finalistRouter, {
|
||||||
|
id: admin.id,
|
||||||
|
email: admin.email,
|
||||||
|
role: 'SUPER_ADMIN',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-enroll in EMAIL mode — should reset DECLINED to PENDING without crashing
|
||||||
|
await caller.enrollFinalists({
|
||||||
|
programId: program.id,
|
||||||
|
roundId: liveFinalRound.id,
|
||||||
|
enrollments: [{ projectId: project.id, mode: 'EMAIL' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const conf = await prisma.finalistConfirmation.findUniqueOrThrow({
|
||||||
|
where: { projectId: project.id },
|
||||||
|
})
|
||||||
|
expect(conf.status).toBe('PENDING')
|
||||||
|
expect(conf.declinedAt).toBeNull()
|
||||||
|
|
||||||
|
// Exactly one PRS row (skipDuplicates kept it idempotent)
|
||||||
|
const prsRows = await prisma.projectRoundState.findMany({
|
||||||
|
where: { projectId: project.id, roundId: liveFinalRound.id },
|
||||||
|
})
|
||||||
|
expect(prsRows).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ADMIN_CONFIRM rejects when attendees exceed cap', async () => {
|
||||||
|
const admin = await createTestUser('SUPER_ADMIN')
|
||||||
|
userIds.push(admin.id)
|
||||||
|
|
||||||
|
const { program, liveFinalRound, project, lead, member } = await setupEnrollFixture(
|
||||||
|
`enroll-overcap-${uid()}`,
|
||||||
|
)
|
||||||
|
programIds.push(program.id)
|
||||||
|
userIds.push(lead.id, member.id)
|
||||||
|
|
||||||
|
// Create 2 extra members so we can pass 4 (cap = 3)
|
||||||
|
const extra1 = await createApplicantUser('MEMBER')
|
||||||
|
const extra2 = await createApplicantUser('MEMBER')
|
||||||
|
userIds.push(extra1.id, extra2.id)
|
||||||
|
await prisma.teamMember.createMany({
|
||||||
|
data: [
|
||||||
|
{ projectId: project.id, userId: extra1.id, role: 'MEMBER' },
|
||||||
|
{ projectId: project.id, userId: extra2.id, role: 'MEMBER' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const caller = createCaller(finalistRouter, {
|
||||||
|
id: admin.id,
|
||||||
|
email: admin.email,
|
||||||
|
role: 'SUPER_ADMIN',
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.enrollFinalists({
|
||||||
|
programId: program.id,
|
||||||
|
roundId: liveFinalRound.id,
|
||||||
|
enrollments: [
|
||||||
|
{
|
||||||
|
projectId: project.id,
|
||||||
|
mode: 'ADMIN_CONFIRM',
|
||||||
|
attendingUserIds: [lead.id, member.id, extra1.id, extra2.id], // 4 > cap 3
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/cap/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user