import { z } from 'zod' import { FlightDetailStatus, VisaStatus } from '@prisma/client' import { TRPCError } from '@trpc/server' 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 }), /** * Read-only listing of every FinalistConfirmation in a program, with the * joined project + attendee count + decline reason. Sorted by status * priority (PENDING first) then deadline ascending so the most urgent * decisions surface at the top of the table. */ listConfirmations: adminProcedure .input(z.object({ programId: z.string() })) .query(async ({ ctx, input }) => { const rows = await ctx.prisma.finalistConfirmation.findMany({ where: { project: { programId: input.programId } }, include: { project: { select: { id: true, title: true, competitionCategory: true, country: true }, }, _count: { select: { attendingMembers: true } }, }, }) const STATUS_PRIORITY: Record = { PENDING: 0, CONFIRMED: 1, DECLINED: 2, EXPIRED: 3, SUPERSEDED: 4, } return rows .map((r) => ({ id: r.id, status: r.status, deadline: r.deadline, confirmedAt: r.confirmedAt, declinedAt: r.declinedAt, declineReason: r.declineReason, expiredAt: r.expiredAt, category: r.category, promotedFromWaitlistEntryId: r.promotedFromWaitlistEntryId, project: r.project, attendeeCount: r._count.attendingMembers, })) .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.deadline.getTime() - b.deadline.getTime() }) }), /** * 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 }), /** * 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 }), /** Read Program.visaStatusVisibleToMembers — drives the admin Visas tab toggle. */ getVisaVisibility: adminProcedure .input(z.object({ programId: z.string() })) .query(async ({ ctx, input }) => { const program = await ctx.prisma.program.findUniqueOrThrow({ where: { id: input.programId }, select: { visaStatusVisibleToMembers: true }, }) return { visible: program.visaStatusVisibleToMembers } }), /** * 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 } }), })