From b1e6eb81ebbc56c051e1b6f5edb4cf59af1e2a23 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 18:19:39 +0200 Subject: [PATCH] feat: flight-detail CRUD on logistics router --- src/server/routers/logistics.ts | 99 +++++++++++++ tests/unit/logistics-flight.test.ts | 209 ++++++++++++++++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 tests/unit/logistics-flight.test.ts diff --git a/src/server/routers/logistics.ts b/src/server/routers/logistics.ts index b00b484..5f2f7a5 100644 --- a/src/server/routers/logistics.ts +++ b/src/server/routers/logistics.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { FlightDetailStatus } from '@prisma/client' import { router, adminProcedure } from '../trpc' import { logAudit } from '../utils/audit' @@ -53,4 +54,102 @@ export const logisticsRouter = router({ }) return hotel }), + + /** + * List all attending members for CONFIRMED finalists in a program, with + * their (optional) flight details. One row per attendee — even those + * without a FlightDetail row yet, so the UI can render empty editors. + */ + listFlightDetails: adminProcedure + .input(z.object({ programId: z.string() })) + .query(async ({ ctx, input }) => { + return ctx.prisma.attendingMember.findMany({ + where: { + confirmation: { + status: 'CONFIRMED', + project: { programId: input.programId }, + }, + }, + select: { + id: true, + needsVisa: true, + user: { select: { id: true, name: true, email: true, country: true } }, + confirmation: { + select: { + project: { + select: { + id: true, + title: true, + country: true, + competitionCategory: true, + }, + }, + }, + }, + flightDetail: true, + }, + orderBy: [{ user: { name: 'asc' } }], + }) + }), + + /** Create or update a flight detail row for an attending member. */ + upsertFlightDetail: adminProcedure + .input( + z.object({ + attendingMemberId: z.string(), + arrivalAt: z.date().nullable().optional(), + arrivalFlightNumber: z.string().max(20).nullable().optional(), + arrivalAirport: z.string().max(10).nullable().optional(), + departureAt: z.date().nullable().optional(), + departureFlightNumber: z.string().max(20).nullable().optional(), + departureAirport: z.string().max(10).nullable().optional(), + adminNotes: z.string().max(1000).nullable().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { attendingMemberId, ...rest } = input + // Strip out undefineds so an upsert update doesn't blow away unset fields. + const data: Record = {} + for (const [k, v] of Object.entries(rest)) { + if (v !== undefined) data[k] = v + } + const detail = await ctx.prisma.flightDetail.upsert({ + where: { attendingMemberId }, + create: { attendingMemberId, ...(data as object) }, + update: data, + }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'FLIGHT_DETAIL_UPSERT', + entityType: 'FlightDetail', + entityId: detail.id, + detailsJson: { attendingMemberId }, + }) + return detail + }), + + /** Toggle PENDING ↔ CONFIRMED on a flight detail. */ + setFlightStatus: adminProcedure + .input( + z.object({ + flightDetailId: z.string(), + status: z.nativeEnum(FlightDetailStatus), + }), + ) + .mutation(async ({ ctx, input }) => { + const detail = await ctx.prisma.flightDetail.update({ + where: { id: input.flightDetailId }, + data: { status: input.status }, + }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'FLIGHT_STATUS_SET', + entityType: 'FlightDetail', + entityId: detail.id, + detailsJson: { status: input.status }, + }) + return detail + }), }) diff --git a/tests/unit/logistics-flight.test.ts b/tests/unit/logistics-flight.test.ts new file mode 100644 index 0000000..e10bb1a --- /dev/null +++ b/tests/unit/logistics-flight.test.ts @@ -0,0 +1,209 @@ +import { afterAll, beforeAll, 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' + +beforeAll(() => { + process.env.NEXTAUTH_SECRET = 'test-secret-for-finalist-tokens' +}) + +async function setupConfirmedFinalist(programName: string) { + const program = await createTestProgram({ name: programName }) + const lead = await prisma.user.create({ + data: { + id: uid('user'), + email: `lead_${uid()}@test.local`, + name: 'Test Lead', + role: 'APPLICANT', + roles: ['APPLICANT'], + status: 'ACTIVE', + }, + }) + const project = await createTestProject(program.id, { + title: 'Confirmed Finalist', + competitionCategory: 'STARTUP', + }) + await prisma.teamMember.create({ + data: { projectId: project.id, userId: lead.id, role: 'LEAD' }, + }) + const confirmation = await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: 'CONFIRMED', + deadline: new Date(Date.now() + 86400000), + token: `tok_${uid()}`, + confirmedAt: new Date(), + }, + }) + const attendingMember = await prisma.attendingMember.create({ + data: { confirmationId: confirmation.id, userId: lead.id, needsVisa: false }, + }) + return { program, lead, project, confirmation, attendingMember } +} + +describe('logistics flight detail procedures', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await prisma.flightDetail.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('listFlightDetails returns one row per AttendingMember of CONFIRMED finalists', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const { program, lead, attendingMember } = await setupConfirmedFinalist( + `flight-list-${uid()}`, + ) + programIds.push(program.id) + userIds.push(lead.id) + + const caller = createCaller(logisticsRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const rows = await caller.listFlightDetails({ programId: program.id }) + expect(rows).toHaveLength(1) + expect(rows[0].id).toBe(attendingMember.id) + expect(rows[0].flightDetail).toBeNull() // no flight detail yet + expect(rows[0].user.email).toBe(lead.email) + expect(rows[0].confirmation.project.title).toBe('Confirmed Finalist') + }) + + it('listFlightDetails excludes attendees of PENDING / DECLINED confirmations', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `flight-exclude-${uid()}` }) + programIds.push(program.id) + const lead = await prisma.user.create({ + data: { + id: uid('user'), + email: `pending_${uid()}@test.local`, + name: 'Pending Lead', + role: 'APPLICANT', + roles: ['APPLICANT'], + status: 'ACTIVE', + }, + }) + userIds.push(lead.id) + const project = await createTestProject(program.id, { + title: 'Pending Project', + competitionCategory: 'STARTUP', + }) + await prisma.teamMember.create({ + data: { projectId: project.id, userId: lead.id, role: 'LEAD' }, + }) + const confirmation = await prisma.finalistConfirmation.create({ + data: { + projectId: project.id, + category: 'STARTUP', + status: 'PENDING', + deadline: new Date(Date.now() + 86400000), + token: `tok_${uid()}`, + }, + }) + // Even though there's an AttendingMember, it shouldn't be returned because confirmation is PENDING + await prisma.attendingMember.create({ + data: { confirmationId: confirmation.id, userId: lead.id }, + }) + + const caller = createCaller(logisticsRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const rows = await caller.listFlightDetails({ programId: program.id }) + expect(rows).toHaveLength(0) + }) + + it('upsertFlightDetail creates then updates', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const { program, lead, attendingMember } = await setupConfirmedFinalist( + `flight-upsert-${uid()}`, + ) + programIds.push(program.id) + userIds.push(lead.id) + + const caller = createCaller(logisticsRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const arrivalDate = new Date('2026-06-28T12:00:00Z') + const created = await caller.upsertFlightDetail({ + attendingMemberId: attendingMember.id, + arrivalAt: arrivalDate, + arrivalFlightNumber: 'AF7400', + arrivalAirport: 'NCE', + }) + expect(created.arrivalFlightNumber).toBe('AF7400') + expect(created.status).toBe('PENDING') + + const updated = await caller.upsertFlightDetail({ + attendingMemberId: attendingMember.id, + arrivalFlightNumber: 'AF7402', + arrivalAirport: 'NCE', + }) + expect(updated.id).toBe(created.id) // same row + expect(updated.arrivalFlightNumber).toBe('AF7402') + // Still 1:1 + const count = await prisma.flightDetail.count({ + where: { attendingMemberId: attendingMember.id }, + }) + expect(count).toBe(1) + }) + + it('setFlightStatus toggles PENDING ↔ CONFIRMED', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const { program, lead, attendingMember } = await setupConfirmedFinalist( + `flight-status-${uid()}`, + ) + programIds.push(program.id) + userIds.push(lead.id) + + const caller = createCaller(logisticsRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const detail = await caller.upsertFlightDetail({ + attendingMemberId: attendingMember.id, + arrivalFlightNumber: 'AF7400', + }) + expect(detail.status).toBe('PENDING') + + const confirmed = await caller.setFlightStatus({ + flightDetailId: detail.id, + status: 'CONFIRMED', + }) + expect(confirmed.status).toBe('CONFIRMED') + + const reverted = await caller.setFlightStatus({ + flightDetailId: detail.id, + status: 'PENDING', + }) + expect(reverted.status).toBe('PENDING') + }) +})