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)