diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index 2636452..990b52c 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -2747,7 +2747,13 @@ export const applicantRouter = router({ */ getMyFinalistConfirmation: protectedProcedure.query(async ({ ctx }) => { const project = await ctx.prisma.project.findFirst({ - where: { teamMembers: { some: { userId: ctx.user.id } } }, + where: { + OR: [ + { submittedByUserId: ctx.user.id }, + { teamMembers: { some: { userId: ctx.user.id } } }, + ], + finalistConfirmation: { isNot: null }, + }, include: { program: { select: { id: true, defaultAttendeeCap: true } }, teamMembers: { @@ -2858,4 +2864,77 @@ export const applicantRouter = router({ projectId: a.confirmation.project.id, })) }), + + /** + * Returns logistics info for the caller's confirmed finalist project: + * hotel details, the caller's own flight detail, and visa visibility + status. + * Returns null when the caller has no confirmed finalist project. + */ + getMyLogistics: protectedProcedure.query(async ({ ctx }) => { + const project = await ctx.prisma.project.findFirst({ + where: { + OR: [ + { submittedByUserId: ctx.user.id }, + { teamMembers: { some: { userId: ctx.user.id } } }, + ], + finalistConfirmation: { isNot: null }, + }, + include: { + program: { + select: { id: true, visaStatusVisibleToMembers: true }, + }, + finalistConfirmation: true, + }, + orderBy: { createdAt: 'desc' }, + }) + + if (!project || !project.finalistConfirmation) return null + if (project.finalistConfirmation.status !== 'CONFIRMED') return null + + const programId = project.program.id + const confirmationId = project.finalistConfirmation.id + const visaVisible = project.program.visaStatusVisibleToMembers + + // Hotel (1:1 per program) + const hotelRow = await ctx.prisma.hotel.findUnique({ + where: { programId }, + select: { name: true, address: true, link: true, notes: true }, + }) + const hotel = hotelRow ?? null + + // Caller's own AttendingMember + flight + visa + const attendee = await ctx.prisma.attendingMember.findFirst({ + where: { confirmationId, userId: ctx.user.id }, + include: { + flightDetail: true, + visaApplication: visaVisible, + }, + }) + + const fd = attendee?.flightDetail ?? null + const myFlight = fd + ? { + arrivalAt: fd.arrivalAt, + arrivalFlightNumber: fd.arrivalFlightNumber, + arrivalAirport: fd.arrivalAirport, + departureAt: fd.departureAt, + departureFlightNumber: fd.departureFlightNumber, + departureAirport: fd.departureAirport, + status: fd.status, + } + : null + + const va = visaVisible ? (attendee?.visaApplication ?? null) : null + const myVisa = va ? { status: va.status, nationality: va.nationality } : null + + return { + projectTitle: project.title, + confirmationStatus: project.finalistConfirmation.status, + hotel, + myFlight, + visaVisible, + myVisa, + } + }), + }) diff --git a/tests/unit/applicant-my-logistics.test.ts b/tests/unit/applicant-my-logistics.test.ts new file mode 100644 index 0000000..4eab910 --- /dev/null +++ b/tests/unit/applicant-my-logistics.test.ts @@ -0,0 +1,214 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestProgram, + createTestProject, + cleanupTestData, + uid, +} from '../helpers' +import { applicantRouter } from '../../src/server/routers/applicant' + +// ─── shared helpers ──────────────────────────────────────────────────────── + +async function createApplicant() { + const id = uid('user') + return prisma.user.create({ + data: { + id, + email: `${id}@test.local`, + name: 'Test Applicant', + role: 'APPLICANT', + roles: ['APPLICANT'], + status: 'ACTIVE', + }, + }) +} + +/** + * Build a fully-confirmed finalist setup: + * - Program with visaStatusVisibleToMembers=true + * - Project with a TeamMember (LEAD) + * - CONFIRMED FinalistConfirmation + * - Hotel for the program + * - AttendingMember for the caller + * - FlightDetail for that attendee + * - VisaApplication GRANTED for that attendee + */ +async function buildConfirmedFinalist() { + const program = await createTestProgram({ + name: `logistics-${uid()}`, + defaultAttendeeCap: 3, + }) + await prisma.program.update({ + where: { id: program.id }, + data: { visaStatusVisibleToMembers: true }, + }) + + const project = await createTestProject(program.id, { + title: 'Ocean Wave Project', + competitionCategory: 'STARTUP', + }) + + const user = await createApplicant() + + await prisma.teamMember.create({ + data: { projectId: project.id, userId: user.id, role: 'LEAD' }, + }) + + const confirmation = await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: 'CONFIRMED', + deadline: new Date(Date.now() + 86_400_000), + token: `tok_${uid()}`, + confirmedAt: new Date(), + }, + }) + + // Hotel for the program (1:1) + await prisma.hotel.create({ + data: { + programId: program.id, + name: 'Hotel Hermitage', + address: '11 Avenue de la Costa, Monaco', + link: 'https://example.com/hotel', + notes: 'Breakfast included.', + }, + }) + + const attendee = await prisma.attendingMember.create({ + data: { + confirmationId: confirmation.id, + userId: user.id, + needsVisa: true, + }, + }) + + // FlightDetail + await prisma.flightDetail.create({ + data: { + attendingMemberId: attendee.id, + arrivalFlightNumber: 'AF1234', + arrivalAirport: 'NCE', + arrivalAt: new Date('2026-06-20T10:00:00Z'), + departureFlightNumber: 'AF5678', + departureAirport: 'NCE', + departureAt: new Date('2026-06-23T15:00:00Z'), + status: 'CONFIRMED', + }, + }) + + // VisaApplication GRANTED + await prisma.visaApplication.create({ + data: { + attendingMemberId: attendee.id, + status: 'GRANTED', + nationality: 'French', + }, + }) + + return { program, project, user, confirmation, attendee } +} + +// ─── Task 1 tests ────────────────────────────────────────────────────────── + +describe('applicant.getMyLogistics', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await prisma.visaApplication.deleteMany({ + where: { attendingMember: { confirmation: { project: { programId } } } }, + }) + await prisma.flightDetail.deleteMany({ + where: { attendingMember: { confirmation: { project: { programId } } } }, + }) + await prisma.hotel.deleteMany({ where: { programId } }) + 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 hotel, flight, and visa for a confirmed finalist', async () => { + const { program, user } = await buildConfirmedFinalist() + programIds.push(program.id) + userIds.push(user.id) + + const caller = createCaller(applicantRouter, { + id: user.id, + email: user.email, + role: 'APPLICANT', + }) + + const result = await caller.getMyLogistics() + expect(result).not.toBeNull() + expect(result!.projectTitle).toBe('Ocean Wave Project') + expect(result!.confirmationStatus).toBe('CONFIRMED') + expect(result!.hotel).not.toBeNull() + expect(result!.hotel!.name).toBe('Hotel Hermitage') + expect(result!.myFlight).not.toBeNull() + expect(result!.myFlight!.arrivalFlightNumber).toBe('AF1234') + expect(result!.myFlight!.arrivalAirport).toBe('NCE') + expect(result!.visaVisible).toBe(true) + expect(result!.myVisa).not.toBeNull() + expect(result!.myVisa!.status).toBe('GRANTED') + }) + + it('returns null for a user who has no confirmed finalist project', async () => { + // user with no project at all + const nonFinalist = await createApplicant() + userIds.push(nonFinalist.id) + + const caller = createCaller(applicantRouter, { + id: nonFinalist.id, + email: nonFinalist.email, + role: 'APPLICANT', + }) + + const result = await caller.getMyLogistics() + expect(result).toBeNull() + }) + + it('returns null when the confirmation is PENDING (not CONFIRMED)', async () => { + const program = await createTestProgram({ + name: `logistics-pending-${uid()}`, + defaultAttendeeCap: 2, + }) + programIds.push(program.id) + const project = await createTestProject(program.id, { + title: 'Pending Project', + competitionCategory: 'STARTUP', + }) + const user = await createApplicant() + userIds.push(user.id) + await prisma.teamMember.create({ + data: { projectId: project.id, userId: user.id, role: 'LEAD' }, + }) + await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: 'PENDING', + deadline: new Date(Date.now() + 86_400_000), + token: `tok_${uid()}`, + }, + }) + + const caller = createCaller(applicantRouter, { + id: user.id, + email: user.email, + role: 'APPLICANT', + }) + + const result = await caller.getMyLogistics() + expect(result).toBeNull() + }) +})