Added Notifications

This commit is contained in:
2026-05-18 14:31:53 +10:00
parent 1d5b9f340f
commit 10b5b6a5c9
18 changed files with 545 additions and 166 deletions
+7 -76
View File
@@ -7,6 +7,7 @@ use App\Models\Aircraft;
use App\Models\IataEquipmentCode;
use App\Models\Notification;
use App\Models\UserFlight;
use App\Services\FlightStatsService;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Console\Attributes\Description;
@@ -19,80 +20,10 @@ use Illuminate\Support\Facades\Log;
#[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
public function __construct(protected FlightStatsService $flightStats)
{
$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();
parent::__construct();
}
protected function notifyDataError(UserFlight $flight): void
@@ -110,7 +41,7 @@ class UpdateDepartedFlights extends Command
/**
* Execute the console command.
*/
public function handle()
public function handle(): void
{
$now = now()->utc();
$oneHourAgo = $now->copy()->subHours(1);
@@ -136,7 +67,7 @@ class UpdateDepartedFlights extends Command
$arrivalDate = $flight->arrival_date->setTimezone($flight->arrivalAirport->timezone);
$data = $this->fetchFlightData($airlineCode, $flightNumber, $arrivalDate);
$data = $this->flightStats->fetchFlightData($airlineCode, $flightNumber, $arrivalDate);
if (!$data) {
$this->warn("No flight data returned for {$airlineCode}{$flightNumber}");
@@ -170,7 +101,7 @@ class UpdateDepartedFlights extends Command
$currentAircraft = $flight->aircraft;
if ($currentAircraft?->iata_code !== $data->equipment_iata) {
$match = $this->guessAircraftFromIata($data->equipment_iata);
$match = $this->flightStats->guessAircraftFromIata($data->equipment_iata);
if ($match) {
$updates['aircraft_id'] = $match->id;
+20 -6
View File
@@ -13,21 +13,35 @@ readonly class FlightStatData
public ?string $equipment_iata,
public ?string $departure_iata,
public ?string $arrival_iata,
public array $airline_fs_codes,
public ?string $operating_fs,
) {}
public static function fromApiResponse(array $flightData): self
{
$primaryFs = $flightData['resultHeader']['carrier']['fs'] ?? null;
$operatingFs = $flightData['positional']['flexTrack']['carrierFsCode'] ?? null;
$codeshares = array_column($flightData['codeshares'] ?? [], 'fs');
$fsCodes = array_values(array_unique(array_filter([
$primaryFs,
$operatingFs,
...$codeshares,
])));
return new self(
aircraft_registration: $flightData['positional']['flexTrack']['tailNumber'] ?? null,
estimated_departure_utc: isset($flightData['schedule']['estimatedActualDepartureUTC'])
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'])
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,
equipment_iata: $flightData['additionalFlightInfo']['equipment']['iata'] ?? null,
departure_iata: $flightData['departureAirport']['iata'] ?? null,
arrival_iata: $flightData['arrivalAirport']['iata'] ?? null,
airline_fs_codes: $fsCodes,
operating_fs: $flightData['positional']['flexTrack']['carrierFsCode'] ?? null,
);
}
}
@@ -22,10 +22,17 @@ class AchievementController extends Controller
$userAchievements = $user->achievements()
->with('achievement')
->select(['achievement_id', 'progress'])
->orderBy('achievement_id')
->get()
->keyBy('achievement_id');
$unlockedByCategory = $achievements->map(fn($group) =>
$group->filter(fn($a) => $userAchievements->get($a->id)?->unlocked)->count()
);
$unlockedCount = $userAchievements->filter(fn($ua) => $ua->unlocked)->count();
return Inertia::render('UserAchievements', [
'user' => $user,
'canEdit' => auth()->id() === $user->id,
@@ -33,6 +40,9 @@ class AchievementController extends Controller
'achievements' => $achievements,
'userAchievements' => $userAchievements,
'loggedInUser' => auth()->user(),
'unlockedCount' => $unlockedCount,
'unlockedByCategory' => $unlockedByCategory,
'totalAchievements' => $achievements->flatten()->count(),
]);
}
@@ -98,6 +108,7 @@ class AchievementController extends Controller
'airlines' => $airlines,
'continents' => $continents,
'aircraft_families' => $aircraftFamilies,
'achievementCount' => $user->unlockedAchievements()->count(),
]);
}
+85 -16
View File
@@ -8,9 +8,11 @@ use App\Models\Airport;
use App\Models\CrewType;
use App\Models\FlightClass;
use App\Models\FlightReason;
use App\Models\IataEquipmentCode;
use App\Models\SeatType;
use App\Models\UserAction;
use App\Models\UserFlight;
use App\Services\FlightStatsService;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -24,7 +26,24 @@ class FlightController extends Controller
return [
'flight_number' => ['nullable', 'string', 'max:10'],
'departure_date' => ['required', 'date'],
'arrival_date' => ['required', 'date'],
'arrival_date' => ['required', 'date', function ($attribute, $value, $fail) {
$from = request()->input('from_id');
$to = request()->input('to_id');
if (!$from || !$to) return;
$departureAirport = Airport::find($from);
$arrivalAirport = Airport::find($to);
if (!$departureAirport || !$arrivalAirport) return;
$departureUtc = Carbon::createFromFormat('Y-m-d\TH:i', request()->input('departure_date'), $departureAirport->timezone)->utc();
$arrivalUtc = Carbon::createFromFormat('Y-m-d\TH:i', $value, $arrivalAirport->timezone)->utc();
if ($arrivalUtc->lessThanOrEqualTo($departureUtc)) {
$fail('The arrival time must be after the departure time, accounting for time zones.');
}
}],
'from_id' => ['required', 'integer', 'exists:airports,id'],
'to_id' => ['required', 'integer', 'exists:airports,id'],
'airline_id' => ['nullable', 'integer', 'exists:airlines,id'],
@@ -44,24 +63,74 @@ class FlightController extends Controller
{
$number = strtoupper(trim($request->query('number', '')));
// Extract the airline code prefix — letters at the start e.g. "QF" from "QF1"
preg_match('/^([A-Z]{2,3})/', $number, $matches);
$code = $matches[1] ?? null;
$isIata = strlen($code) === 2;
$codeColumn = $isIata ? 'IATA_code' : 'ICAO_code';
preg_match('/^([A-Z]{2,3})(\d+)/', $number, $matches);
$code = $matches[1] ?? null;
$flightNumber = $matches[2] ?? null;
$isIata = strlen($code) === 2;
$codeColumn = $isIata ? 'IATA_code' : 'ICAO_code';
$apiAirlineCodes = [];
$fromOptions = [];
$toOptions = [];
$aircraftOptions = [];
if (strlen($number) >= 3 && $isIata) {
$flightStatsApi = new FlightStatsService();
$flightData = $flightStatsApi->fetchFlightData($code, $flightNumber);
if ($flightData) {
$apiAirlineCodes = $flightData->airline_fs_codes;
$fromOptions = Airport::where('iata_code', $flightData->departure_iata)
->get()
->map(fn($a) => ['value' => $a->id, 'title' => $a->display_name, 'country_code' => strtolower($a->region->country->code)])
->values()
->toArray();
$toOptions = Airport::where('iata_code', $flightData->arrival_iata)
->get()
->map(fn($a) => ['value' => $a->id, 'title' => $a->display_name, 'country_code' => strtolower($a->region->country->code)])
->values()
->toArray();
if($flightData->equipment_iata){
$equipment = IataEquipmentCode::where('iata_code', $flightData->equipment_iata)->first();
if ($equipment) {
$bestGuess = $flightStatsApi->guessAircraftFromIata($flightData->equipment_iata);
$aircraftOptions = Aircraft::where('designator', $equipment->icao_code)
->get()
->sortBy(fn($a) => $a->id === $bestGuess?->id ? 0 : 1)
->map(fn($a) => ['value' => $a->id, 'title' => $a->display_name])
->values()
->toArray();
}
}
}
}
// Airlines from the typed code + any additional codes from the API, merged and deduped by id
$allCodes = array_unique(array_filter([$code, ...$apiAirlineCodes]));
$airlines = Airline::where(function ($q) use ($codeColumn, $code, $allCodes) {
$q->whereIn($codeColumn, $allCodes);
})
->get()
->unique('id')
->sortBy(function ($a) use ($code, $flightData) {
if ($a->IATA_code === $flightData?->operating_fs) return 0;
if ($a->IATA_code === $code) return 1;
return 2;
})
->map(fn($a) => ['value' => $a->id, 'title' => $a->display_name, 'logo_url' => $a->logo_url])
->values()
->toArray();
$airlines = $code
? Airline::where($codeColumn, $code)
->get()
->map(fn ($airline) => ['value' => $airline->id, 'title' => $airline->display_name, 'logo_url' => $airline->logo_url])
->values()
->toArray()
: collect()->toArray();
return response()->json([
'airline_options' => $airlines,
'from_options' => [],
'to_options' => [],
'aircraft_options' => [],
'from_options' => $fromOptions,
'to_options' => $toOptions,
'aircraft_options' => $aircraftOptions,
]);
}
+11 -27
View File
@@ -35,37 +35,21 @@ class FlightImportController extends Controller
->toArray();
}
public function getPossibleAircraft(string $aircraftQuery) {
public function getPossibleAircraft(string $aircraftQuery): array
{
preg_match('/\((\w+)\)/', $aircraftQuery, $matches);
$designator = $matches[1] ?? null;
$sortOverrides = [
'B788' => "CASE WHEN model_full_name ILIKE '%BBJ%' THEN 1 ELSE 0 END",
'B789' => "CASE WHEN model_full_name ILIKE '%BBJ%' THEN 1 ELSE 0 END",
];
if (!$designator) return [];
if(!$designator){
$aircraft = [];
} else {
$aircraft = Aircraft::when($designator, fn($query) => $query->where('designator', 'ilike', $designator))
->when(
isset($sortOverrides[$designator]),
fn($q) => $q->orderByRaw($sortOverrides[$designator])
)
->orderBy('model_full_name')
->limit(10)
->get(['id', 'manufacturer_code', 'model_full_name', 'designator'])
->map(fn($aircraft) => [
'value' => $aircraft->id,
'title' => $aircraft->display_name,
])
->values()
->toArray();
}
return $aircraft;
return Aircraft::where('designator', 'ilike', $designator)
->orderByDesc('preferred')
->orderBy('model_full_name')
->limit(10)
->get(['id', 'manufacturer_code', 'model_full_name', 'designator', 'preferred'])
->map(fn($a) => ['value' => $a->id, 'title' => $a->display_name])
->values()
->toArray();
}
public function getPossibleAirports(string $airportQuery) {
+3
View File
@@ -15,10 +15,12 @@ class Aircraft extends Model
'engine_type',
'engine_count',
'wtc',
'preferred'
];
protected $casts = [
'engine_count' => 'integer',
'preferred' => 'boolean'
];
protected $appends = [
@@ -51,6 +53,7 @@ class Aircraft extends Model
'A380' => ['A380', 'A388'],
];
protected function displayName() : Attribute{
return Attribute::make(
get: function () {
+20
View File
@@ -41,6 +41,26 @@ class User extends Authenticatable
return $this->hasMany(UserAchievement::class);
}
public function unlockedAchievements(): HasMany
{
return $this->achievements()
->join('achievements', 'achievements.id', '=', 'user_achievements.achievement_id')
->where(function ($query) {
$query
// Non-progressive achievements: always count
->where(function ($q) {
$q->where('achievements.progressive', false)
->orWhereNull('achievements.progressive');
})
// Progressive achievements: only if progress >= threshold
->orWhere(function ($q) {
$q->where('achievements.progressive', true)
->whereNotNull('achievements.threshold')
->whereColumn('user_achievements.progress', '>=', 'achievements.threshold');
});
});
}
public function resolveRouteBinding($value, $field = null): ?User
{
return $this->where('name', 'ilike', $value)->firstOrFail();
+16
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -26,6 +27,21 @@ class UserAchievement extends Model
'progress' => 'integer',
];
protected $appends = [
'unlocked',
];
protected function unlocked(): Attribute
{
return Attribute::make(
get: function () {
if (!$this->achievement) return false;
if (!$this->achievement->progressive || !$this->achievement->threshold) return true;
return ($this->progress ?? 0) >= $this->achievement->threshold;
}
);
}
// ---------------------------------------------------------------
// Relationships
// ---------------------------------------------------------------
@@ -74,6 +74,7 @@ class GeneralFlyingChecker extends BaseChecker
$this->awardProgress((int) $totalDistance, 'general_flying.circumference_of_the_earth');
$this->awardProgress((int) $totalDistance, 'general_flying.to_the_moon');
$this->awardProgress((int) $totalDistance, 'general_flying.gigametre');
$this->awardProgress($count,'general_flying.10_flights');
$this->awardProgress($count,'general_flying.50_flights');
+111
View File
@@ -0,0 +1,111 @@
<?php
namespace App\Services;
use App\DTOs\FlightStatData;
use App\Models\Aircraft;
use App\Models\IataEquipmentCode;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class FlightStatsService
{
public function fetchOtherDays(string $airlineCode, string $flightNumber): array
{
$url = sprintf(
'https://www.flightstats.com/v2/api-next/flight-tracker/other-days/%s/%s',
$airlineCode,
$flightNumber,
);
$response = Http::withOptions([
'verify' => config('app.verify_ssl'),
])->get($url);
if (!$response->successful()) {
return [];
}
$data = $response->json('data');
if (!is_array($data)) {
return [];
}
return $data;
}
public function fetchFlightData(string $airlineCode, string $flightNumber, ?CarbonImmutable $date = null): ?FlightStatData
{
$specificDate = $date !== null;
$date ??= now()->utc()->toImmutable();
$data = $this->fetchForDate($airlineCode, $flightNumber, $date);
if ($data || $specificDate) return $data;
$otherDays = $this->fetchOtherDays($airlineCode, $flightNumber);
$pastDays = collect($otherDays)
->filter(fn($day) => !empty($day['flights']))
->sortByDesc(fn($day) => $day['year'] . $day['date1']);
foreach ($pastDays as $day) {
$pastDate = CarbonImmutable::createFromFormat('Y-d-M', $day['year'] . '-' . $day['date1']);
$result = $this->fetchForDate($airlineCode, $flightNumber, $pastDate);
if ($result) return $result;
}
return null;
}
protected function fetchForDate(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);
}
public function guessAircraftFromIata(string $iataCode): ?Aircraft
{
$equipment = IataEquipmentCode::where('iata_code', $iataCode)->first();
if (!$equipment) {
Log::info("Unknown IATA equipment code: {$iataCode}");
return null;
}
$aircraft = Aircraft::where('designator', $equipment->icao_code)
->orderByDesc('preferred')
->orderBy('id')
->first();
if (!$aircraft) {
Log::info("No aircraft found for ICAO: {$equipment->icao_code} (IATA: {$iataCode})");
}
return $aircraft;
}
}