diff --git a/src/server/routers/logistics.ts b/src/server/routers/logistics.ts index f96d25d..60f5188 100644 --- a/src/server/routers/logistics.ts +++ b/src/server/routers/logistics.ts @@ -1,5 +1,6 @@ import { z } from 'zod' -import { FlightDetailStatus } from '@prisma/client' +import { FlightDetailStatus, VisaStatus } from '@prisma/client' +import { TRPCError } from '@trpc/server' import { router, adminProcedure } from '../trpc' import { logAudit } from '../utils/audit' @@ -199,4 +200,141 @@ export const logisticsRouter = router({ }) return detail }), + + /** + * List all VisaApplication rows for a program, joined with the project + + * attendee + project so the admin Visas tab can render a flat table. + * Sorted by status priority (REQUESTED first → resolved last) so the most + * urgent in-flight applications surface at the top. + */ + listVisaApplications: adminProcedure + .input(z.object({ programId: z.string() })) + .query(async ({ ctx, input }) => { + const rows = await ctx.prisma.visaApplication.findMany({ + where: { + attendingMember: { + confirmation: { project: { programId: input.programId } }, + }, + }, + include: { + attendingMember: { + include: { + user: { select: { id: true, name: true, email: true } }, + confirmation: { + select: { + id: true, + project: { select: { id: true, title: true } }, + }, + }, + }, + }, + }, + }) + const STATUS_PRIORITY: Record = { + REQUESTED: 0, + INVITATION_SENT: 1, + APPOINTMENT_BOOKED: 2, + GRANTED: 3, + DENIED: 4, + NOT_NEEDED: 5, + } + return rows + .map((r) => ({ + id: r.id, + status: r.status, + nationality: r.nationality, + invitationSentAt: r.invitationSentAt, + appointmentAt: r.appointmentAt, + decisionAt: r.decisionAt, + notes: r.notes, + updatedAt: r.updatedAt, + attendee: { + id: r.attendingMember.id, + user: r.attendingMember.user, + }, + project: r.attendingMember.confirmation.project, + })) + .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.project.title.localeCompare(b.project.title) + }) + }), + + /** + * Update a VisaApplication's status, dates, nationality, and notes. Empty + * date fields clear the value. Audit-logged as VISA_UPDATE. + */ + updateVisaApplication: adminProcedure + .input( + z.object({ + id: z.string(), + status: z.nativeEnum(VisaStatus).optional(), + nationality: z.string().max(100).optional().nullable(), + invitationSentAt: z.date().optional().nullable(), + appointmentAt: z.date().optional().nullable(), + decisionAt: z.date().optional().nullable(), + notes: z.string().max(2000).optional().nullable(), + }), + ) + .mutation(async ({ ctx, input }) => { + const existing = await ctx.prisma.visaApplication.findUnique({ + where: { id: input.id }, + }) + if (!existing) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Visa application not found' }) + } + const data: Record = {} + if (input.status !== undefined) data.status = input.status + if (input.nationality !== undefined) data.nationality = input.nationality + if (input.invitationSentAt !== undefined) data.invitationSentAt = input.invitationSentAt + if (input.appointmentAt !== undefined) data.appointmentAt = input.appointmentAt + if (input.decisionAt !== undefined) data.decisionAt = input.decisionAt + if (input.notes !== undefined) data.notes = input.notes + const updated = await ctx.prisma.visaApplication.update({ + where: { id: input.id }, + data, + }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'VISA_UPDATE', + entityType: 'VisaApplication', + entityId: updated.id, + detailsJson: { + previous: { + status: existing.status, + nationality: existing.nationality, + invitationSentAt: existing.invitationSentAt, + appointmentAt: existing.appointmentAt, + decisionAt: existing.decisionAt, + }, + next: data, + }, + }) + return updated + }), + + /** + * Flip Program.visaStatusVisibleToMembers. Controls whether the team can + * see their own visa status on the applicant dashboard. + */ + setVisaVisibility: adminProcedure + .input(z.object({ programId: z.string(), visible: z.boolean() })) + .mutation(async ({ ctx, input }) => { + const program = await ctx.prisma.program.update({ + where: { id: input.programId }, + data: { visaStatusVisibleToMembers: input.visible }, + }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'VISA_VISIBILITY_SET', + entityType: 'Program', + entityId: program.id, + detailsJson: { visible: input.visible }, + }) + return { visible: program.visaStatusVisibleToMembers } + }), }) diff --git a/tests/unit/visa-admin.test.ts b/tests/unit/visa-admin.test.ts new file mode 100644 index 0000000..0696b95 --- /dev/null +++ b/tests/unit/visa-admin.test.ts @@ -0,0 +1,222 @@ +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' + +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 setupConfirmedAttendee(programName: string) { + const program = await createTestProgram({ name: programName }) + 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: true }, + }) + const app = await prisma.visaApplication.create({ + data: { attendingMemberId: attendee.id, status: 'REQUESTED' }, + }) + return { program, project, user, confirmation, attendee, app } +} + +describe('logistics.listVisaApplications', () => { + 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 rows for the program sorted by status priority', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const { program, project, user, confirmation, app } = await setupConfirmedAttendee( + `visa-list-${uid()}`, + ) + programIds.push(program.id) + userIds.push(user.id) + + // add two more attendees with different statuses + const u2 = await createApplicant() + const u3 = await createApplicant() + userIds.push(u2.id, u3.id) + await prisma.teamMember.createMany({ + data: [ + { projectId: project.id, userId: u2.id, role: 'MEMBER' }, + { projectId: project.id, userId: u3.id, role: 'MEMBER' }, + ], + }) + const a2 = await prisma.attendingMember.create({ + data: { confirmationId: confirmation.id, userId: u2.id, needsVisa: true }, + }) + const a3 = await prisma.attendingMember.create({ + data: { confirmationId: confirmation.id, userId: u3.id, needsVisa: true }, + }) + await prisma.visaApplication.createMany({ + data: [ + { attendingMemberId: a2.id, status: 'GRANTED' }, + { attendingMemberId: a3.id, status: 'APPOINTMENT_BOOKED' }, + ], + }) + + const caller = createCaller(logisticsRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const result = await caller.listVisaApplications({ programId: program.id }) + + expect(result).toHaveLength(3) + // REQUESTED (0) → APPOINTMENT_BOOKED (2) → GRANTED (3) + expect(result.map((r) => r.status)).toEqual(['REQUESTED', 'APPOINTMENT_BOOKED', 'GRANTED']) + expect(result[0].id).toBe(app.id) + }) +}) + +describe('logistics.updateVisaApplication', () => { + 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('updates status, dates, notes, nationality and audit-logs', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const { program, user, app } = await setupConfirmedAttendee(`visa-update-${uid()}`) + programIds.push(program.id) + userIds.push(user.id) + + const caller = createCaller(logisticsRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const apptDate = new Date('2026-06-15T10:00:00Z') + const updated = await caller.updateVisaApplication({ + id: app.id, + status: 'APPOINTMENT_BOOKED', + nationality: 'Brazilian', + appointmentAt: apptDate, + notes: 'Embassy in Rio confirmed', + }) + + expect(updated.status).toBe('APPOINTMENT_BOOKED') + expect(updated.nationality).toBe('Brazilian') + expect(updated.appointmentAt?.toISOString()).toBe(apptDate.toISOString()) + expect(updated.notes).toContain('Rio') + + const audit = await prisma.auditLog.findFirst({ + where: { action: 'VISA_UPDATE', entityId: app.id }, + }) + expect(audit).not.toBeNull() + }) + + it('rejects an unknown application id', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + + const caller = createCaller(logisticsRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + await expect( + caller.updateVisaApplication({ + id: 'nonexistent_id_xxx', + status: 'GRANTED', + }), + ).rejects.toThrow(/not found/i) + }) +}) + +describe('logistics.setVisaVisibility', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('flips Program.visaStatusVisibleToMembers', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `visa-vis-${uid()}` }) + programIds.push(program.id) + expect(program.visaStatusVisibleToMembers).toBe(true) + + const caller = createCaller(logisticsRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const result = await caller.setVisaVisibility({ programId: program.id, visible: false }) + expect(result.visible).toBe(false) + + const refreshed = await prisma.program.findUniqueOrThrow({ where: { id: program.id } }) + expect(refreshed.visaStatusVisibleToMembers).toBe(false) + }) +})