From 74cd111e3a29190ae5e7be9bd8bb89f5da3d6e6f Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 4 Jun 2026 16:51:01 +0200 Subject: [PATCH] feat(applicant): self-service visa nationality entry Add updateMyVisaNationality mutation: finds the caller's AttendingMember where the program has visaStatusVisibleToMembers=true and a VisaApplication exists, updates VisaApplication.nationality, and emits a VISA_NATIONALITY_SELF_SET audit log. Throws NOT_FOUND when no eligible application exists. Tests: persists update; rejects caller without a visible visa app. Co-Authored-By: Claude Sonnet 4.6 --- src/server/routers/applicant.ts | 47 +++++++++++++++++ tests/unit/applicant-my-logistics.test.ts | 62 +++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index 990b52c..0e80a44 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -2937,4 +2937,51 @@ export const applicantRouter = router({ } }), + /** + * Allows the authenticated user to self-declare their passport nationality + * on their own VisaApplication when visaStatusVisibleToMembers is true. + */ + updateMyVisaNationality: protectedProcedure + .input(z.object({ nationality: z.string().max(100) })) + .mutation(async ({ ctx, input }) => { + // Find the caller's AttendingMember whose program has visaStatusVisibleToMembers=true + // and which has a visaApplication. + const attendee = await ctx.prisma.attendingMember.findFirst({ + where: { + userId: ctx.user.id, + confirmation: { + project: { + program: { visaStatusVisibleToMembers: true }, + }, + }, + visaApplication: { isNot: null }, + }, + include: { visaApplication: true }, + }) + + if (!attendee || !attendee.visaApplication) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'No visa application to update', + }) + } + + const updated = await ctx.prisma.visaApplication.update({ + where: { id: attendee.visaApplication.id }, + data: { nationality: input.nationality }, + }) + + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'VISA_NATIONALITY_SELF_SET', + entityType: 'VisaApplication', + entityId: updated.id, + detailsJson: { nationality: input.nationality }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }) + + return { ok: true } + }), }) diff --git a/tests/unit/applicant-my-logistics.test.ts b/tests/unit/applicant-my-logistics.test.ts index 4eab910..b9d6fbb 100644 --- a/tests/unit/applicant-my-logistics.test.ts +++ b/tests/unit/applicant-my-logistics.test.ts @@ -212,3 +212,65 @@ describe('applicant.getMyLogistics', () => { expect(result).toBeNull() }) }) + +// ─── Task 2 tests ────────────────────────────────────────────────────────── + +describe('applicant.updateMyVisaNationality', () => { + 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('updates VisaApplication.nationality and persists it', async () => { + const { program, user, attendee } = 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.updateMyVisaNationality({ nationality: 'Kenya' }) + expect(result).toEqual({ ok: true }) + + const updated = await prisma.visaApplication.findUnique({ + where: { attendingMemberId: attendee.id }, + }) + expect(updated!.nationality).toBe('Kenya') + }) + + it('throws NOT_FOUND when the caller has no visible visa application', async () => { + const nonVisa = await createApplicant() + userIds.push(nonVisa.id) + + const caller = createCaller(applicantRouter, { + id: nonVisa.id, + email: nonVisa.email, + role: 'APPLICANT', + }) + + await expect( + caller.updateMyVisaNationality({ nationality: 'Kenya' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }) + }) +})