Files
MOPC-Portal/src/server/routers/logistics.ts
Matt 4cd2651f9c feat(logistics): hotels CRUD + rooming + assignment procedures + travel email
Replace getHotel/upsertHotel with listHotels/createHotel/updateHotel/deleteHotel
(multi-hotel per edition). Add listRooming, assignStay, assignTeamToHotel, and
unassignStay procedures for per-attendee room assignments. Update setFlightStatus
to include attendee's HotelStay in TRAVEL_CONFIRMED notification metadata.
Extend getTravelConfirmedTemplate to render room number and check-in/out dates.
All procedures are adminProcedure and audit-logged. 10 new unit tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:19:18 +02:00

769 lines
28 KiB
TypeScript

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'
import { createNotification, NotificationTypes } from '../services/in-app-notification'
export const logisticsRouter = router({
// ─────────────────────────────────────────────────────────────────────────
// Hotels CRUD (multi-hotel per edition)
// ─────────────────────────────────────────────────────────────────────────
/** List all hotels for an edition, including occupancy count. */
listHotels: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.hotel.findMany({
where: { programId: input.programId },
include: { _count: { select: { stays: true } } },
orderBy: { name: 'asc' },
})
}),
/** Create a new hotel for the edition. Empty link strings are stored as null. */
createHotel: 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.create({
data: {
programId: input.programId,
name: input.name,
address: input.address ?? null,
link,
notes: input.notes ?? null,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_CREATE',
entityType: 'Hotel',
entityId: hotel.id,
detailsJson: { programId: input.programId, name: input.name },
})
return hotel
}),
/** Update an existing hotel's fields. Empty link strings are stored as null. */
updateHotel: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(200),
address: z.string().max(500).optional().nullable(),
link: z
.string()
.url()
.or(z.literal(''))
.optional()
.nullable(),
notes: z.string().max(2000).optional().nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
const link =
input.link !== undefined
? input.link && input.link.trim().length > 0
? input.link
: null
: undefined
const hotel = await ctx.prisma.hotel.update({
where: { id: input.id },
data: {
name: input.name,
...(input.address !== undefined ? { address: input.address } : {}),
...(link !== undefined ? { link } : {}),
...(input.notes !== undefined ? { notes: input.notes } : {}),
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_UPDATE',
entityType: 'Hotel',
entityId: hotel.id,
detailsJson: { id: input.id, name: input.name },
})
return hotel
}),
/**
* Delete a hotel. Rejected with BAD_REQUEST if any HotelStay rows reference it
* (the user must reassign occupants first).
*/
deleteHotel: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const occupantCount = await ctx.prisma.hotelStay.count({
where: { hotelId: input.id },
})
if (occupantCount > 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Reassign ${occupantCount} occupant(s) before deleting this hotel.`,
})
}
const hotel = await ctx.prisma.hotel.delete({ where: { id: input.id } })
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_DELETE',
entityType: 'Hotel',
entityId: hotel.id,
detailsJson: { id: input.id, name: hotel.name },
})
return { success: true }
}),
// ─────────────────────────────────────────────────────────────────────────
// Rooming — per-attendee hotel/room assignment
// ─────────────────────────────────────────────────────────────────────────
/**
* One row per CONFIRMED AttendingMember in the program.
* Each row includes their hotelStay (null when not yet assigned).
* Sorted by project title then user name.
*/
listRooming: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const rows = await ctx.prisma.attendingMember.findMany({
where: {
confirmation: {
status: 'CONFIRMED',
project: { programId: input.programId },
},
},
select: {
id: true,
user: { select: { id: true, name: true, email: true } },
confirmation: {
select: {
id: true,
project: { select: { id: true, title: true } },
},
},
hotelStay: {
select: { hotelId: true, roomNumber: true, checkInAt: true, checkOutAt: true },
},
},
orderBy: [
{ confirmation: { project: { title: 'asc' } } },
{ user: { name: 'asc' } },
],
})
return rows.map((r) => ({
attendingMemberId: r.id,
confirmationId: r.confirmation.id,
projectId: r.confirmation.project.id,
projectTitle: r.confirmation.project.title,
user: r.user,
stay: r.hotelStay ?? null,
}))
}),
/**
* Upsert an individual attendee's hotel/room assignment.
* Validates that the hotel belongs to the same program as the attendee.
*/
assignStay: adminProcedure
.input(
z.object({
attendingMemberId: z.string(),
hotelId: z.string(),
roomNumber: z.string().max(50).optional().nullable(),
checkInAt: z.date().optional().nullable(),
checkOutAt: z.date().optional().nullable(),
notes: z.string().max(2000).optional().nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
// Validate hotel belongs to the same program as the attendee
const [attendee, hotel] = await Promise.all([
ctx.prisma.attendingMember.findUnique({
where: { id: input.attendingMemberId },
select: {
confirmation: { select: { project: { select: { programId: true } } } },
},
}),
ctx.prisma.hotel.findUnique({
where: { id: input.hotelId },
select: { programId: true },
}),
])
if (!attendee || !hotel) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Attendee or hotel not found.' })
}
if (attendee.confirmation.project.programId !== hotel.programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Hotel does not belong to the same program as the attendee.',
})
}
const data: Record<string, unknown> = {
hotelId: input.hotelId,
}
if (input.roomNumber !== undefined) data.roomNumber = input.roomNumber
if (input.checkInAt !== undefined) data.checkInAt = input.checkInAt
if (input.checkOutAt !== undefined) data.checkOutAt = input.checkOutAt
if (input.notes !== undefined) data.notes = input.notes
const stay = await ctx.prisma.hotelStay.upsert({
where: { attendingMemberId: input.attendingMemberId },
create: {
attendingMemberId: input.attendingMemberId,
hotelId: input.hotelId,
roomNumber: input.roomNumber ?? null,
checkInAt: input.checkInAt ?? null,
checkOutAt: input.checkOutAt ?? null,
notes: input.notes ?? null,
},
update: data,
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_STAY_ASSIGN',
entityType: 'HotelStay',
entityId: stay.id,
detailsJson: { attendingMemberId: input.attendingMemberId, hotelId: input.hotelId },
})
return stay
}),
/**
* Assign all AttendingMembers of a confirmation to a hotel (the "whole team" shortcut).
* Preserves each member's existing roomNumber on update; sets it to null on create.
* Validates the hotel belongs to the same program as the confirmation's project.
*/
assignTeamToHotel: adminProcedure
.input(
z.object({
confirmationId: z.string(),
hotelId: z.string(),
checkInAt: z.date().optional().nullable(),
checkOutAt: z.date().optional().nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
// Validate program match
const [confirmation, hotel] = await Promise.all([
ctx.prisma.finalistConfirmation.findUnique({
where: { id: input.confirmationId },
select: {
project: { select: { programId: true } },
attendingMembers: { select: { id: true } },
},
}),
ctx.prisma.hotel.findUnique({
where: { id: input.hotelId },
select: { programId: true },
}),
])
if (!confirmation || !hotel) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Confirmation or hotel not found.' })
}
if (confirmation.project.programId !== hotel.programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Hotel does not belong to the same program as this confirmation.',
})
}
// Upsert each attendee's stay, preserving existing roomNumber
const results = await Promise.all(
confirmation.attendingMembers.map(async (member) => {
const existing = await ctx.prisma.hotelStay.findUnique({
where: { attendingMemberId: member.id },
select: { roomNumber: true },
})
return ctx.prisma.hotelStay.upsert({
where: { attendingMemberId: member.id },
create: {
attendingMemberId: member.id,
hotelId: input.hotelId,
roomNumber: null,
checkInAt: input.checkInAt ?? null,
checkOutAt: input.checkOutAt ?? null,
},
update: {
hotelId: input.hotelId,
...(input.checkInAt !== undefined ? { checkInAt: input.checkInAt } : {}),
...(input.checkOutAt !== undefined ? { checkOutAt: input.checkOutAt } : {}),
// Preserve existing roomNumber (do not overwrite it)
...(existing?.roomNumber !== undefined ? {} : {}),
},
})
}),
)
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_TEAM_ASSIGN',
entityType: 'FinalistConfirmation',
entityId: input.confirmationId,
detailsJson: {
confirmationId: input.confirmationId,
hotelId: input.hotelId,
count: results.length,
},
})
return { count: results.length }
}),
/** Remove an attendee's hotel/room assignment. No-op safe (deleteMany). */
unassignStay: adminProcedure
.input(z.object({ attendingMemberId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.hotelStay.deleteMany({
where: { attendingMemberId: input.attendingMemberId },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'HOTEL_STAY_UNASSIGN',
entityType: 'HotelStay',
entityId: input.attendingMemberId,
detailsJson: { attendingMemberId: input.attendingMemberId },
})
return { success: true }
}),
// ─────────────────────────────────────────────────────────────────────────
// Confirmations
// ─────────────────────────────────────────────────────────────────────────
/**
* 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<string, number> = {
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()
})
}),
// ─────────────────────────────────────────────────────────────────────────
// Flight details
// ─────────────────────────────────────────────────────────────────────────
/**
* 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
}
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) },
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 },
})
// Send travel-confirmed notification to the attendee (best-effort — never throws)
if (input.status === 'CONFIRMED') {
try {
const attendee = await ctx.prisma.attendingMember.findUnique({
where: { id: detail.attendingMemberId },
select: {
userId: true,
hotelStay: {
include: { hotel: { select: { name: true, address: true, link: true } } },
},
confirmation: {
select: {
project: {
select: {
title: true,
},
},
},
},
},
})
if (attendee) {
const projectTitle = attendee.confirmation.project.title
const hotelStay = attendee.hotelStay
await createNotification({
userId: attendee.userId,
type: NotificationTypes.TRAVEL_CONFIRMED,
title: 'Your grand-finale travel is confirmed',
message: 'Your flight details for the grand finale are confirmed.',
linkUrl: '/applicant',
metadata: {
projectTitle,
arrivalAt: detail.arrivalAt?.toISOString() ?? null,
arrivalFlightNumber: detail.arrivalFlightNumber ?? null,
arrivalAirport: detail.arrivalAirport ?? null,
departureAt: detail.departureAt?.toISOString() ?? null,
departureFlightNumber: detail.departureFlightNumber ?? null,
departureAirport: detail.departureAirport ?? null,
hotel: hotelStay?.hotel
? {
name: hotelStay.hotel.name,
address: hotelStay.hotel.address ?? null,
link: hotelStay.hotel.link ?? null,
}
: null,
roomNumber: hotelStay?.roomNumber ?? null,
checkInAt: hotelStay?.checkInAt?.toISOString() ?? null,
checkOutAt: hotelStay?.checkOutAt?.toISOString() ?? null,
},
})
}
} catch (err) {
console.error('[setFlightStatus] Failed to send TRAVEL_CONFIRMED notification:', err)
}
}
return detail
}),
// ─────────────────────────────────────────────────────────────────────────
// Visa applications
// ─────────────────────────────────────────────────────────────────────────
/**
* 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<VisaStatus, number> = {
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 },
include: {
attendingMember: {
select: {
userId: true,
confirmation: {
select: {
project: { select: { title: true } },
},
},
},
},
},
})
if (!existing) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Visa application not found' })
}
const data: Record<string, unknown> = {}
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,
},
})
// Send visa-status notification to the attendee when a notifiable status changes (best-effort)
const NOTIFIABLE_STATUSES = new Set<VisaStatus>([
'INVITATION_SENT',
'APPOINTMENT_BOOKED',
'GRANTED',
'DENIED',
])
if (
input.status !== undefined &&
input.status !== existing.status &&
NOTIFIABLE_STATUSES.has(input.status)
) {
try {
const projectTitle = existing.attendingMember.confirmation.project.title
const statusMessages: Record<string, string> = {
INVITATION_SENT: 'Your visa invitation letter for the Grand Finale has been sent.',
APPOINTMENT_BOOKED: 'A visa appointment has been booked for you.',
GRANTED: 'Congratulations — your visa for the Grand Finale has been granted!',
DENIED: 'Your visa application outcome is now available. Please contact the MOPC team.',
}
await createNotification({
userId: existing.attendingMember.userId,
type: NotificationTypes.VISA_STATUS_UPDATE,
title: 'Visa update for the grand finale',
message: statusMessages[input.status] ?? 'Your visa status has been updated.',
linkUrl: '/applicant',
metadata: {
projectTitle,
status: input.status,
},
})
} catch (err) {
console.error('[updateVisaApplication] Failed to send VISA_STATUS_UPDATE notification:', err)
}
}
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 }
}),
})