Files
MOPC-Portal/src/server/routers/logistics.ts

156 lines
4.9 KiB
TypeScript
Raw Normal View History

2026-04-28 18:18:16 +02:00
import { z } from 'zod'
import { FlightDetailStatus } from '@prisma/client'
2026-04-28 18:18:16 +02:00
import { router, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
export const logisticsRouter = router({
/** Read the hotel for a program (1:1). Null if not yet set. */
getHotel: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.hotel.findUnique({ where: { programId: input.programId } })
}),
/** Create or update the program's hotel. Empty link strings are stored as null. */
upsertHotel: adminProcedure
.input(
z.object({
programId: z.string(),
name: z.string().min(1).max(200),
address: z.string().max(500).optional(),
link: z
.string()
.url()
.or(z.literal(''))
.optional(),
notes: z.string().max(2000).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const link = input.link && input.link.trim().length > 0 ? input.link : null
const hotel = await ctx.prisma.hotel.upsert({
where: { programId: input.programId },
create: {
programId: input.programId,
name: input.name,
address: input.address ?? null,
link,
notes: input.notes ?? null,
},
update: {
name: input.name,
address: input.address ?? null,
link,
notes: input.notes ?? null,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_UPSERT',
entityType: 'Hotel',
entityId: hotel.id,
detailsJson: { programId: input.programId, name: input.name },
})
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<string, unknown> = {}
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
}),
2026-04-28 18:18:16 +02:00
})