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(