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 | 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 => 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 => 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 => 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(); 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" } };