diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index f5df4ec..28a7b5e 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -2774,4 +2774,53 @@ export const applicantRouter = router({ editableNow, } }), + + /** + * Returns the caller's visa application rows (one per AttendingMember + * the caller owns) when their program has visaStatusVisibleToMembers=true. + * Returns null when the toggle is off so the UI can hide the section + * entirely. Returns an empty array when the toggle is on but the caller + * has no needsVisa attendees yet. + */ + getMyVisaApplications: protectedProcedure.query(async ({ ctx }) => { + const attendees = await ctx.prisma.attendingMember.findMany({ + where: { userId: ctx.user.id }, + include: { + confirmation: { + select: { + id: true, + project: { + select: { + id: true, + programId: true, + program: { select: { visaStatusVisibleToMembers: true } }, + }, + }, + }, + }, + visaApplication: true, + }, + }) + + const visibleAttendees = attendees.filter( + (a) => a.confirmation.project.program.visaStatusVisibleToMembers, + ) + if (attendees.length > 0 && visibleAttendees.length === 0) { + return null + } + + return visibleAttendees + .filter((a) => a.visaApplication !== null) + .map((a) => ({ + id: a.visaApplication!.id, + attendingMemberId: a.id, + status: a.visaApplication!.status, + nationality: a.visaApplication!.nationality, + invitationSentAt: a.visaApplication!.invitationSentAt, + appointmentAt: a.visaApplication!.appointmentAt, + decisionAt: a.visaApplication!.decisionAt, + notes: a.visaApplication!.notes, + projectId: a.confirmation.project.id, + })) + }), }) diff --git a/tests/unit/visa-member-visibility.test.ts b/tests/unit/visa-member-visibility.test.ts new file mode 100644 index 0000000..af38131 --- /dev/null +++ b/tests/unit/visa-member-visibility.test.ts @@ -0,0 +1,146 @@ +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' + +async function createApplicant() { + const id = uid('user') + return prisma.user.create({ + data: { + id, + email: `${id}@test.local`, + name: 'Test User', + role: 'APPLICANT', + roles: ['APPLICANT'], + status: 'ACTIVE', + }, + }) +} + +async function setupMemberWithVisa(opts: { + programName: string + visible: boolean + hasVisaApp: boolean +}) { + const program = await createTestProgram({ + name: opts.programName, + defaultAttendeeCap: 3, + }) + await prisma.program.update({ + where: { id: program.id }, + data: { visaStatusVisibleToMembers: opts.visible }, + }) + const project = await createTestProject(program.id, { + title: 'P', + 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(), + }, + }) + const attendee = await prisma.attendingMember.create({ + data: { confirmationId: confirmation.id, userId: user.id, needsVisa: opts.hasVisaApp }, + }) + if (opts.hasVisaApp) { + await prisma.visaApplication.create({ + data: { + attendingMemberId: attendee.id, + status: 'APPOINTMENT_BOOKED', + appointmentAt: new Date('2026-06-15T10:00:00Z'), + }, + }) + } + return { program, project, user } +} + +describe('applicant.getMyVisaApplications', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await prisma.visaApplication.deleteMany({ + where: { attendingMember: { confirmation: { project: { 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 the caller-team visa apps when toggle is true', async () => { + const { program, user } = await setupMemberWithVisa({ + programName: `visa-vis-on-${uid()}`, + visible: true, + hasVisaApp: true, + }) + programIds.push(program.id) + userIds.push(user.id) + + const caller = createCaller(applicantRouter, { + id: user.id, + email: user.email, + role: 'APPLICANT', + }) + const result = await caller.getMyVisaApplications() + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(1) + expect(result![0].status).toBe('APPOINTMENT_BOOKED') + }) + + it('returns null when toggle is false', async () => { + const { program, user } = await setupMemberWithVisa({ + programName: `visa-vis-off-${uid()}`, + visible: false, + hasVisaApp: true, + }) + programIds.push(program.id) + userIds.push(user.id) + + const caller = createCaller(applicantRouter, { + id: user.id, + email: user.email, + role: 'APPLICANT', + }) + const result = await caller.getMyVisaApplications() + expect(result).toBeNull() + }) + + it('returns empty array when caller has no visa apps but toggle is on', async () => { + const { program, user } = await setupMemberWithVisa({ + programName: `visa-vis-empty-${uid()}`, + visible: true, + hasVisaApp: false, + }) + programIds.push(program.id) + userIds.push(user.id) + + const caller = createCaller(applicantRouter, { + id: user.id, + email: user.email, + role: 'APPLICANT', + }) + const result = await caller.getMyVisaApplications() + expect(result).toEqual([]) + }) +})