utc(); $userFlights = UserFlight::where('arrival_date', '<=', $now->copy()->subHour()->toDateTimeString()) ->where('auto_update', true) ->whereNotNull('flight_number') ->get(); $this->info("Found {$userFlights->count()} flights."); foreach ($userFlights as $flight) { $this->processFlight($flight); } } protected function processFlight(UserFlight $flight): void { preg_match('/^([A-Z]{2,3})(\d+)$/i', $flight->flight_number, $matches); // Case 1: unparseable flight number on our end — disable and move on, no notification. if (empty($matches)) { $this->warn("Could not parse flight number: {$flight->flight_number}"); Log::warning("Could not parse flight number for auto-update", ['flight_id' => $flight->id, 'flight_number' => $flight->flight_number]); $flight->update(['auto_update' => false]); return; } $airlineCode = strtoupper($matches[1]); $flightNumber = $matches[2]; $arrivalDate = $flight->arrival_date->setTimezone($flight->arrivalAirport->timezone); $data = $this->flightStats->fetchFlightData($airlineCode, $flightNumber, $arrivalDate); // Case 2: valid flight number, but the API had nothing for it. if (!$data) { $this->warn("No flight data returned for {$airlineCode}{$flightNumber}"); $this->notifyLookupFailed($flight, $airlineCode, $flightNumber); return; } // Case 3: API returned data, but for the wrong route — treat like a failed lookup. if ($data->departure_iata !== $flight->departureAirport->iata_code || $data->arrival_iata !== $flight->arrivalAirport->iata_code) { $this->warn("Airport mismatch for {$airlineCode}{$flightNumber} — API: {$data->departure_iata}→{$data->arrival_iata}, expected: {$flight->departureAirport->iata_code}→{$flight->arrivalAirport->iata_code}"); $this->notifyLookupFailed($flight, $airlineCode, $flightNumber); return; } // Case 4/5: valid data — apply whatever changed, but always report every field. $this->applyAndNotify($flight, $data, $airlineCode, $flightNumber); } protected function splitFlightNumber(UserFlight $flight): array { $raw = strtoupper($flight->flight_number); $airline = $flight->airline; // Preferred: anchor on the airline's known codes rather than guessing. foreach (array_filter([$airline?->iata_code, $airline?->icao_code]) as $code) { $code = strtoupper($code); if (str_starts_with($raw, $code) && preg_match('/^\d+$/', substr($raw, strlen($code)))) { return [$code, substr($raw, strlen($code))]; } } // Fallback: regex guess. // Covers: letter+digit (J2), digit+letter (9K, 4U), and 2-3 letter codes (AA, DAL). if (preg_match('/^([A-Z]\d|\d[A-Z]|[A-Z]{2,3})(\d+)$/', $raw, $matches)) { return [$matches[1], $matches[2]]; } return [null, null]; } protected function notifyLookupFailed(UserFlight $flight, string $airlineCode, string $flightNumber): void { Notification::create([ 'user_id' => $flight->user_id, 'title' => "Auto update failed for {$flight->flight_number}", 'body' => "We tried to find up-to-date information for {$airlineCode}{$flightNumber}, but couldn't find a reliable match. Please manually check the aircraft type, registration and departure/arrival times.", 'url' => '/flights/' . $flight->id . '/edit', ]); $flight->update(['auto_update' => false]); } protected function applyAndNotify(UserFlight $flight, FlightStatData $data, string $airlineCode, string $flightNumber): void { // Snapshot originals before we touch anything, so descriptions reflect the real "from" state. $originalRegistration = $flight->aircraft_registration; $originalDeparture = $flight->departure_date; $originalArrival = $flight->arrival_date; $originalAircraft = $flight->aircraft; $updates = []; $aircraftNotFound = false; if ($data->aircraft_registration && $data->aircraft_registration !== $originalRegistration) { $updates['aircraft_registration'] = $data->aircraft_registration; } if ($data->estimated_departure_utc?->ne($originalDeparture)) { $updates['departure_date'] = $data->estimated_departure_utc; } if ($data->estimated_arrival_utc?->ne($originalArrival)) { $updates['arrival_date'] = $data->estimated_arrival_utc; } if ($data->equipment_iata && $originalAircraft?->iata_code !== $data->equipment_iata) { $match = $this->flightStats->guessAircraftFromIata($data->equipment_iata); if ($match) { $updates['aircraft_id'] = $match->id; } else { $aircraftNotFound = true; Log::warning("No aircraft match for IATA code {$data->equipment_iata} on flight {$airlineCode}{$flightNumber}", [ 'flight_id' => $flight->id, ]); } } if (!empty($updates)) { $flight->update($updates); $this->info("Updated flight {$airlineCode}{$flightNumber}: " . implode(', ', array_keys($updates))); } else { $this->info("No changes for {$airlineCode}{$flightNumber}"); } $flight->update(['auto_update' => false]); $newAircraft = isset($updates['aircraft_id']) ? Aircraft::find($updates['aircraft_id']) : $originalAircraft; $lines = [ $this->describeValue( 'Aircraft Registration', $originalRegistration, $data->aircraft_registration, $updates['aircraft_registration'] ?? null, ), $this->describeDate('Departure', $originalDeparture, $data->estimated_departure_utc, $updates['departure_date'] ?? null, $flight->departureAirport->timezone), $this->describeDate('Arrival', $originalArrival, $data->estimated_arrival_utc, $updates['arrival_date'] ?? null, $flight->arrivalAirport->timezone), $this->describeAircraftType($originalAircraft, $newAircraft, $data->equipment_iata, $aircraftNotFound), ]; $title = $aircraftNotFound ? "Flight {$airlineCode}{$flightNumber} updated — aircraft type not found" : "Flight {$airlineCode}{$flightNumber} updated"; Notification::create([ 'user_id' => $flight->user_id, 'title' => $title, 'body' => implode("\n", $lines), 'url' => '/u/' . $flight->user->name . '/flight/' . $flight->id, ]); } /** * Describe a simple string field: from → to, or "no change" / "not found" as appropriate. */ protected function describeValue(string $label, ?string $from, ?string $apiValue, ?string $applied): string { $fromLabel = $from ?: 'None'; if (!$apiValue) { return "{$label}: {$fromLabel} (could not find — no change)"; } if (!$applied) { return "{$label}: {$apiValue} (no change)"; } return "{$label}: {$fromLabel} → {$apiValue}"; } protected function describeDate(string $label, ?CarbonInterface $from, ?CarbonInterface $apiValue, mixed $applied, string $timezone): string { $toLocal = $apiValue?->copy()->setTimezone($timezone); $fromLocal = $from?->copy()->setTimezone($timezone); if (!$toLocal) { $fromLabel = $fromLocal ? $this->formatFlightTime($fromLocal) : 'Unknown'; return "{$label}: {$fromLabel} (could not find — no change)"; } if (!$applied) { return "{$label}: " . $this->formatFlightTime($toLocal) . " (no change)"; } // Only bother showing the date if it actually changed (e.g. delayed past midnight). $showDate = !$fromLocal || !$fromLocal->isSameDay($toLocal); $showYear = $showDate && $fromLocal && $fromLocal->year !== $toLocal->year; $fromLabel = $fromLocal ? $this->formatFlightTime($fromLocal, $showDate, $showYear) : 'Unknown'; $toLabel = $this->formatFlightTime($toLocal, $showDate, $showYear); return "{$label}: {$fromLabel} → {$toLabel}"; } protected function formatFlightTime(CarbonInterface $time, bool $showDate = false, bool $showYear = false): string { $datePart = $showDate ? $time->format($showYear ? 'j M Y, ' : 'j M, ') : ''; return $datePart . $time->format('g:ia') . ' ' . $time->format('T'); } protected function describeAircraftType(?Aircraft $from, ?Aircraft $to, ?string $equipmentIata, bool $notFound): string { $fromLabel = $from?->display_name_short ?? 'None'; if (!$equipmentIata) { return "Aircraft Type: {$fromLabel} (not returned by API — no change)"; } if ($notFound) { return "Aircraft Type: {$fromLabel} (API reported equipment code {$equipmentIata}, but no matching aircraft was found in our database — no change)"; } if ($to?->id === $from?->id) { return "Aircraft Type: {$fromLabel} (no change)"; } return "Aircraft Type: {$fromLabel} → {$to?->display_name_short}"; } }