mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
116 lines
4.6 KiB
TypeScript
116 lines
4.6 KiB
TypeScript
/**
|
|
* 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<number, string> = {
|
|
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,
|
|
};
|
|
}
|