/** * Builds the two passenger-facing values the `booking.created` SMS/email template needs: * the `{{passengerName}}` salutation and the `{{trainSeatLines}}` block. * * Why this is a shared pure helper rather than inline logic: the salutation used to be * `seats[0]?.passengerName`, and the query loading those seats had no `orderBy`. Postgres * returns heap order for an unordered SELECT, and an UPDATE relocates a row to the end of * the heap — so a group booking regularly greeted the LAST passenger while texting the * first one's phone. Deriving both values from the whole seat set, sorted deterministically, * removes the dependency on row order entirely, and keeps the formatting unit-testable * without a Nest testing module. * * Group bookings send ONE SMS to Booking.contactPhone by design — BookingSeat has no * phone/email column, so there is no per-passenger recipient. Hence 2+ passengers are * greeted collectively and each seat line names its own occupant. */ export interface SeatSummary { /** Salutation: the traveller's name when solo, otherwise 'Passengers'. */ passengerName: string; /** One line per booked seat, newline-joined, with a heading per leg on multi-leg bookings. */ trainSeatLines: string; } /** * Seat numbers are stored as strings of digits (Seat.seatNumber), so they must be compared * numerically — a plain string compare orders '10' before '9'. Non-numeric labels sort last, * then alphabetically among themselves. */ function compareSeatNumber(a: string, b: string): number { const na = Number.parseInt(a, 10); const nb = Number.parseInt(b, 10); const aNum = Number.isNaN(na); const bNum = Number.isNaN(nb); if (aNum && bNum) return a.localeCompare(b); if (aNum) return 1; if (bNum) return -1; return na - nb || a.localeCompare(b); } const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : v == null ? '' : String(v).trim()); const ROUND_TRIP_TRANSIT_LEGS: Record = { 1: 'Outbound leg 1', 2: 'Outbound leg 2', 3: 'Return leg 1', 4: 'Return leg 2', }; /** * Leg numbering means different things per booking type — see the enum documented on * TicketsController.validate. TRANSIT's leg 2 is a connecting segment of the SAME outbound * journey, so it must never be labelled 'Return'. */ function legLabel(bookingType: string | undefined, leg: number): string { switch (bookingType) { case 'ROUND_TRIP': return leg === 1 ? 'Outbound' : leg === 2 ? 'Return' : `Leg ${leg}`; case 'TRANSIT': return `Leg ${leg}`; case 'ROUND_TRIP_TRANSIT': return ROUND_TRIP_TRANSIT_LEGS[leg] ?? `Leg ${leg}`; default: // Unknown or newly added booking type — degrade to a generic heading rather than guessing. return `Leg ${leg}`; } } export function buildSeatSummary(seats: any[], bookingType?: string): SeatSummary { const rows = [...(seats ?? [])].sort( (a, b) => (a?.leg ?? 1) - (b?.leg ?? 1) || str(a?.seat?.coach?.number).localeCompare(str(b?.seat?.coach?.number)) || compareSeatNumber(str(a?.seat?.seatNumber), str(b?.seat?.seatNumber)), ); // Distinct travellers. A round-trip/transit booking has one row per passenger PER LEG, so // the same name legitimately repeats — count people, not rows. const names: string[] = []; for (const row of rows) { const name = str(row?.passengerName); if (name && !names.includes(name)) names.push(name); } const isGroup = names.length > 1; const line = (row: any): string => { const coach = str(row?.seat?.coach?.number) || '-'; const coachType = str(row?.seat?.coach?.coachType?.name); const seatNo = str(row?.seat?.seatNumber) || '-'; // Trim each part before joining: the coach-type name carries a trailing space in some // records, which a `.replace(/ +/g, ' ')` collapse cannot remove (it shrinks runs of // spaces but leaves a single one), and it surfaced as 'VIP Bed , seat no. 9'. const where = [coach, coachType].filter(Boolean).join(' '); const who = isGroup ? `${str(row?.passengerName) || 'Passenger'}, ` : ''; return `${who}${where}, seat no. ${seatNo}`; }; const legs = [...new Set(rows.map((row) => row?.leg ?? 1))]; const trainSeatLines = legs.length > 1 ? legs .map((leg) => [ `${legLabel(bookingType, leg)}:`, ...rows.filter((row) => (row?.leg ?? 1) === leg).map(line), ].join('\n'), ) .join('\n') : rows.map(line).join('\n'); return { passengerName: isGroup ? 'Passengers' : (names[0] || 'Passenger'), trainSeatLines, }; }