310 lines
9.4 KiB
TypeScript
310 lines
9.4 KiB
TypeScript
import type {
|
|
FlightLegWindow,
|
|
FlightPassengerGroup,
|
|
FlightReportRequest,
|
|
FlightReportRequestDraft,
|
|
FlightSearchPreferences,
|
|
NormalizedFlightReportRequest
|
|
} from "./types.js";
|
|
import { isPlausibleEmail, normalizeMarketCountry } from "./input-validation.js";
|
|
|
|
const DEFAULT_PREFERENCES: FlightSearchPreferences = {
|
|
preferOneStop: true,
|
|
maxLayoverHours: 6,
|
|
normalizeCurrencyTo: "USD"
|
|
};
|
|
|
|
function cleanString(value: string | null | undefined): string | undefined {
|
|
if (typeof value !== "string") {
|
|
return undefined;
|
|
}
|
|
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
|
|
function uniqueStrings(values: Array<string | null | undefined> | undefined): string[] | undefined {
|
|
if (!values?.length) {
|
|
return undefined;
|
|
}
|
|
|
|
const cleaned = values
|
|
.map((value) => cleanString(value))
|
|
.filter((value): value is string => Boolean(value));
|
|
|
|
return cleaned.length ? Array.from(new Set(cleaned)) : undefined;
|
|
}
|
|
|
|
function normalizePassengerGroups(
|
|
groups: FlightReportRequestDraft["passengerGroups"]
|
|
): FlightPassengerGroup[] {
|
|
return (groups || [])
|
|
.filter((group): group is NonNullable<typeof group> => Boolean(group))
|
|
.map((group, index) => ({
|
|
id: cleanString(group.id) || `group-${index + 1}`,
|
|
adults: Number(group.adults || 0),
|
|
children: Number(group.children || 0) || undefined,
|
|
infants: Number(group.infants || 0) || undefined,
|
|
label: cleanString(group.label)
|
|
}));
|
|
}
|
|
|
|
function normalizeLegs(legs: FlightReportRequestDraft["legs"]): FlightLegWindow[] {
|
|
return (legs || [])
|
|
.filter((leg): leg is NonNullable<typeof leg> => Boolean(leg))
|
|
.map((leg, index) => ({
|
|
id: cleanString(leg.id) || `leg-${index + 1}`,
|
|
origin: cleanString(leg.origin) || "",
|
|
destination: cleanString(leg.destination) || "",
|
|
earliest: cleanString(leg.earliest),
|
|
latest: cleanString(leg.latest),
|
|
relativeToLegId: cleanString(leg.relativeToLegId),
|
|
minDaysAfter:
|
|
typeof leg.minDaysAfter === "number" && Number.isFinite(leg.minDaysAfter)
|
|
? leg.minDaysAfter
|
|
: undefined,
|
|
maxDaysAfter:
|
|
typeof leg.maxDaysAfter === "number" && Number.isFinite(leg.maxDaysAfter)
|
|
? leg.maxDaysAfter
|
|
: undefined,
|
|
label: cleanString(leg.label)
|
|
}));
|
|
}
|
|
|
|
function normalizeAssignments(
|
|
draft: FlightReportRequestDraft["legAssignments"]
|
|
): FlightReportRequest["legAssignments"] {
|
|
return (draft || [])
|
|
.filter((assignment): assignment is NonNullable<typeof assignment> => Boolean(assignment))
|
|
.map((assignment) => ({
|
|
legId: cleanString(assignment.legId) || "",
|
|
passengerGroupIds:
|
|
uniqueStrings(
|
|
(assignment.passengerGroupIds || []).map((value) =>
|
|
typeof value === "string" ? value : undefined
|
|
)
|
|
) || []
|
|
}));
|
|
}
|
|
|
|
function normalizePreferences(
|
|
preferences: FlightReportRequestDraft["preferences"]
|
|
): FlightSearchPreferences {
|
|
const draft = preferences || {};
|
|
return {
|
|
...DEFAULT_PREFERENCES,
|
|
cabin: draft.cabin,
|
|
maxStops: typeof draft.maxStops === "number" ? draft.maxStops : undefined,
|
|
preferOneStop:
|
|
typeof draft.preferOneStop === "boolean"
|
|
? draft.preferOneStop
|
|
: DEFAULT_PREFERENCES.preferOneStop,
|
|
maxLayoverHours:
|
|
typeof draft.maxLayoverHours === "number"
|
|
? draft.maxLayoverHours
|
|
: DEFAULT_PREFERENCES.maxLayoverHours,
|
|
excludeAirlines: uniqueStrings(draft.excludeAirlines),
|
|
excludeCountries: uniqueStrings(draft.excludeCountries),
|
|
excludeAirports: uniqueStrings(draft.excludeAirports),
|
|
flexibleDates:
|
|
typeof draft.flexibleDates === "boolean" ? draft.flexibleDates : undefined,
|
|
requireAirlineDirectCrossCheck:
|
|
typeof draft.requireAirlineDirectCrossCheck === "boolean"
|
|
? draft.requireAirlineDirectCrossCheck
|
|
: undefined,
|
|
specialConstraints: uniqueStrings(draft.specialConstraints),
|
|
geoPricingMarket: cleanString(draft.geoPricingMarket),
|
|
marketCountry: normalizeMarketCountry(draft.marketCountry),
|
|
normalizeCurrencyTo: "USD"
|
|
};
|
|
}
|
|
|
|
function collectMissingSearchInputs(request: FlightReportRequest): string[] {
|
|
const missing = new Set<string>();
|
|
|
|
if (!request.legs.length) {
|
|
missing.add("trip legs");
|
|
}
|
|
|
|
if (!request.passengerGroups.length) {
|
|
missing.add("passenger groups");
|
|
}
|
|
|
|
for (const group of request.passengerGroups) {
|
|
const total = group.adults + (group.children || 0) + (group.infants || 0);
|
|
if (total <= 0) {
|
|
missing.add(`traveler count for passenger group ${group.label || group.id}`);
|
|
}
|
|
}
|
|
|
|
const legIds = new Set(request.legs.map((leg) => leg.id));
|
|
const groupIds = new Set(request.passengerGroups.map((group) => group.id));
|
|
|
|
for (const leg of request.legs) {
|
|
const label = leg.label || leg.id;
|
|
if (!leg.origin || !leg.destination) {
|
|
missing.add(`origin and destination for ${label}`);
|
|
}
|
|
|
|
const hasAbsoluteWindow = Boolean(leg.earliest || leg.latest);
|
|
const hasRelativeWindow = Boolean(
|
|
leg.relativeToLegId &&
|
|
(typeof leg.minDaysAfter === "number" || typeof leg.maxDaysAfter === "number")
|
|
);
|
|
|
|
if (!hasAbsoluteWindow && !hasRelativeWindow) {
|
|
missing.add(`date window for ${label}`);
|
|
}
|
|
|
|
if (leg.relativeToLegId && !legIds.has(leg.relativeToLegId)) {
|
|
missing.add(`valid reference leg for ${label}`);
|
|
}
|
|
}
|
|
|
|
if (!request.legAssignments.length) {
|
|
missing.add("passenger assignments for every leg");
|
|
}
|
|
|
|
for (const leg of request.legs) {
|
|
const label = leg.label || leg.id;
|
|
const assignment = request.legAssignments.find((entry) => entry.legId === leg.id);
|
|
if (!assignment) {
|
|
missing.add(`passenger assignments for ${label}`);
|
|
continue;
|
|
}
|
|
|
|
if (!assignment.passengerGroupIds.length) {
|
|
missing.add(`assigned travelers for ${label}`);
|
|
continue;
|
|
}
|
|
|
|
if (assignment.passengerGroupIds.some((groupId) => !groupIds.has(groupId))) {
|
|
missing.add(`valid passenger-group references for ${label}`);
|
|
}
|
|
}
|
|
|
|
return Array.from(missing);
|
|
}
|
|
|
|
export function normalizeFlightReportRequest(
|
|
draft: FlightReportRequestDraft
|
|
): NormalizedFlightReportRequest {
|
|
const normalizedRecipientEmail = cleanString(draft.recipientEmail) || null;
|
|
const request: FlightReportRequest = {
|
|
tripName: cleanString(draft.tripName),
|
|
legs: normalizeLegs(draft.legs),
|
|
passengerGroups: normalizePassengerGroups(draft.passengerGroups),
|
|
legAssignments: normalizeAssignments(draft.legAssignments),
|
|
recipientEmail: normalizedRecipientEmail,
|
|
preferences: normalizePreferences(draft.preferences)
|
|
};
|
|
|
|
const missingSearchInputs = collectMissingSearchInputs(request);
|
|
const missingDeliveryInputs = !request.recipientEmail
|
|
? ["recipient email"]
|
|
: isPlausibleEmail(request.recipientEmail)
|
|
? []
|
|
: ["valid recipient email"];
|
|
const warnings: string[] = [];
|
|
|
|
if (!request.recipientEmail) {
|
|
warnings.push(
|
|
"Recipient email is still missing. Ask for it before rendering or sending the PDF report."
|
|
);
|
|
} else if (!isPlausibleEmail(request.recipientEmail)) {
|
|
warnings.push(
|
|
"Recipient email looks malformed. Ask for a corrected email address before rendering or sending the PDF report."
|
|
);
|
|
}
|
|
|
|
if (request.preferences.marketCountry && !request.preferences.geoPricingMarket) {
|
|
warnings.push(
|
|
`Market-localized search is explicit for this run. Connect VPN to ${request.preferences.marketCountry} only for the bounded search phase, then disconnect before ranking/render/delivery.`
|
|
);
|
|
}
|
|
|
|
return {
|
|
request,
|
|
readyToSearch: missingSearchInputs.length === 0,
|
|
missingInputs: [...missingSearchInputs, ...missingDeliveryInputs],
|
|
missingSearchInputs,
|
|
missingDeliveryInputs,
|
|
warnings
|
|
};
|
|
}
|
|
|
|
export const DFW_BLQ_2026_PROMPT_DRAFT: FlightReportRequestDraft = {
|
|
tripName: "DFW ↔ BLQ flight report",
|
|
legs: [
|
|
{
|
|
id: "outbound",
|
|
origin: "DFW",
|
|
destination: "BLQ",
|
|
earliest: "2026-05-30",
|
|
latest: "2026-06-07",
|
|
label: "Outbound"
|
|
},
|
|
{
|
|
id: "return-pair",
|
|
origin: "BLQ",
|
|
destination: "DFW",
|
|
relativeToLegId: "outbound",
|
|
minDaysAfter: 6,
|
|
maxDaysAfter: 10,
|
|
label: "Return for 2 adults"
|
|
},
|
|
{
|
|
id: "return-solo",
|
|
origin: "BLQ",
|
|
destination: "DFW",
|
|
earliest: "2026-06-28",
|
|
latest: "2026-07-05",
|
|
label: "Return for 1 adult"
|
|
}
|
|
],
|
|
passengerGroups: [
|
|
{
|
|
id: "pair",
|
|
adults: 2,
|
|
label: "2 adults traveling together"
|
|
},
|
|
{
|
|
id: "solo",
|
|
adults: 1,
|
|
label: "1 adult returning separately"
|
|
}
|
|
],
|
|
legAssignments: [
|
|
{
|
|
legId: "outbound",
|
|
passengerGroupIds: ["pair", "solo"]
|
|
},
|
|
{
|
|
legId: "return-pair",
|
|
passengerGroupIds: ["pair"]
|
|
},
|
|
{
|
|
legId: "return-solo",
|
|
passengerGroupIds: ["solo"]
|
|
}
|
|
],
|
|
recipientEmail: "stefano@fiorinis.com",
|
|
preferences: {
|
|
preferOneStop: true,
|
|
maxStops: 1,
|
|
maxLayoverHours: 6,
|
|
flexibleDates: true,
|
|
excludeAirlines: ["Turkish Airlines"],
|
|
excludeCountries: ["TR"],
|
|
requireAirlineDirectCrossCheck: true,
|
|
specialConstraints: [
|
|
"Exclude itineraries operated by airlines based in Arab or Middle Eastern countries.",
|
|
"Exclude itineraries routing through airports in Arab or Middle Eastern countries.",
|
|
"Start from a fresh browser/session profile for this search."
|
|
],
|
|
geoPricingMarket: "Thailand",
|
|
marketCountry: "TH",
|
|
normalizeCurrencyTo: "USD"
|
|
}
|
|
};
|