Files
edr-platform/apps/edr-passenger-api/src/common/utils/booking-sms.utils.ts

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,
};
}