Added Notifications
This commit is contained in:
@@ -2,17 +2,111 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\DTOs\FlightStatData;
|
||||
use App\Models\Aircraft;
|
||||
use App\Models\IataEquipmentCode;
|
||||
use App\Models\Notification;
|
||||
use App\Models\UserFlight;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
#[Signature('app:update-departed-flights')]
|
||||
#[Description('Command description')]
|
||||
class UpdateDepartedFlights extends Command
|
||||
{
|
||||
/**
|
||||
* Fetch live flight data from FlightStats.
|
||||
* Returns null if the request fails or no data is found.
|
||||
*/
|
||||
protected function fetchFlightData(string $airlineCode, string $flightNumber, CarbonImmutable $date): ?FlightStatData
|
||||
{
|
||||
$url = sprintf(
|
||||
'https://www.flightstats.com/v2/api-next/flight-tracker/%s/%s/%d/%d/%d',
|
||||
$airlineCode,
|
||||
$flightNumber,
|
||||
$date->year,
|
||||
$date->month,
|
||||
$date->day,
|
||||
);
|
||||
|
||||
$response = Http::withOptions([
|
||||
'verify' => config('app.verify_ssl'),
|
||||
])->get($url);
|
||||
|
||||
if (!$response->successful()) {
|
||||
Log::warning("FlightStats request failed for {$airlineCode}{$flightNumber}: HTTP {$response->status()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$flightData = $response->json('data');
|
||||
|
||||
if (empty($flightData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FlightStatData::fromApiResponse($flightData);
|
||||
}
|
||||
/**
|
||||
* Attempt to resolve the best matching Aircraft record for a given IATA equipment code.
|
||||
* Prefers passenger variants over freighters/BBJs where multiple matches exist.
|
||||
*/
|
||||
protected function guessAircraftFromIata(string $iataCode): ?Aircraft
|
||||
{
|
||||
$equipment = IataEquipmentCode::where('iata_code', $iataCode)->first();
|
||||
|
||||
if (!$equipment) {
|
||||
Log::info("Unknown IATA equipment code: {$iataCode}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidates = Aircraft::where('designator', $equipment->icao_code)->get();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
Log::info("No aircraft found for ICAO: {$equipment->icao_code} (IATA: {$iataCode})");
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($candidates->count() === 1) {
|
||||
return $candidates->first();
|
||||
}
|
||||
|
||||
// Prefer passenger variants — deprioritise freighters, BBJs, and convertibles
|
||||
$deprioritised = ['freighter', 'bbj', 'combi', 'mixed', 'cargo', 'prestige', 'winglet', 'sharklet', 'freight'];
|
||||
|
||||
$pattern = implode('|', $deprioritised);
|
||||
|
||||
$passengerVariants = $candidates->filter(
|
||||
fn(Aircraft $a) => !preg_match("/({$pattern})/i", $a->display_name_short)
|
||||
);
|
||||
|
||||
if ($passengerVariants->count() === 1) {
|
||||
return $passengerVariants->first();
|
||||
}
|
||||
|
||||
if ($passengerVariants->isNotEmpty()) {
|
||||
return $passengerVariants->first();
|
||||
}
|
||||
|
||||
return $candidates->first();
|
||||
}
|
||||
|
||||
protected function notifyDataError(UserFlight $flight): void
|
||||
{
|
||||
Notification::create([
|
||||
'user_id' => $flight->user_id,
|
||||
'title' => "Auto update failed for {$flight->flight_number}",
|
||||
'body' => "There was an error fetching flight data for {$flight->flight_number}. Please manually check the aircraft type, registration and departure/arrival times.",
|
||||
'url' => '/flights/' . $flight->id . '/edit'
|
||||
]);
|
||||
|
||||
$flight->update(['auto_update' => false]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
@@ -21,10 +115,7 @@ class UpdateDepartedFlights extends Command
|
||||
$now = now()->utc();
|
||||
$oneHourAgo = $now->copy()->subHours(1);
|
||||
|
||||
$userFlights = UserFlight::whereBetween('arrival_date', [
|
||||
$oneHourAgo->toDateTimeString(),
|
||||
$now->toDateTimeString(),
|
||||
])
|
||||
$userFlights = UserFlight::where('arrival_date', '<=', $now->copy()->subHour()->toDateTimeString())
|
||||
->where('auto_update', true)
|
||||
->whereNotNull('flight_number')
|
||||
->get();
|
||||
@@ -36,72 +127,94 @@ class UpdateDepartedFlights extends Command
|
||||
|
||||
if (empty($matches)) {
|
||||
$this->warn("Could not parse flight number: {$flight->flight_number}");
|
||||
$this->notifyDataError($flight);
|
||||
continue;
|
||||
}
|
||||
|
||||
$airlineCode = strtoupper($matches[1]);
|
||||
$airlineCode = strtoupper($matches[1]);
|
||||
$flightNumber = $matches[2];
|
||||
|
||||
$arrivalDate = $flight->arrival_date->setTimezone($flight->arrivalAirport->timezone);
|
||||
$year = $arrivalDate->year;
|
||||
$month = $arrivalDate->month;
|
||||
$day = $arrivalDate->day;
|
||||
|
||||
$url = "https://www.flightstats.com/v2/api-next/flight-tracker/{$airlineCode}/{$flightNumber}/{$year}/{$month}/{$day}";
|
||||
$data = $this->fetchFlightData($airlineCode, $flightNumber, $arrivalDate);
|
||||
|
||||
$response = Http::get($url);
|
||||
|
||||
if (!$response->successful()) {
|
||||
$this->warn("Failed to fetch data for {$flight->flight_number}: HTTP {$response->status()}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
$flightData = $data['data'] ?? [];
|
||||
|
||||
if (empty($flightData)) {
|
||||
if (!$data) {
|
||||
$this->warn("No flight data returned for {$airlineCode}{$flightNumber}");
|
||||
$this->notifyDataError($flight);
|
||||
continue;
|
||||
}
|
||||
|
||||
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->notifyDataError($flight);
|
||||
|
||||
$tailNumber = $flightData['positional']['flexTrack']['tailNumber'] ?? null;
|
||||
$estimatedDepartureUtc = $flightData['schedule']['estimatedActualDepartureUTC'] ?? null;
|
||||
$estimatedArrivalUtc = $flightData['schedule']['estimatedActualArrivalUTC'] ?? null;
|
||||
$equipmentCode = $flightData['additionalFlightInfo']['equipment']['iata'] ?? null;
|
||||
|
||||
$apiDepartureIata = $data['data']['departureAirport']['iata'] ?? null;
|
||||
$apiArrivalIata = $data['data']['arrivalAirport']['iata'] ?? null;
|
||||
|
||||
if ($apiDepartureIata !== $flight->departureAirport->iata_code ||
|
||||
$apiArrivalIata !== $flight->arrivalAirport->iata_code) {
|
||||
$this->warn("Airport mismatch for {$airlineCode}{$flightNumber} — API: {$apiDepartureIata}→{$apiArrivalIata}, expected: {$flight->departureAirport->iata_code}→{$flight->arrivalAirport->iata_code}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
|
||||
if ($tailNumber && $tailNumber !== $flight->aircraft_registration) {
|
||||
$updates['aircraft_registration'] = $tailNumber;
|
||||
if ($data->aircraft_registration && $data->aircraft_registration !== $flight->aircraft_registration) {
|
||||
$updates['aircraft_registration'] = $data->aircraft_registration;
|
||||
}
|
||||
|
||||
if ($estimatedDepartureUtc && Carbon::parse($estimatedDepartureUtc)->ne($flight->departure_date)) {
|
||||
$updates['departure_date'] = Carbon::parse($estimatedDepartureUtc);
|
||||
if ($data->estimated_departure_utc?->ne($flight->departure_date)) {
|
||||
$updates['departure_date'] = $data->estimated_departure_utc;
|
||||
}
|
||||
|
||||
if ($estimatedArrivalUtc && Carbon::parse($estimatedArrivalUtc)->ne($flight->arrival_date)) {
|
||||
$updates['arrival_date'] = Carbon::parse($estimatedArrivalUtc);
|
||||
if ($data->estimated_arrival_utc?->ne($flight->arrival_date)) {
|
||||
$updates['arrival_date'] = $data->estimated_arrival_utc;
|
||||
}
|
||||
|
||||
if ($data->equipment_iata) {
|
||||
$currentAircraft = $flight->aircraft;
|
||||
|
||||
if ($currentAircraft?->iata_code !== $data->equipment_iata) {
|
||||
$match = $this->guessAircraftFromIata($data->equipment_iata);
|
||||
|
||||
if ($match) {
|
||||
$updates['aircraft_id'] = $match->id;
|
||||
} else {
|
||||
Log::info("No aircraft match for IATA code {$data->equipment_iata} on flight {$airlineCode}{$flightNumber}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($updates)) {
|
||||
$flight->update($updates);
|
||||
$this->info("Updated flight {$airlineCode}{$flightNumber}: " . implode(', ', array_keys($updates)));
|
||||
|
||||
$changeDescriptions = [];
|
||||
if (isset($updates['aircraft_registration'])) {
|
||||
$changeDescriptions[] = "Registration updated to {$updates['aircraft_registration']}";
|
||||
}
|
||||
if (isset($updates['departure_date'])) {
|
||||
$changeDescriptions[] = "Departure updated to {$updates['departure_date']}";
|
||||
}
|
||||
if (isset($updates['arrival_date'])) {
|
||||
$changeDescriptions[] = "Arrival updated to {$updates['arrival_date']}";
|
||||
}
|
||||
if (isset($updates['aircraft_id'])) {
|
||||
$aircraft = Aircraft::find($updates['aircraft_id']);
|
||||
$changeDescriptions[] = "Aircraft type updated to {$aircraft->display_name_short}";
|
||||
}
|
||||
|
||||
Notification::create([
|
||||
'user_id' => $flight->user_id,
|
||||
'title' => "Flight {$airlineCode}{$flightNumber} updated",
|
||||
'body' => implode("\n", $changeDescriptions),
|
||||
]);
|
||||
} else {
|
||||
$this->info("No changes for {$airlineCode}{$flightNumber}");
|
||||
|
||||
Notification::create([
|
||||
'user_id' => $flight->user_id,
|
||||
'title' => "Flight {$airlineCode}{$flightNumber} updated — no changes",
|
||||
'body' => "Your flight was completed and no updates were made to aircraft, registration, or departure/arrival times.",
|
||||
]);
|
||||
}
|
||||
|
||||
$this->info("Flight {$airlineCode}{$flightNumber} — Tail: {$tailNumber}, Equipment: {$equipmentCode}");
|
||||
$this->info("Departure: {$estimatedDepartureUtc} | Arrival: {$estimatedArrivalUtc}");
|
||||
|
||||
$flight->update(['auto_update' => false]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\DTOs;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
readonly class FlightStatData
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $aircraft_registration,
|
||||
public ?Carbon $estimated_departure_utc,
|
||||
public ?Carbon $estimated_arrival_utc,
|
||||
public ?string $equipment_iata,
|
||||
public ?string $departure_iata,
|
||||
public ?string $arrival_iata,
|
||||
) {}
|
||||
|
||||
public static function fromApiResponse(array $flightData): self
|
||||
{
|
||||
return new self(
|
||||
aircraft_registration: $flightData['positional']['flexTrack']['tailNumber'] ?? null,
|
||||
estimated_departure_utc: isset($flightData['schedule']['estimatedActualDepartureUTC'])
|
||||
? Carbon::parse($flightData['schedule']['estimatedActualDepartureUTC'])
|
||||
: null,
|
||||
estimated_arrival_utc: isset($flightData['schedule']['estimatedActualArrivalUTC'])
|
||||
? Carbon::parse($flightData['schedule']['estimatedActualArrivalUTC'])
|
||||
: null,
|
||||
equipment_iata: $flightData['additionalFlightInfo']['equipment']['iata'] ?? null,
|
||||
departure_iata: $flightData['departureAirport']['iata'] ?? null,
|
||||
arrival_iata: $flightData['arrivalAirport']['iata'] ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,34 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$unread = $user->notifications()
|
||||
->whereNull('read_at')
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
if ($unread->isNotEmpty()) {
|
||||
return response()->json($unread);
|
||||
}
|
||||
|
||||
$recent = $user->notifications()
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return response()->json($recent);
|
||||
}
|
||||
|
||||
public function markRead(Request $request, Notification $notification)
|
||||
{
|
||||
$this->authorize('update', $notification);
|
||||
|
||||
@@ -29,6 +29,7 @@ class HandleInertiaRequests extends Middleware
|
||||
*/
|
||||
public function share(Request $request): array
|
||||
{
|
||||
|
||||
return [
|
||||
...parent::share($request),
|
||||
'logo_api_url' => config('app.logo_api_url'),
|
||||
@@ -44,6 +45,11 @@ class HandleInertiaRequests extends Middleware
|
||||
->latest()
|
||||
->get()
|
||||
: [],
|
||||
'unread_notification_count' => $request->user()?->notifications()
|
||||
->whereNull('read_at')
|
||||
->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now())
|
||||
->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
*/
|
||||
class Achievement extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'internal_name',
|
||||
|
||||
@@ -26,6 +26,11 @@ class Aircraft extends Model
|
||||
'display_name_short'
|
||||
];
|
||||
|
||||
const array IATA_ALIAS_MAP = [
|
||||
'7S8' => '73H',
|
||||
'7S9' => '73J'
|
||||
];
|
||||
|
||||
protected function displayName() : Attribute{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class IataEquipmentCode extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'iata_code',
|
||||
'icao_code',
|
||||
'description',
|
||||
];
|
||||
|
||||
public function aircraft(): HasMany
|
||||
{
|
||||
return $this->hasMany(Aircraft::class, 'designator', 'icao_code');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user