mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
204 lines
8.1 KiB
TypeScript
204 lines
8.1 KiB
TypeScript
/**
|
|
* One card per booking. Senders write one FlightReservation per passenger
|
|
* and leg, an Order next to its ParcelDelivery, or the same booking in both
|
|
* JSON-LD and microdata; equal merge keys (normalize.ts cardKey) are folded
|
|
* together here. If any part of a booking failed validation, or two parts
|
|
* disagree about the same leg, the whole booking is dropped.
|
|
*/
|
|
import { legKey } from './normalize';
|
|
import type {
|
|
Booking,
|
|
BookingLinks,
|
|
Candidate,
|
|
Item,
|
|
Moment,
|
|
OrderStatus,
|
|
Parcel,
|
|
ReservationStatus,
|
|
Seat,
|
|
Shipment,
|
|
StructuredCard,
|
|
TransportBooking,
|
|
TransportLeg,
|
|
} from './types';
|
|
|
|
const KIND_ORDER: StructuredCard['kind'][] = ['flight', 'train', 'bus', 'lodging', 'rentalcar', 'event', 'restaurant', 'shipment'];
|
|
|
|
function sameMoment(a: Moment | undefined, b: Moment | undefined): boolean {
|
|
if (!a || !b) return true;
|
|
if (a.utc && b.utc) return a.utc === b.utc;
|
|
return a.date === b.date && (a.time ?? '') === (b.time ?? '');
|
|
}
|
|
|
|
function mergeLinks(all: BookingLinks[]): BookingLinks {
|
|
const out: BookingLinks = {};
|
|
for (const links of all) {
|
|
out.checkin ??= links.checkin;
|
|
out.manage ??= links.manage;
|
|
out.ticket ??= links.ticket;
|
|
}
|
|
for (const key of Object.keys(out) as (keyof BookingLinks)[]) if (!out[key]) delete out[key];
|
|
return out;
|
|
}
|
|
|
|
function unique<T>(values: (T | undefined)[]): T[] {
|
|
return [...new Set(values.filter((v): v is T => v !== undefined))];
|
|
}
|
|
|
|
function latest(values: (string | undefined)[]): string | undefined {
|
|
return unique(values).sort().pop();
|
|
}
|
|
|
|
/**
|
|
* Status of a leg or booking several reservations contributed to: cancelled
|
|
* only when every one of them says so. One cancelled passenger on a leg the
|
|
* others still hold leaves the leg live.
|
|
*/
|
|
function combinedStatus(statuses: (ReservationStatus | undefined)[]): ReservationStatus | undefined {
|
|
if (statuses.length > 0 && statuses.every((s) => s === 'cancelled')) return 'cancelled';
|
|
return statuses.find((s) => s && s !== 'cancelled');
|
|
}
|
|
|
|
function mergeTransport(group: TransportBooking[]): TransportBooking | null {
|
|
const legs = new Map<string, { legs: TransportLeg[] }>();
|
|
for (const booking of group) {
|
|
for (const leg of booking.legs) {
|
|
const key = legKey(leg);
|
|
const entry = legs.get(key) ?? { legs: [] };
|
|
entry.legs.push(leg);
|
|
legs.set(key, entry);
|
|
}
|
|
}
|
|
const merged: TransportLeg[] = [];
|
|
for (const { legs: parts } of legs.values()) {
|
|
const [first] = parts;
|
|
for (const other of parts.slice(1)) {
|
|
if (!sameMoment(first.departure.at, other.departure.at) || !sameMoment(first.arrival?.at, other.arrival?.at)) return null;
|
|
}
|
|
const seats: Seat[] = [];
|
|
const seen = new Set<string>();
|
|
for (const seat of parts.flatMap((p) => p.seats)) {
|
|
const id = `${seat.passenger ?? ''}|${seat.seat}`;
|
|
if (!seen.has(id)) {
|
|
seen.add(id);
|
|
seats.push(seat);
|
|
}
|
|
}
|
|
const status = combinedStatus(parts.map((p) => p.status));
|
|
const withArrival = parts.find((p) => p.arrival?.at) ?? first;
|
|
const { status: _ignored, ...rest } = first;
|
|
merged.push({ ...rest, ...(withArrival.arrival ? { arrival: withArrival.arrival } : {}), seats, ...(status ? { status } : {}) });
|
|
}
|
|
merged.sort((a, b) => (a.departure.at.utc ?? `${a.departure.at.date}T${a.departure.at.time}`).localeCompare(b.departure.at.utc ?? `${b.departure.at.date}T${b.departure.at.time}`));
|
|
const [head] = group;
|
|
const allCancelled = merged.length > 0 && merged.every((leg) => leg.status === 'cancelled');
|
|
const bookingStatus = allCancelled ? 'cancelled' : combinedStatus(group.map((b) => b.status).filter((s) => s !== 'cancelled'));
|
|
const { status: _s, modified: _m, provider: _p, ...base } = head;
|
|
const modified = latest(group.map((b) => b.modified));
|
|
const provider = group.find((b) => b.provider)?.provider;
|
|
return {
|
|
...base,
|
|
...(bookingStatus ? { status: bookingStatus } : {}),
|
|
passengers: unique(group.flatMap((b) => b.passengers)),
|
|
...(provider ? { provider } : {}),
|
|
links: mergeLinks(group.map((b) => b.links)),
|
|
...(modified ? { modified } : {}),
|
|
legs: merged,
|
|
} as TransportBooking;
|
|
}
|
|
|
|
function mergeOther(group: Booking[]): Booking {
|
|
const [head] = group;
|
|
const status = combinedStatus(group.map((b) => b.status));
|
|
const modified = latest(group.map((b) => b.modified));
|
|
const { status: _s, modified: _m, ...base } = head;
|
|
const merged = {
|
|
...base,
|
|
...(status ? { status } : {}),
|
|
passengers: unique(group.flatMap((b) => b.passengers)),
|
|
links: mergeLinks(group.map((b) => b.links)),
|
|
...(modified ? { modified } : {}),
|
|
} as Booking;
|
|
if (merged.kind === 'event') merged.tickets = unique(group.flatMap((b) => (b.kind === 'event' ? b.tickets : [])));
|
|
return merged;
|
|
}
|
|
|
|
/** Later shipping states win over earlier ones; a problem state over all. */
|
|
const ORDER_PROGRESS: OrderStatus[] = ['payment_due', 'processing', 'in_transit', 'pickup_available', 'delivered', 'returned', 'problem', 'cancelled'];
|
|
|
|
function mergeItems(lists: Item[][]): Item[] {
|
|
const byName = new Map<string, Item>();
|
|
for (const item of lists.flat()) {
|
|
const key = item.name.toLowerCase();
|
|
const known = byName.get(key);
|
|
byName.set(key, known ? { ...item, ...known, image: known.image ?? item.image } : item);
|
|
}
|
|
return [...byName.values()].map((item) => {
|
|
if (!item.image) delete item.image;
|
|
return item;
|
|
});
|
|
}
|
|
|
|
function mergeShipments(group: Shipment[]): Shipment {
|
|
const parcels: Parcel[] = [];
|
|
for (const parcel of group.flatMap((s) => s.parcels)) {
|
|
const same = parcel.trackingNumber ? parcels.find((p) => p.trackingNumber === parcel.trackingNumber) : undefined;
|
|
if (same) {
|
|
same.carrier ??= parcel.carrier;
|
|
same.trackingUrl ??= parcel.trackingUrl;
|
|
same.expectedBy ??= parcel.expectedBy;
|
|
same.items = mergeItems([same.items, parcel.items]);
|
|
} else {
|
|
parcels.push({ ...parcel, items: [...parcel.items] });
|
|
}
|
|
}
|
|
const statuses = group.map((s) => s.status).filter((s): s is OrderStatus => !!s);
|
|
const status = statuses.sort((a, b) => ORDER_PROGRESS.indexOf(a) - ORDER_PROGRESS.indexOf(b)).pop();
|
|
const withOrder = group.find((s) => s.items.length > 0 || s.total) ?? group[0];
|
|
const merged: Shipment = {
|
|
kind: 'shipment',
|
|
key: group[0].key,
|
|
...(group.find((s) => s.merchant) ? { merchant: group.find((s) => s.merchant)!.merchant } : {}),
|
|
...(group.find((s) => s.orderNumber) ? { orderNumber: group.find((s) => s.orderNumber)!.orderNumber } : {}),
|
|
...(status ? { status } : {}),
|
|
items: mergeItems(group.map((s) => s.items)),
|
|
...(withOrder.total ? { total: withOrder.total } : {}),
|
|
...(group.find((s) => s.orderUrl) ? { orderUrl: group.find((s) => s.orderUrl)!.orderUrl } : {}),
|
|
parcels,
|
|
source: group[0].source,
|
|
};
|
|
return merged;
|
|
}
|
|
|
|
function startOf(card: StructuredCard): string {
|
|
switch (card.kind) {
|
|
case 'flight': case 'train': case 'bus': return card.legs[0]?.departure.at.utc ?? card.legs[0]?.departure.at.date ?? '';
|
|
case 'lodging': return card.checkin.date;
|
|
case 'rentalcar': return card.pickup.at.utc ?? card.pickup.at.date;
|
|
case 'event': case 'restaurant': return card.start.utc ?? card.start.date;
|
|
case 'shipment': return '';
|
|
}
|
|
}
|
|
|
|
/** Fold candidates with equal keys into one card each; drop doubtful groups. */
|
|
export function mergeCandidates(candidates: Candidate[]): StructuredCard[] {
|
|
const groups = new Map<string, Candidate[]>();
|
|
for (const candidate of candidates) {
|
|
const group = groups.get(candidate.card.key) ?? [];
|
|
group.push(candidate);
|
|
groups.set(candidate.card.key, group);
|
|
}
|
|
const out: StructuredCard[] = [];
|
|
for (const group of groups.values()) {
|
|
if (group.some((c) => c.problems.length > 0)) continue;
|
|
const cards = group.map((c) => c.card);
|
|
const [head] = cards;
|
|
if (head.kind === 'shipment') out.push(mergeShipments(cards as Shipment[]));
|
|
else if (head.kind === 'flight' || head.kind === 'train' || head.kind === 'bus') {
|
|
const merged = mergeTransport(cards as TransportBooking[]);
|
|
if (merged) out.push(merged);
|
|
} else out.push(mergeOther(cards as Booking[]));
|
|
}
|
|
return out.sort((a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) || startOf(a).localeCompare(startOf(b)));
|
|
}
|