From 97951deb689d88354e52826a58026118fb228e4d Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 4 Jun 2026 16:56:22 +0200 Subject: [PATCH] feat(logistics): departure-after-arrival validation + travel/visa CSV export - upsertFlightDetail throws BAD_REQUEST when departureAt < arrivalAt - Travel tab: Download CSV button (project/attendee/email/flight fields/status/visa) - Visas tab: Download CSV button (project/attendee/nationality/status/dates/notes) - TDD: 2 new tests (rejects invalid, accepts valid); all 6 flight tests pass Co-Authored-By: Claude Sonnet 4.6 --- src/components/admin/logistics/travel-tab.tsx | 65 ++++++++++++++- src/components/admin/logistics/visas-tab.tsx | 83 +++++++++++++++++-- src/server/routers/logistics.ts | 6 ++ tests/unit/logistics-flight.test.ts | 58 +++++++++++++ 4 files changed, 203 insertions(+), 9 deletions(-) diff --git a/src/components/admin/logistics/travel-tab.tsx b/src/components/admin/logistics/travel-tab.tsx index 188f294..0e880c9 100644 --- a/src/components/admin/logistics/travel-tab.tsx +++ b/src/components/admin/logistics/travel-tab.tsx @@ -26,7 +26,7 @@ import { TableHeader, TableRow, } from '@/components/ui/table' -import { Loader2, Plane } from 'lucide-react' +import { Download, Loader2, Plane } from 'lucide-react' import type { FlightDetailStatus } from '@prisma/client' interface Props { @@ -240,6 +240,47 @@ function FlightEditorSheet({ ) } +function csvEscape(value: string | null | undefined): string { + const str = value ?? '' + if (str.includes('"') || str.includes(',') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"` + } + return str +} + +function buildTravelCsv(rows: AttendeeRow[]): string { + const header = [ + 'Project', + 'Attendee', + 'Email', + 'Arrival date/time', + 'Arrival flight', + 'Arrival airport', + 'Departure date/time', + 'Departure flight', + 'Departure airport', + 'Status', + 'Needs visa', + ].join(',') + const lines = rows.map((r) => { + const fd = r.flightDetail + return [ + csvEscape(r.confirmation.project.title), + csvEscape(r.user.name ?? r.user.email), + csvEscape(r.user.email), + csvEscape(fd?.arrivalAt ? new Date(fd.arrivalAt).toLocaleString() : ''), + csvEscape(fd?.arrivalFlightNumber), + csvEscape(fd?.arrivalAirport), + csvEscape(fd?.departureAt ? new Date(fd.departureAt).toLocaleString() : ''), + csvEscape(fd?.departureFlightNumber), + csvEscape(fd?.departureAirport), + csvEscape(fd?.status ?? ''), + r.needsVisa ? 'Yes' : 'No', + ].join(',') + }) + return [header, ...lines].join('\r\n') +} + export function TravelTab({ programId }: Props) { const utils = trpc.useUtils() const [statusFilter, setStatusFilter] = useState('all') @@ -306,11 +347,31 @@ export function TravelTab({ programId }: Props) { Travel for confirmed finalists -
+
+
diff --git a/src/components/admin/logistics/visas-tab.tsx b/src/components/admin/logistics/visas-tab.tsx index 97744cc..e75989e 100644 --- a/src/components/admin/logistics/visas-tab.tsx +++ b/src/components/admin/logistics/visas-tab.tsx @@ -15,7 +15,7 @@ import { TableHeader, TableRow, } from '@/components/ui/table' -import { Settings as SettingsIcon, ShieldOff } from 'lucide-react' +import { Download, Settings as SettingsIcon, ShieldOff } from 'lucide-react' import { VisaEditDialog, type VisaEditTarget } from './visa-edit-dialog' import type { VisaStatus } from '@prisma/client' @@ -56,6 +56,53 @@ function nextDate(row: { return { label: '—', date: null } } +function csvEscape(value: string | null | undefined): string { + const str = value ?? '' + if (str.includes('"') || str.includes(',') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"` + } + return str +} + +type VisaRow = { + status: VisaStatus + nationality: string | null + invitationSentAt: Date | null + appointmentAt: Date | null + decisionAt: Date | null + notes: string | null + project: { id: string; title: string } + attendee: { id: string; user: { id: string; name: string | null; email: string } } +} + +function buildVisaCsv(rows: VisaRow[]): string { + const header = [ + 'Project', + 'Attendee', + 'Email', + 'Nationality', + 'Status', + 'Invitation sent', + 'Appointment', + 'Decision', + 'Notes', + ].join(',') + const lines = rows.map((r) => { + return [ + csvEscape(r.project.title), + csvEscape(r.attendee.user.name ?? r.attendee.user.email), + csvEscape(r.attendee.user.email), + csvEscape(r.nationality), + csvEscape(r.status), + csvEscape(r.invitationSentAt ? new Date(r.invitationSentAt).toLocaleDateString() : ''), + csvEscape(r.appointmentAt ? new Date(r.appointmentAt).toLocaleDateString() : ''), + csvEscape(r.decisionAt ? new Date(r.decisionAt).toLocaleDateString() : ''), + csvEscape(r.notes), + ].join(',') + }) + return [header, ...lines].join('\r\n') +} + export function VisasTab({ programId }: Props) { const [statusFilter, setStatusFilter] = useState('all') const [editTarget, setEditTarget] = useState(null) @@ -117,12 +164,34 @@ export function VisasTab({ programId }: Props) { continue to flow over email and are never stored on this platform.

- +
+ + +
diff --git a/src/server/routers/logistics.ts b/src/server/routers/logistics.ts index 7ca7515..fd37f6f 100644 --- a/src/server/routers/logistics.ts +++ b/src/server/routers/logistics.ts @@ -162,6 +162,12 @@ export const logisticsRouter = router({ for (const [k, v] of Object.entries(rest)) { if (v !== undefined) data[k] = v } + if (input.arrivalAt && input.departureAt && input.departureAt < input.arrivalAt) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Departure must be after arrival', + }) + } const detail = await ctx.prisma.flightDetail.upsert({ where: { attendingMemberId }, create: { attendingMemberId, ...(data as object) }, diff --git a/tests/unit/logistics-flight.test.ts b/tests/unit/logistics-flight.test.ts index e10bb1a..04471eb 100644 --- a/tests/unit/logistics-flight.test.ts +++ b/tests/unit/logistics-flight.test.ts @@ -174,6 +174,64 @@ describe('logistics flight detail procedures', () => { expect(count).toBe(1) }) + it('upsertFlightDetail rejects when departureAt < arrivalAt', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const { program, lead, attendingMember } = await setupConfirmedFinalist( + `flight-dep-before-arr-${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-28T14:00:00Z') + const departureBeforeArrival = new Date('2026-06-28T10:00:00Z') // 4 hours earlier + + await expect( + caller.upsertFlightDetail({ + attendingMemberId: attendingMember.id, + arrivalAt: arrivalDate, + departureAt: departureBeforeArrival, + arrivalFlightNumber: 'AF7400', + departureFlightNumber: 'AF7405', + }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST', message: 'Departure must be after arrival' }) + }) + + it('upsertFlightDetail succeeds when departureAt >= arrivalAt', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const { program, lead, attendingMember } = await setupConfirmedFinalist( + `flight-dep-after-arr-${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-28T10:00:00Z') + const departureAfterArrival = new Date('2026-06-30T14:00:00Z') // 2 days later + + const result = await caller.upsertFlightDetail({ + attendingMemberId: attendingMember.id, + arrivalAt: arrivalDate, + departureAt: departureAfterArrival, + arrivalFlightNumber: 'AF7400', + departureFlightNumber: 'AF7405', + }) + expect(result.arrivalFlightNumber).toBe('AF7400') + expect(result.departureFlightNumber).toBe('AF7405') + }) + it('setFlightStatus toggles PENDING ↔ CONFIRMED', async () => { const admin = await createTestUser('SUPER_ADMIN') userIds.push(admin.id)