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>
This commit is contained in:
Matt
2026-06-04 19:19:18 +02:00
parent 75e63eb47f
commit 4cd2651f9c
3 changed files with 843 additions and 24 deletions

View File

@@ -2297,6 +2297,11 @@ function getTravelConfirmedTemplate(
address?: string | null
link?: string | null
},
room?: {
roomNumber?: string | null
checkInAt?: string | null
checkOutAt?: string | null
},
): EmailTemplate {
const greeting = name ? `Hi ${name},` : 'Hi there,'
@@ -2333,18 +2338,38 @@ function getTravelConfirmedTemplate(
`</ul>`
: ''
const fmtDateShort = (d: string | null | undefined) => {
if (!d) return null
const dt = new Date(d)
return dt.toLocaleString('en-GB', { timeZone: 'Europe/Paris', dateStyle: 'long', timeStyle: 'short' })
}
const roomLines: string[] = []
if (room?.roomNumber) roomLines.push(`Room: ${room.roomNumber}`)
if (room?.checkInAt) roomLines.push(`Check-in: ${fmtDateShort(room.checkInAt)} (Paris time)`)
if (room?.checkOutAt) roomLines.push(`Check-out: ${fmtDateShort(room.checkOutAt)} (Paris time)`)
const roomHtml =
roomLines.length > 0
? `<ul style="margin:8px 0 0;padding-left:20px;color:${BRAND.textDark};font-size:14px;">` +
roomLines.map((l) => `<li style="margin:4px 0;">${escapeHtml(l)}</li>`).join('') +
`</ul>`
: ''
const hotelHtml = hotel
? `<h3 style="margin:20px 0 8px;color:#0f172a;font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:1px;">Hotel</h3>` +
infoBox(
`<strong>${escapeHtml(hotel.name)}</strong>` +
(hotel.address ? `<br>${escapeHtml(hotel.address)}` : '') +
(hotel.link ? `<br><a href="${hotel.link}" style="color:${BRAND.darkBlue};">View hotel</a>` : ''),
(hotel.link ? `<br><a href="${hotel.link}" style="color:${BRAND.darkBlue};">View hotel</a>` : '') +
roomHtml,
'info',
)
: ''
const roomTextLines = roomLines.length > 0 ? '\n' + roomLines.map((l) => ` ${l}`).join('\n') : ''
const hotelText = hotel
? ['\nHotel:', ` ${hotel.name}`, ...(hotel.address ? [` ${hotel.address}`] : []), ...(hotel.link ? [` ${hotel.link}`] : [])].join('\n')
? ['\nHotel:', ` ${hotel.name}`, ...(hotel.address ? [` ${hotel.address}`] : []), ...(hotel.link ? [` ${hotel.link}`] : []), roomTextLines].join('\n')
: ''
const content = `
@@ -2679,6 +2704,13 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
departureAirport: ctx.metadata?.departureAirport as string | undefined,
},
ctx.metadata?.hotel as { name: string; address?: string; link?: string } | undefined,
ctx.metadata?.roomNumber !== undefined || ctx.metadata?.checkInAt !== undefined || ctx.metadata?.checkOutAt !== undefined
? {
roomNumber: ctx.metadata?.roomNumber as string | null | undefined,
checkInAt: ctx.metadata?.checkInAt as string | null | undefined,
checkOutAt: ctx.metadata?.checkOutAt as string | null | undefined,
}
: undefined,
),
VISA_STATUS_UPDATE: (ctx) =>
getVisaStatusTemplate(

View File

@@ -6,15 +6,23 @@ import { logAudit } from '../utils/audit'
import { createNotification, NotificationTypes } from '../services/in-app-notification'
export const logisticsRouter = router({
/** Read the hotel for a program (1:1). Null if not yet set. */
getHotel: adminProcedure
// ─────────────────────────────────────────────────────────────────────────
// 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.findUnique({ where: { programId: input.programId } })
return ctx.prisma.hotel.findMany({
where: { programId: input.programId },
include: { _count: { select: { stays: true } } },
orderBy: { name: 'asc' },
})
}),
/** Create or update the program's hotel. Empty link strings are stored as null. */
upsertHotel: adminProcedure
/** Create a new hotel for the edition. Empty link strings are stored as null. */
createHotel: adminProcedure
.input(
z.object({
programId: z.string(),
@@ -30,26 +38,19 @@ export const logisticsRouter = router({
)
.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: {
const hotel = await ctx.prisma.hotel.create({
data: {
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',
action: 'HOTEL_CREATE',
entityType: 'Hotel',
entityId: hotel.id,
detailsJson: { programId: input.programId, name: input.name },
@@ -57,6 +58,301 @@ export const logisticsRouter = router({
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
@@ -104,6 +400,10 @@ export const logisticsRouter = router({
})
}),
// ─────────────────────────────────────────────────────────────────────────
// Flight details
// ─────────────────────────────────────────────────────────────────────────
/**
* List all attending members for CONFIRMED finalists in a program, with
* their (optional) flight details. One row per attendee — even those
@@ -213,12 +513,14 @@ export const logisticsRouter = router({
where: { id: detail.attendingMemberId },
select: {
userId: true,
hotelStay: {
include: { hotel: { select: { name: true, address: true, link: true } } },
},
confirmation: {
select: {
project: {
select: {
title: true,
programId: true,
},
},
},
@@ -227,11 +529,7 @@ export const logisticsRouter = router({
})
if (attendee) {
const projectTitle = attendee.confirmation.project.title
const programId = attendee.confirmation.project.programId
const hotel = await ctx.prisma.hotel.findUnique({
where: { programId },
select: { name: true, address: true, link: true },
})
const hotelStay = attendee.hotelStay
await createNotification({
userId: attendee.userId,
type: NotificationTypes.TRAVEL_CONFIRMED,
@@ -246,7 +544,16 @@ export const logisticsRouter = router({
departureAt: detail.departureAt?.toISOString() ?? null,
departureFlightNumber: detail.departureFlightNumber ?? null,
departureAirport: detail.departureAirport ?? null,
hotel: hotel ?? undefined,
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,
},
})
}
@@ -258,6 +565,10 @@ export const logisticsRouter = router({
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.