diff --git a/src/server/routers/logistics.ts b/src/server/routers/logistics.ts index 5f2f7a5..f96d25d 100644 --- a/src/server/routers/logistics.ts +++ b/src/server/routers/logistics.ts @@ -55,6 +55,53 @@ export const logisticsRouter = router({ return hotel }), + /** + * Read-only listing of every FinalistConfirmation in a program, with the + * joined project + attendee count + decline reason. Sorted by status + * priority (PENDING first) then deadline ascending so the most urgent + * decisions surface at the top of the table. + */ + listConfirmations: adminProcedure + .input(z.object({ programId: z.string() })) + .query(async ({ ctx, input }) => { + const rows = await ctx.prisma.finalistConfirmation.findMany({ + where: { project: { programId: input.programId } }, + include: { + project: { + select: { id: true, title: true, competitionCategory: true, country: true }, + }, + _count: { select: { attendingMembers: true } }, + }, + }) + const STATUS_PRIORITY: Record = { + PENDING: 0, + CONFIRMED: 1, + DECLINED: 2, + EXPIRED: 3, + SUPERSEDED: 4, + } + return rows + .map((r) => ({ + id: r.id, + status: r.status, + deadline: r.deadline, + confirmedAt: r.confirmedAt, + declinedAt: r.declinedAt, + declineReason: r.declineReason, + expiredAt: r.expiredAt, + category: r.category, + promotedFromWaitlistEntryId: r.promotedFromWaitlistEntryId, + project: r.project, + attendeeCount: r._count.attendingMembers, + })) + .sort((a, b) => { + const sa = STATUS_PRIORITY[a.status] ?? 9 + const sb = STATUS_PRIORITY[b.status] ?? 9 + if (sa !== sb) return sa - sb + return a.deadline.getTime() - b.deadline.getTime() + }) + }), + /** * List all attending members for CONFIRMED finalists in a program, with * their (optional) flight details. One row per attendee — even those diff --git a/tests/unit/logistics-confirmations.test.ts b/tests/unit/logistics-confirmations.test.ts new file mode 100644 index 0000000..b6bd166 --- /dev/null +++ b/tests/unit/logistics-confirmations.test.ts @@ -0,0 +1,164 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, + createTestProgram, + createTestProject, + cleanupTestData, + uid, +} from '../helpers' +import { logisticsRouter } from '../../src/server/routers/logistics' + +describe('logistics.listConfirmations', () => { + 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 cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('returns every confirmation in a program with project + attendee count', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `confs-list-${uid()}` }) + programIds.push(program.id) + + // Two confirmations: one CONFIRMED with 2 attendees, one PENDING + const lead1 = await prisma.user.create({ + data: { + id: uid('user'), + email: `l1_${uid()}@test.local`, + name: 'L1', + role: 'APPLICANT', + roles: ['APPLICANT'], + status: 'ACTIVE', + }, + }) + const teammate = await prisma.user.create({ + data: { + id: uid('user'), + email: `tm_${uid()}@test.local`, + name: 'TM', + role: 'APPLICANT', + roles: ['APPLICANT'], + status: 'ACTIVE', + }, + }) + userIds.push(lead1.id, teammate.id) + + const projectA = await createTestProject(program.id, { + title: 'A', + competitionCategory: 'STARTUP', + }) + const projectB = await createTestProject(program.id, { + title: 'B', + competitionCategory: 'STARTUP', + }) + + const confirmedRow = await prisma.finalistConfirmation.create({ + data: { + projectId: projectA.id, + category: 'STARTUP', + status: 'CONFIRMED', + deadline: new Date(Date.now() + 86400000), + token: `tok_${uid()}`, + confirmedAt: new Date(), + }, + }) + await prisma.attendingMember.createMany({ + data: [ + { confirmationId: confirmedRow.id, userId: lead1.id, needsVisa: false }, + { confirmationId: confirmedRow.id, userId: teammate.id, needsVisa: true }, + ], + }) + + await prisma.finalistConfirmation.create({ + data: { + projectId: projectB.id, + category: 'STARTUP', + status: 'PENDING', + deadline: new Date(Date.now() + 86400000), + token: `tok_${uid()}`, + }, + }) + + const caller = createCaller(logisticsRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const rows = await caller.listConfirmations({ programId: program.id }) + expect(rows).toHaveLength(2) + + const byTitle = Object.fromEntries( + rows.map((r: (typeof rows)[number]) => [r.project.title, r]), + ) + expect(byTitle['A'].status).toBe('CONFIRMED') + expect(byTitle['A'].attendeeCount).toBe(2) + expect(byTitle['B'].status).toBe('PENDING') + expect(byTitle['B'].attendeeCount).toBe(0) + }) + + it('sorts PENDING first then by deadline ascending', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `confs-sort-${uid()}` }) + programIds.push(program.id) + + const projects = await Promise.all([ + createTestProject(program.id, { title: 'Old confirmed', competitionCategory: 'STARTUP' }), + createTestProject(program.id, { title: 'New pending', competitionCategory: 'STARTUP' }), + createTestProject(program.id, { title: 'Old pending', competitionCategory: 'STARTUP' }), + ]) + await prisma.finalistConfirmation.create({ + data: { + projectId: projects[0].id, + category: 'STARTUP', + status: 'CONFIRMED', + deadline: new Date(Date.now() + 1000), + token: `tok_${uid()}`, + confirmedAt: new Date(), + }, + }) + await prisma.finalistConfirmation.create({ + data: { + projectId: projects[1].id, + category: 'STARTUP', + status: 'PENDING', + deadline: new Date(Date.now() + 200_000), + token: `tok_${uid()}`, + }, + }) + await prisma.finalistConfirmation.create({ + data: { + projectId: projects[2].id, + category: 'STARTUP', + status: 'PENDING', + deadline: new Date(Date.now() + 100_000), + token: `tok_${uid()}`, + }, + }) + + const caller = createCaller(logisticsRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const rows = await caller.listConfirmations({ programId: program.id }) + // PENDING ones first (sorted by deadline asc), then CONFIRMED + expect(rows.map((r: (typeof rows)[number]) => r.project.title)).toEqual([ + 'Old pending', + 'New pending', + 'Old confirmed', + ]) + }) +})