Compare commits
144
Commits
5760f8e576
..
1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ac580bb06 | ||
|
|
85eeba982f | ||
|
|
d765161b9a | ||
|
|
4f3b7bd839 | ||
|
|
03bd74580f | ||
|
|
6d0ecb19c9 | ||
|
|
c6065577af | ||
|
|
1b92c662f1 | ||
|
|
df7fa1a598 | ||
|
|
16184d9c83 | ||
|
|
f422a6e38e | ||
|
|
598c10c926 | ||
|
|
85c587cb1e | ||
|
|
30d5feaf63 | ||
|
|
00bd30fc5e | ||
|
|
4c98b33fb4 | ||
|
|
027437dc9b | ||
|
|
84379baf6a | ||
|
|
d1b50e2f1a | ||
|
|
b12a6e4fd9 | ||
|
|
25d413283a | ||
|
|
940bf2a367 | ||
|
|
6978510147 | ||
|
|
6cadd3d9e6 | ||
|
|
e551e202d5 | ||
|
|
164b590a6c | ||
|
|
6e69bea82a | ||
|
|
fcba639bae | ||
|
|
cd1538ba12 | ||
|
|
a39589ee6f | ||
|
|
07e2796e09 | ||
|
|
5850c849d0 | ||
|
|
05ca994253 | ||
|
|
6fad966b7e | ||
|
|
badb4dc46f | ||
|
|
3aba428d2a | ||
|
|
a270913931 | ||
|
|
a753bffaf8 | ||
|
|
e24d3ceaec | ||
|
|
30e4d5ffb3 | ||
|
|
9dbacaa4ac | ||
|
|
0f12250644 | ||
|
|
a6812ad95c | ||
|
|
906f8cda57 | ||
|
|
09841ba1f7 | ||
|
|
61ff0c5002 | ||
|
|
05c16147ee | ||
|
|
57b015eb18 | ||
|
|
150c34bfb8 | ||
|
|
10d6ee8dee | ||
|
|
3eb3971d79 | ||
|
|
3bd2bda84c | ||
|
|
05a6d1da0e | ||
|
|
f05ea2fd97 | ||
|
|
1846cb6a6d | ||
|
|
e1bed676e4 | ||
|
|
10b5b6a5c9 | ||
|
|
1d5b9f340f | ||
|
|
69d72e0912 | ||
|
|
c7fe3268c7 | ||
|
|
016e752dcd | ||
|
|
eabf715bf2 | ||
|
|
217a971360 | ||
|
|
f2b2eaaabe | ||
|
|
7e3af37a46 | ||
|
|
b94b1d8ec2 | ||
|
|
14aed7bf6e | ||
|
|
f6d5b97784 | ||
|
|
1821b99524 | ||
|
|
f8bac7f85a | ||
|
|
924e03334c | ||
|
|
de183995b6 | ||
|
|
678096b463 | ||
|
|
110ed5b984 | ||
|
|
bd8ef98d30 | ||
|
|
d68e23e93a | ||
|
|
9e995eedef | ||
|
|
a57775e141 | ||
|
|
e007824fa9 | ||
|
|
061ee9dd07 | ||
|
|
5deefcbfb3 | ||
|
|
8d7d8f02d3 | ||
|
|
4244b8835d | ||
|
|
d90f338321 | ||
|
|
63d6fb9e76 | ||
|
|
147bf43f09 | ||
|
|
5066052013 | ||
|
|
a535521834 | ||
|
|
2a657bbbf7 | ||
|
|
4110b52ba5 | ||
|
|
a9aa65f0d2 | ||
|
|
0f84ec023e | ||
|
|
f335951784 | ||
|
|
95624f345c | ||
|
|
e83fd3bdca | ||
|
|
7a07616f03 | ||
|
|
43f5c8ac3e | ||
|
|
79469c02cf | ||
|
|
2ad8c65b86 | ||
|
|
b53c92de36 | ||
|
|
968272754e | ||
|
|
8da717a400 | ||
|
|
fcbf021af7 | ||
|
|
9631e7949d | ||
|
|
d0fe6d78d3 | ||
|
|
b4e2caad07 | ||
|
|
e55c771318 | ||
|
|
8a1581641b | ||
|
|
bf34c20d85 | ||
|
|
509efbe821 | ||
|
|
7f62c31456 | ||
|
|
30b56ece8a | ||
|
|
548e838e81 | ||
|
|
baff2066e6 | ||
|
|
6bb6ff7f71 | ||
|
|
4ed4110ba0 | ||
|
|
236f075df4 | ||
|
|
bfe246ab44 | ||
|
|
877caa3291 | ||
|
|
6a88d0cdfb | ||
|
|
063a393168 | ||
|
|
89135a554a | ||
|
|
1d77ce140b | ||
|
|
d7140f1554 | ||
|
|
8ec4e92541 | ||
|
|
4366c8f6ea | ||
|
|
d0191dbabd | ||
|
|
b6ffeea8ad | ||
|
|
2056f6b03a | ||
|
|
baa08f0b3a | ||
|
|
65f009d222 | ||
|
|
0b14b40fd6 | ||
|
|
4adba94109 | ||
|
|
6bc72d88c2 | ||
|
|
cfb0ab0298 | ||
|
|
98d24e9b38 | ||
|
|
fc9e67ad2b | ||
|
|
2c51a10bb0 | ||
|
|
dfa68b0ad9 | ||
|
|
e4e5afafa7 | ||
|
|
dd69f1e623 | ||
|
|
22d978f6ca | ||
|
|
e7ad41aede | ||
|
|
70f7943c0f |
+4
-1
@@ -1,4 +1,7 @@
|
|||||||
.env
|
.env
|
||||||
vendor/
|
vendor/
|
||||||
.git/
|
.git/
|
||||||
storage/app/private/images
|
storage/app/private
|
||||||
|
node_modules
|
||||||
|
public/hot
|
||||||
|
public/build
|
||||||
|
|||||||
+28
-8
@@ -4,6 +4,11 @@ APP_KEY=
|
|||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
|
APP_DOMAIN=flightsgoneby.test
|
||||||
|
API_DOMAIN=api.flightsgoneby.test
|
||||||
|
|
||||||
|
TRUSTED_FRONTEND_ORIGINS=https://app.example.com
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
@@ -27,13 +32,20 @@ DB_DATABASE=flightsapi
|
|||||||
DB_USERNAME=root
|
DB_USERNAME=root
|
||||||
DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
|
|
||||||
|
PRODUCTION_DB_CONNECTION=
|
||||||
|
PRODUCTION_DB_HOST=
|
||||||
|
PRODUCTION_DB_PORT=
|
||||||
|
PRODUCTION_DB_DATABASE=
|
||||||
|
PRODUCTION_DB_USERNAME=
|
||||||
|
PRODUCTION_DB_PASSWORD=
|
||||||
|
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
@@ -47,14 +59,14 @@ REDIS_HOST=127.0.0.1
|
|||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
MAIL_MAILER=log
|
MAIL_MAILER=mailgun
|
||||||
MAIL_SCHEME=null
|
MAIL_FROM_ADDRESS="test@yourddomain.com"
|
||||||
MAIL_HOST=127.0.0.1
|
|
||||||
MAIL_PORT=2525
|
|
||||||
MAIL_USERNAME=null
|
|
||||||
MAIL_PASSWORD=null
|
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
LOCAL_DISK_ROOT=
|
||||||
|
TEST_EMAIL=""
|
||||||
|
MAILGUN_DOMAIN=
|
||||||
|
MAILGUN_SECRET=
|
||||||
|
MAILGUN_ENDPOINT=api.mailgun.net
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
@@ -62,4 +74,12 @@ AWS_DEFAULT_REGION=us-east-1
|
|||||||
AWS_BUCKET=
|
AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
|
BROADCAST_CONNECTION=reverb
|
||||||
|
REVERB_APP_ID=#openssl rand -hex 16)
|
||||||
|
REVERB_APP_KEY=#openssl rand -hex 20)
|
||||||
|
REVERB_APP_SECRET=#openssl rand -hex 20)
|
||||||
|
REVERB_HOST=#website.com
|
||||||
|
REVERB_PORT=8080
|
||||||
|
REVERB_SCHEME=http
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|||||||
+3
-1
@@ -22,4 +22,6 @@
|
|||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
pgdata/
|
pgdata/
|
||||||
|
/public/sw.js
|
||||||
|
/public/manifest.webmanifest
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\UserAction;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
#[Signature('app:flight-feed-update')]
|
||||||
|
#[Description('Command description')]
|
||||||
|
class FlightFeedUpdate extends Command
|
||||||
|
{
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$time = now('UTC')->startOfMinute();
|
||||||
|
|
||||||
|
$this->logFlightActions('flight_departing', UserFlight::where('departure_date', $time)->get());
|
||||||
|
$this->logFlightActions('flight_arriving', UserFlight::where('arrival_date', $time)->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $type
|
||||||
|
* @param Collection<UserFlight> $flights
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function logFlightActions(string $type, Collection $flights): void
|
||||||
|
{
|
||||||
|
|
||||||
|
foreach ($flights as $flight) {
|
||||||
|
|
||||||
|
UserAction::create([
|
||||||
|
'user_id' => $flight->user_id,
|
||||||
|
'type' => $type,
|
||||||
|
'data' => [
|
||||||
|
'flight' => $flight->snapshot($flight->id),
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\DTOs\FlightStatData;
|
||||||
|
use App\Models\Aircraft;
|
||||||
|
use App\Models\Notification;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use App\Services\FlightStatsService;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
#[Signature('app:get-aircraft-info-for-arrived-flights')]
|
||||||
|
#[Description('Search for flights that have arrived recently and try and determine any new aircraft info.')]
|
||||||
|
class GetAircraftInfoForArrivedFlights extends Command
|
||||||
|
{
|
||||||
|
public function __construct(protected FlightStatsService $flightStats)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$now = now()->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
|
||||||
|
{
|
||||||
|
[$airlineCode, $flightNumber] = $this->splitFlightNumber($flight);
|
||||||
|
|
||||||
|
// Case 1: unparseable flight number on our end — disable and move on, no notification.
|
||||||
|
if ($airlineCode === null) {
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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}");
|
||||||
|
Log::warning("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;
|
||||||
|
$updates['departure_processed_at'] = $data->estimated_departure_utc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data->estimated_arrival_utc?->ne($originalArrival)) {
|
||||||
|
$updates['arrival_date'] = $data->estimated_arrival_utc;
|
||||||
|
$updates['arrival_processed_at'] = $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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Airport;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
#[Signature('app:populate-airport-timezones')]
|
||||||
|
#[Description('Populate timezone data for all airports using lat/lng')]
|
||||||
|
class PopulateAirportTimezones extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
Airport::whereNull('timezone')->chunkById(100, function ($airports) {
|
||||||
|
foreach ($airports as $airport) {
|
||||||
|
$zoneName = null;
|
||||||
|
$attempts = 0;
|
||||||
|
|
||||||
|
while ($zoneName === null && $attempts < 3) {
|
||||||
|
$response = Http::withoutVerifying()
|
||||||
|
->withOptions(['allow_redirects' => false])
|
||||||
|
->get('http://vip.timezonedb.com/v2.1/get-time-zone', [
|
||||||
|
'key' => config('app.timezone_api_key'),
|
||||||
|
'format' => 'json',
|
||||||
|
'by' => 'position',
|
||||||
|
'lat' => $airport->latitude_deg,
|
||||||
|
'lng' => $airport->longitude_deg,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$zoneName = $response->json('zoneName');
|
||||||
|
$attempts++;
|
||||||
|
|
||||||
|
if ($zoneName === null) {
|
||||||
|
$this->warn("✗ {$airport->name} — attempt {$attempts} failed: " . $response->body());
|
||||||
|
sleep(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($zoneName) {
|
||||||
|
$airport->update(['timezone' => $zoneName]);
|
||||||
|
$this->info("✓ {$airport->name} — {$zoneName}");
|
||||||
|
} else {
|
||||||
|
$this->warn("✗ {$airport->name} — giving up after {$attempts} attempts".$response->body());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info('Done!');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
#[Signature('app:recalculate-achivements')]
|
||||||
|
#[Description('Recalculate achievements for all users')]
|
||||||
|
class RecalculateAchievements extends Command
|
||||||
|
{
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$users = User::all();
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$user->calculateAchievements();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class UpDb extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'db:updb
|
||||||
|
{--no-backup : Skip saving a local backup before wiping}
|
||||||
|
{--force : Skip confirmation prompt}';
|
||||||
|
|
||||||
|
protected $description = 'Download the production database and replace the local database with it';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
if (! $this->option('force') && ! $this->confirm('This will wipe your local database and replace it with production. Are you sure?')) {
|
||||||
|
$this->info('Aborted.');
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$local = $this->connectionConfig('local');
|
||||||
|
$production = $this->connectionConfig('production');
|
||||||
|
|
||||||
|
$timestamp = now()->format('Y_m_d_His');
|
||||||
|
$dumpPath = storage_path("app/private/db_dumps/production_{$timestamp}.dump");
|
||||||
|
$backupPath = storage_path("app/private/db_dumps/local_backup_{$timestamp}.dump");
|
||||||
|
|
||||||
|
if (! is_dir(storage_path('app/private/db_dumps'))) {
|
||||||
|
mkdir(storage_path('app/private/db_dumps'), 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Dump production
|
||||||
|
$this->info('Dumping production database...');
|
||||||
|
$dumpResult = $this->pgDump($production, $dumpPath);
|
||||||
|
|
||||||
|
if ($dumpResult !== 0) {
|
||||||
|
$this->error('Failed to dump production database.');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Production dump saved to: {$dumpPath}");
|
||||||
|
|
||||||
|
// Step 2: Back up local (unless skipped)
|
||||||
|
if (! $this->option('no-backup')) {
|
||||||
|
$this->info('Backing up local database...');
|
||||||
|
$backupResult = $this->pgDump($local, $backupPath);
|
||||||
|
|
||||||
|
if ($backupResult !== 0) {
|
||||||
|
$this->error('Failed to back up local database. Aborting to be safe. Use --no-backup to skip.');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Local backup saved to: {$backupPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Drop and recreate the local database
|
||||||
|
$this->info('Wiping local database...');
|
||||||
|
$this->dropAndRecreateLocalDatabase($local);
|
||||||
|
|
||||||
|
// Step 4: Restore production dump into local
|
||||||
|
$this->info('Restoring production dump to local database...');
|
||||||
|
$restoreResult = $this->pgRestore($local, $dumpPath);
|
||||||
|
|
||||||
|
if ($restoreResult !== 0) {
|
||||||
|
$this->error('Restore failed. Your local backup is at: ' . ($this->option('no-backup') ? 'N/A (backup was skipped)' : $backupPath));
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Done. Local database now mirrors production.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function connectionConfig(string $type): array
|
||||||
|
{
|
||||||
|
$connection = $type === 'production' ? 'pgsql_production' : config('database.default');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'host' => config("database.connections.{$connection}.host"),
|
||||||
|
'port' => config("database.connections.{$connection}.port"),
|
||||||
|
'database' => config("database.connections.{$connection}.database"),
|
||||||
|
'username' => config("database.connections.{$connection}.username"),
|
||||||
|
'password' => config("database.connections.{$connection}.password"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pgDump(array $config, string $outputPath): int
|
||||||
|
{
|
||||||
|
$pgpass = $this->writePgPass($config);
|
||||||
|
|
||||||
|
$command = sprintf(
|
||||||
|
'pg_dump -Fc -h %s -p %s -U %s %s -f %s',
|
||||||
|
escapeshellarg($config['host']),
|
||||||
|
escapeshellarg($config['port']),
|
||||||
|
escapeshellarg($config['username']),
|
||||||
|
escapeshellarg($config['database']),
|
||||||
|
escapeshellarg($outputPath),
|
||||||
|
);
|
||||||
|
|
||||||
|
$resultCode = $this->runWithPgPass($pgpass, $command);
|
||||||
|
|
||||||
|
unlink($pgpass);
|
||||||
|
|
||||||
|
return $resultCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pgRestore(array $config, string $dumpPath): int
|
||||||
|
{
|
||||||
|
$pgpass = $this->writePgPass($config);
|
||||||
|
|
||||||
|
$command = sprintf(
|
||||||
|
'pg_restore -h %s -p %s -U %s -d %s --no-owner --no-privileges %s',
|
||||||
|
escapeshellarg($config['host']),
|
||||||
|
escapeshellarg($config['port']),
|
||||||
|
escapeshellarg($config['username']),
|
||||||
|
escapeshellarg($config['database']),
|
||||||
|
escapeshellarg($dumpPath),
|
||||||
|
);
|
||||||
|
|
||||||
|
$resultCode = $this->runWithPgPass($pgpass, $command);
|
||||||
|
|
||||||
|
unlink($pgpass);
|
||||||
|
|
||||||
|
return $resultCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a temporary pgpass file and return its path.
|
||||||
|
* This is the cross-platform alternative to PGPASSWORD which doesn't work on Windows.
|
||||||
|
*/
|
||||||
|
private function writePgPass(array $config): string
|
||||||
|
{
|
||||||
|
// Escape any colons or backslashes in the password per pgpass format rules
|
||||||
|
$password = str_replace(['\\', ':'], ['\\\\', '\\:'], $config['password']);
|
||||||
|
$content = "{$config['host']}:{$config['port']}:*:{$config['username']}:{$password}";
|
||||||
|
$path = tempnam(sys_get_temp_dir(), 'pgpass_');
|
||||||
|
|
||||||
|
file_put_contents($path, $content);
|
||||||
|
chmod($path, 0600);
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runWithPgPass(string $pgpassFile, string $command): int
|
||||||
|
{
|
||||||
|
// PGPASSFILE is supported on all platforms including Windows
|
||||||
|
putenv("PGPASSFILE={$pgpassFile}");
|
||||||
|
passthru($command, $resultCode);
|
||||||
|
putenv('PGPASSFILE');
|
||||||
|
|
||||||
|
return $resultCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dropAndRecreateLocalDatabase(array $config): void
|
||||||
|
{
|
||||||
|
$database = $config['database'];
|
||||||
|
|
||||||
|
// Connect to the postgres maintenance database to drop/recreate
|
||||||
|
$pdo = new \PDO(
|
||||||
|
"pgsql:host={$config['host']};port={$config['port']};dbname=postgres",
|
||||||
|
$config['username'],
|
||||||
|
$config['password'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$pdo->exec("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = " . $pdo->quote($database));
|
||||||
|
$pdo->exec("DROP DATABASE IF EXISTS " . '"' . $database . '"');
|
||||||
|
$pdo->exec("CREATE DATABASE " . '"' . $database . '"');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Airline;
|
||||||
|
use App\Models\Alliance;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
#[Signature('app:update-alliances')]
|
||||||
|
#[Description('Looks for any changes to Alliance membership and updates automatically')]
|
||||||
|
class UpdateAlliances extends Command
|
||||||
|
{
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$day = now('UTC')->startOfMinute()->toDateString();
|
||||||
|
|
||||||
|
$schedules = [
|
||||||
|
'2026-12-17' => [
|
||||||
|
['airline' => 'asiana', 'alliance' => null],
|
||||||
|
],
|
||||||
|
'2027-12-12' => [
|
||||||
|
['airline' => 'philippine-airlines', 'alliance' => 'oneworld'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!isset($schedules[$day])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($schedules[$day] as $change) {
|
||||||
|
$allianceId = $change['alliance']
|
||||||
|
? Alliance::whereInternalName($change['alliance'])->value('id')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
Airline::whereInternalName($change['airline'])->update(['alliance' => $allianceId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Alliance::recalculateChallengeThresholds();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Enums\FlightNotificationType;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use App\Services\FollowerNotificationService;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
#[Signature('app:update-arrived-flights')]
|
||||||
|
#[Description('When a flight arrives, update the arrival_processed_at timestamp and notify followers')]
|
||||||
|
class UpdateArrivedFlights extends Command
|
||||||
|
{
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$now = now()->utc();
|
||||||
|
|
||||||
|
$userFlights = UserFlight::where('arrival_date', '<=', $now->toDateTimeString())
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->whereNull('arrival_processed_at')
|
||||||
|
->orWhereColumn('arrival_processed_at', '<', 'arrival_date');
|
||||||
|
})
|
||||||
|
->get();
|
||||||
|
|
||||||
|
echo $userFlights->count() . " flights to update\n";
|
||||||
|
foreach ($userFlights as $flight) {
|
||||||
|
$flight->update(['arrival_processed_at' => $now]);
|
||||||
|
new FollowerNotificationService()->notifyFlightEvent($flight, FlightNotificationType::Arrived);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Enums\FlightNotificationType;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use App\Services\FollowerNotificationService;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
#[Signature('app:update-departed-flights')]
|
||||||
|
#[Description('Command description')]
|
||||||
|
class UpdateDepartedFlights extends Command
|
||||||
|
{
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$now = now()->utc();
|
||||||
|
|
||||||
|
$userFlights = UserFlight::where('departure_date', '<=', $now->toDateTimeString())
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->whereNull('departure_processed_at')
|
||||||
|
->orWhereColumn('departure_processed_at', '<', 'departure_date');
|
||||||
|
})
|
||||||
|
->get();
|
||||||
|
|
||||||
|
|
||||||
|
foreach ($userFlights as $flight) {
|
||||||
|
$flight->update(['departure_processed_at' => $now]);
|
||||||
|
new FollowerNotificationService()->notifyFlightEvent($flight, FlightNotificationType::Departed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?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 ?Carbon $scheduled_departure_local,
|
||||||
|
public ?Carbon $scheduled_arrival_local,
|
||||||
|
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,
|
||||||
|
])));
|
||||||
|
|
||||||
|
$departureTimezone = $flightData['departureAirport']['timeZoneRegionName'] ?? null;
|
||||||
|
$arrivalTimezone = $flightData['arrivalAirport']['timeZoneRegionName'] ?? null;
|
||||||
|
|
||||||
|
$scheduledDepartureLocal = isset($flightData['schedule']['scheduledDeparture'], $departureTimezone)
|
||||||
|
? Carbon::parse($flightData['schedule']['scheduledDeparture'], $departureTimezone)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$scheduledArrivalLocal = isset($flightData['schedule']['scheduledArrival'], $arrivalTimezone)
|
||||||
|
? Carbon::parse($flightData['schedule']['scheduledArrival'], $arrivalTimezone)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
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,
|
||||||
|
scheduled_departure_local: $scheduledDepartureLocal,
|
||||||
|
scheduled_arrival_local: $scheduledArrivalLocal,
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\DTOs;
|
||||||
|
|
||||||
|
readonly class MissingLivery
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $airline_name,
|
||||||
|
public readonly string $aircraft_display_name,
|
||||||
|
public readonly string $filename,
|
||||||
|
public readonly string $clipboard_text,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
|
||||||
|
enum FlightNotificationType: string
|
||||||
|
{
|
||||||
|
case Booked = 'booked';
|
||||||
|
case Logged = 'logged';
|
||||||
|
case Cancelled = 'cancelled';
|
||||||
|
case Departed = 'departed';
|
||||||
|
case Arrived = 'arrived';
|
||||||
|
|
||||||
|
public function settingKey(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Booked => 'notify_on_upcoming_flight_added',
|
||||||
|
self::Logged => 'notify_on_historic_flight_added',
|
||||||
|
self::Cancelled => 'notify_on_flight_cancellation',
|
||||||
|
self::Departed => 'notify_on_flight_departure',
|
||||||
|
self::Arrived => 'notify_on_flight_arrival',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function title(User $user): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Booked => "{$user->name} booked a new flight",
|
||||||
|
self::Logged => "{$user->name} logged a new flight",
|
||||||
|
self::Cancelled => "{$user->name} cancelled a flight",
|
||||||
|
self::Departed => "{$user->name} is taking off!",
|
||||||
|
self::Arrived => "{$user->name} has landed!",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function body(UserFlight $flight): string
|
||||||
|
{
|
||||||
|
$flightNumber = $flight->flight_number ? "{$flight->flight_number} from" : 'From';
|
||||||
|
|
||||||
|
return match ($this) {
|
||||||
|
self::Departed => "{$flightNumber} {$flight->departureAirport->municipality} → {$flight->arrivalAirport->municipality}",
|
||||||
|
self::Arrived => "In {$flight->arrivalAirport->municipality}",
|
||||||
|
default => "{$flightNumber} {$flight->departureAirport->municipality} ({$flight->departureAirport->display_code}) → "
|
||||||
|
. "{$flight->arrivalAirport->municipality} ({$flight->arrivalAirport->display_code}) on {$flight->departure_date_display}",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function url(UserFlight $flight): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Cancelled => route('profile.view', [$flight->user->name]),
|
||||||
|
self::Departed, self::Arrived => route('profile.flight', ['user' => $flight->user->name, 'userFlight' => $flight->id]),
|
||||||
|
default => route('profile.departure-board', [$flight->user->name, $flight->id]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function forCreation(UserFlight $flight): self
|
||||||
|
{
|
||||||
|
return $flight->departure_date->isFuture() ? self::Booked : self::Logged;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Notification;
|
||||||
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class NotificationCreated implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public Notification $notification) {}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new PrivateChannel('App.Models.User.' . $this->notification->user_id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'notification.created';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
|
||||||
|
use App\Models\IgnoredMissingLivery;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use App\Services\AdminService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class AdminController extends Controller
|
||||||
|
{
|
||||||
|
|
||||||
|
public function __construct(private readonly AdminService $adminService){}
|
||||||
|
|
||||||
|
function staticAdminData(){
|
||||||
|
return [
|
||||||
|
'missingLiveryCount' => $this->adminService->getMissingLiveries()->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dashboard(){
|
||||||
|
return inertia('Admin/Dashboard', [
|
||||||
|
'title' => 'Admin Control Panel',
|
||||||
|
'userCount' => User::count(),
|
||||||
|
'oneWeekUserGrowth' => User::where('created_at', '>=', Carbon::now()->subWeek())->count(),
|
||||||
|
'flightCount' => UserFlight::count(),
|
||||||
|
'oneWeekFlightGrowth' => UserFlight::where('created_at', '>=', Carbon::now()->subWeek())->count(),
|
||||||
|
'latestUser' => User::latest()->first()->name,
|
||||||
|
...$this->staticAdminData(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileMissingLiveries(){
|
||||||
|
$missingLiveries = $this->adminService->getMissingLiveries();
|
||||||
|
|
||||||
|
return Inertia::render('Admin/MissingLiveries', [
|
||||||
|
'title' => $missingLiveries->count() . ' Missing Liveries',
|
||||||
|
'missingLiveries' => $missingLiveries,
|
||||||
|
...$this->staticAdminData(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ignoreMissingLivery(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'filename' => 'required|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
IgnoredMissingLivery::createOrFirst($validated);
|
||||||
|
|
||||||
|
return back();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Airline;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AirlineController extends Controller
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class AircraftApiController extends Controller
|
||||||
|
{
|
||||||
|
public function getLivery(string $aircraftDesignator){
|
||||||
|
$path = "images/livery_templates/{$aircraftDesignator}.png";
|
||||||
|
return $this->imageIfExists($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\ApiController;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Airline;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class AirlineApiController extends ApiController
|
||||||
|
{
|
||||||
|
const array CONDOR_LOGOS = ['BEACH', 'ISLAND', 'PASSION', 'SEA', 'SUNSHINE'];
|
||||||
|
|
||||||
|
public function getAirlineLogo(?Airline $airline)
|
||||||
|
{
|
||||||
|
$logoFile = $airline?->logo ?? 'blank.png';
|
||||||
|
$cacheLimit = 60 * 60 * 24;
|
||||||
|
|
||||||
|
if ($airline?->internal_name == 'condor') {
|
||||||
|
$logoKey = array_rand(self::CONDOR_LOGOS);
|
||||||
|
$logoFile = 'DE_' . self::CONDOR_LOGOS[$logoKey] . '.png';
|
||||||
|
$cacheLimit = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = 'images/logos/tail/' . $logoFile;
|
||||||
|
if (!Storage::disk('local')->exists($path)) {
|
||||||
|
$path = 'images/logos/tail/blank.png';
|
||||||
|
}
|
||||||
|
|
||||||
|
$fullPath = Storage::disk('local')->path($path);
|
||||||
|
$lastModified = filemtime($fullPath);
|
||||||
|
|
||||||
|
return response()->file($fullPath, [
|
||||||
|
'Content-Type' => 'image/png',
|
||||||
|
'Cache-Control' => 'public, max-age='.$cacheLimit, // 24 hours
|
||||||
|
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT',
|
||||||
|
'ETag' => md5($path . $lastModified),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function getLogoByInternalName(string $internalName){
|
||||||
|
$airline = Airline::where('internal_name', $internalName)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $this->getAirlineLogo($airline);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLivery(string $airlineInternalName, string $aircraftDesignator){
|
||||||
|
$path = "images/liveries/{$airlineInternalName}_{$aircraftDesignator}.png";
|
||||||
|
|
||||||
|
return $this->imageIfExists($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAirlineData(Airline $airline){
|
||||||
|
$countryCode = $airline->country->code;
|
||||||
|
|
||||||
|
$result = $airline->toArray();
|
||||||
|
unset($result['id']);
|
||||||
|
unset($result['logo']);
|
||||||
|
unset($result['country_id']);
|
||||||
|
unset($result['country']);
|
||||||
|
$result['slug'] = $result['internal_name'];
|
||||||
|
unset($result['internal_name']);
|
||||||
|
$result['country_code'] = $countryCode;
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getByCode(string $code){
|
||||||
|
$lookupColumn = strlen($code) === 3 ? 'icao_code' : 'iata_code';
|
||||||
|
$airlines = Airline::where($lookupColumn, strtoupper($code))->get()->map(fn($airline) => $this->parseAirlineData($airline));
|
||||||
|
return response()->json($airlines);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function get(string $internalName){
|
||||||
|
$airline = Airline::where('internal_name', $internalName)->first();
|
||||||
|
return response()->json($this->parseAirlineData($airline));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\ApiController;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
class UserApiController extends ApiController
|
||||||
|
{
|
||||||
|
public function nextFlight(?User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $user?->exists ? $user : auth()->user();
|
||||||
|
|
||||||
|
if (!$user->id) {
|
||||||
|
return response()->json(['message' => 'User not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Gate::denies('viewProfileData', $user)) {
|
||||||
|
return response()->json(['message' => 'Cannot access private user.'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$flight = UserFlight::with(['departureAirport', 'arrivalAirport', 'airline', 'aircraft'])
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('departure_date', '>', now()->utc())
|
||||||
|
->orderBy('departure_date', 'asc')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$flight) {
|
||||||
|
return response()->json(['message' => 'No upcoming flights found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$departure = Carbon::parse($flight->departure_date)->setTimezone($flight->departureAirport->timezone);
|
||||||
|
$arrival = Carbon::parse($flight->arrival_date)->setTimezone($flight->arrivalAirport->timezone);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'departureAirportCode' => $flight->departureAirport->iata_code,
|
||||||
|
'departureCity' => $flight->departureAirport->municipality,
|
||||||
|
'departureDateReadable' => $departure->format('F j'),
|
||||||
|
'departureTime' => $departure->format('H:i'),
|
||||||
|
'departureDateUtc' => $flight->departure_date,
|
||||||
|
'arrivalAirportCode' => $flight->arrivalAirport->iata_code,
|
||||||
|
'arrivalCity' => $flight->arrivalAirport->municipality,
|
||||||
|
'arrivalDateReadable' => $arrival->format('F j'),
|
||||||
|
'arrivalTime' => $arrival->format('H:i'),
|
||||||
|
'arrivalDateUtc' => $flight->arrival_date,
|
||||||
|
'flightNumber' => $flight->flight_number,
|
||||||
|
'airlineName' => $flight->airline?->name,
|
||||||
|
'aircraftType' => $flight->aircraft?->manufacturer_code . ' ' . $flight->aircraft?->model_full_name,
|
||||||
|
'logoUrl' => $flight->airline?->logo_url ?? '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewableFlights(User $user)
|
||||||
|
{
|
||||||
|
if (Gate::denies('viewProfileData', $user)) {
|
||||||
|
return collect([]);
|
||||||
|
}
|
||||||
|
return $user->flightsWithRelationshipsLoaded();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewableDepartedFlights(User $user)
|
||||||
|
{
|
||||||
|
if (Gate::denies('viewProfileData', $user)) {
|
||||||
|
return collect([]);
|
||||||
|
}
|
||||||
|
return $user->flightsWithRelationshipsLoaded('departed');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ApiController extends Controller
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Auth\LoginRequest;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class AuthenticatedSessionController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the login view.
|
||||||
|
*/
|
||||||
|
public function create(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Auth/Login', [
|
||||||
|
'canResetPassword' => Route::has('password.request'),
|
||||||
|
'status' => session('status'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming authentication request.
|
||||||
|
*/
|
||||||
|
public function store(LoginRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->authenticate();
|
||||||
|
|
||||||
|
$request->session()->regenerate();
|
||||||
|
|
||||||
|
return redirect()->intended(route('home', absolute: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy an authenticated session.
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::guard('web')->logout();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ConfirmablePasswordController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the confirm password view.
|
||||||
|
*/
|
||||||
|
public function show(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Auth/ConfirmPassword');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm the user's password.
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if (! Auth::guard('web')->validate([
|
||||||
|
'email' => $request->user()->email,
|
||||||
|
'password' => $request->password,
|
||||||
|
])) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'password' => __('auth.password'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->put('auth.password_confirmed_at', time());
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard', absolute: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class EmailVerificationNotificationController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Send a new email verification notification.
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($request->user()->hasVerifiedEmail()) {
|
||||||
|
return redirect()->intended(route('dashboard', absolute: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->sendEmailVerificationNotification();
|
||||||
|
|
||||||
|
return back()->with('status', 'verification-link-sent');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class EmailVerificationPromptController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the email verification prompt.
|
||||||
|
*/
|
||||||
|
public function __invoke(Request $request): RedirectResponse|Response
|
||||||
|
{
|
||||||
|
return $request->user()->hasVerifiedEmail()
|
||||||
|
? redirect()->intended(route('dashboard', absolute: false))
|
||||||
|
: Inertia::render('Auth/VerifyEmail', ['status' => session('status')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Auth\Events\PasswordReset;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rules;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class NewPasswordController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the password reset view.
|
||||||
|
*/
|
||||||
|
public function create(Request $request): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Auth/ResetPassword', [
|
||||||
|
'email' => $request->email,
|
||||||
|
'token' => $request->route('token'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming new password request.
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'token' => 'required',
|
||||||
|
'email' => 'required|email',
|
||||||
|
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Here we will attempt to reset the user's password. If it is successful we
|
||||||
|
// will update the password on an actual user model and persist it to the
|
||||||
|
// database. Otherwise we will parse the error and return the response.
|
||||||
|
$status = Password::reset(
|
||||||
|
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||||
|
function ($user) use ($request) {
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => Hash::make($request->password),
|
||||||
|
'remember_token' => Str::random(60),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
event(new PasswordReset($user));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the password was successfully reset, we will redirect the user back to
|
||||||
|
// the application's home authenticated view. If there is an error we can
|
||||||
|
// redirect them back to where they came from with their error message.
|
||||||
|
if ($status == Password::PASSWORD_RESET) {
|
||||||
|
return redirect()->route('login')->with('status', __($status));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => [trans($status)],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
class PasswordController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Update the user's password.
|
||||||
|
*/
|
||||||
|
public function update(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'current_password' => ['required', 'current_password'],
|
||||||
|
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->user()->update([
|
||||||
|
'password' => Hash::make($validated['password']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class PasswordResetLinkController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the password reset link request view.
|
||||||
|
*/
|
||||||
|
public function create(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Auth/ForgotPassword', [
|
||||||
|
'status' => session('status'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming password reset link request.
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'email' => 'required|email',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// We will send the password reset link to this user. Once we have attempted
|
||||||
|
// to send the link, we will examine the response then see the message we
|
||||||
|
// need to show to the user. Finally, we'll send out a proper response.
|
||||||
|
$status = Password::sendResetLink(
|
||||||
|
$request->only('email')
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($status == Password::RESET_LINK_SENT) {
|
||||||
|
return back()->with('status', __($status));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => [trans($status)],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Events\Registered;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rules;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class RegisteredUserController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the registration view.
|
||||||
|
*/
|
||||||
|
public function create(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Auth/Register');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming registration request.
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'name' => 'required|string|max:32|alpha_dash',
|
||||||
|
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
|
||||||
|
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validator->after(function ($validator) use ($request) {
|
||||||
|
if (User::whereRaw(DB::raw('LOWER(name) = ?'), [strtolower($request->name)])->exists()) {
|
||||||
|
$validator->errors()->add('name', 'The name has already been taken.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$validator->validate();
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $request->name,
|
||||||
|
'email' => $request->email,
|
||||||
|
'password' => Hash::make($request->password),
|
||||||
|
]);
|
||||||
|
|
||||||
|
event(new Registered($user));
|
||||||
|
|
||||||
|
Auth::login($user);
|
||||||
|
|
||||||
|
return redirect(route('dashboard', absolute: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Auth\Events\Verified;
|
||||||
|
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
||||||
|
class VerifyEmailController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Mark the authenticated user's email address as verified.
|
||||||
|
*/
|
||||||
|
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($request->user()->hasVerifiedEmail()) {
|
||||||
|
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->user()->markEmailAsVerified()) {
|
||||||
|
event(new Verified($request->user()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,23 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
abstract class Controller
|
abstract class Controller
|
||||||
{
|
{
|
||||||
//
|
public function imageIfExists(string $path, $cacheLimit = 60 * 60 * 72){
|
||||||
|
if(!Storage::disk('local')->exists($path)){
|
||||||
|
return response()->json(['error' => 'Image not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fullPath = Storage::disk('local')->path($path);
|
||||||
|
$lastModified = filemtime($fullPath);
|
||||||
|
|
||||||
|
return response()->file($fullPath, [
|
||||||
|
'Content-Type' => 'image/png',
|
||||||
|
'Cache-Control' => 'public, max-age='.$cacheLimit, // 24 hours
|
||||||
|
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT',
|
||||||
|
'ETag' => md5($path . $lastModified),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\UserAction;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class FeedController extends Controller
|
||||||
|
{
|
||||||
|
public function view()
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
$followeeIds = $user->following()->pluck('followee_id');
|
||||||
|
|
||||||
|
$feed = UserAction::whereIn('user_id', $followeeIds)
|
||||||
|
->whereNotIn('type', ['flight_deleted'])
|
||||||
|
->with([
|
||||||
|
'user',
|
||||||
|
])
|
||||||
|
|
||||||
|
->latest()
|
||||||
|
->limit(50)
|
||||||
|
->get();
|
||||||
|
return Inertia::render('Feed', [
|
||||||
|
'user' => $user,
|
||||||
|
'feed' => $feed,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function following()
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return Cache::remember(
|
||||||
|
"user:{$user->id}:following",
|
||||||
|
now()->addMinutes(15),
|
||||||
|
fn () => $user->following()
|
||||||
|
->with('followee')
|
||||||
|
->get()
|
||||||
|
->pluck('followee')
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->toArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function followingFlights()
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return Cache::remember(
|
||||||
|
"user_following_flights_{$user->id}",
|
||||||
|
now()->addDays(30),
|
||||||
|
function () use ($user) {
|
||||||
|
$followingIds = $user->following()
|
||||||
|
->with('followee')
|
||||||
|
->get()
|
||||||
|
->pluck('followee')
|
||||||
|
->filter()
|
||||||
|
->reject(fn ($followee) => ($followee->settings['profile_privacy'] ?? 'public') === 'private')
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
return UserFlight::query()
|
||||||
|
->with(['departureAirport', 'departureAirport.region.country', 'arrivalAirport', 'arrivalAirport.region.country', 'user', 'airline'])
|
||||||
|
->whereIn('user_id', $followingIds)
|
||||||
|
->orderByDesc('departure_date')
|
||||||
|
->limit(100)
|
||||||
|
->get()
|
||||||
|
->values()
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Aircraft;
|
||||||
|
use App\Models\Airline;
|
||||||
|
use App\Models\Airport;
|
||||||
|
use App\Models\CrewType;
|
||||||
|
use App\Models\FlightClass;
|
||||||
|
use App\Models\FlightReason;
|
||||||
|
use App\Models\IataEquipmentCode;
|
||||||
|
use App\Models\Notification;
|
||||||
|
use App\Models\SeatType;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserAction;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use App\Services\FlightStatsService;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
class FlightController extends Controller
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'flight_number' => ['nullable', 'string', 'max:10'],
|
||||||
|
'departure_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'],
|
||||||
|
'aircraft_id' => ['nullable', 'integer', 'exists:aircraft,id'],
|
||||||
|
'aircraft_registration' => ['nullable', 'string', 'max:10'],
|
||||||
|
'seat_number' => ['nullable', 'string', 'max:10'],
|
||||||
|
'seat_type_id' => ['integer', 'exists:seat_types,id'],
|
||||||
|
'flight_class_id' => ['integer', 'exists:flight_classes,id'],
|
||||||
|
'flight_reason_id' => ['integer', 'exists:flight_reasons,id'],
|
||||||
|
'note' => ['nullable', 'string', 'max:5000'],
|
||||||
|
'auto_update' => ['boolean'],
|
||||||
|
'crew_type_id' => ['nullable', 'exists:crew_types,id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lookup(Request $request)
|
||||||
|
{
|
||||||
|
$number = strtoupper(trim($request->query('number', '')));
|
||||||
|
preg_match(
|
||||||
|
'/^([A-Z]{3}|[A-Z]{2}|[A-Z]\d|\d[A-Z])(\d+)$/',
|
||||||
|
$number,
|
||||||
|
$matches
|
||||||
|
);
|
||||||
|
$code = $matches[1] ?? null;
|
||||||
|
$flightNumber = $matches[2] ?? null;
|
||||||
|
$isIata = $code !== null && strlen($code) === 2;
|
||||||
|
$codeColumn = $isIata ? 'iata_code' : 'icao_code';
|
||||||
|
|
||||||
|
$apiAirlineCodes = [];
|
||||||
|
$fromOptions = [];
|
||||||
|
$toOptions = [];
|
||||||
|
$aircraftOptions = [];
|
||||||
|
$flightData = null;
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'airline_options' => $airlines,
|
||||||
|
'from_options' => $fromOptions,
|
||||||
|
'to_options' => $toOptions,
|
||||||
|
'aircraft_options' => $aircraftOptions,
|
||||||
|
'scheduled_departure_time' => $flightData?->scheduled_departure_local?->format('H:i'),
|
||||||
|
'scheduled_arrival_time' => $flightData?->scheduled_arrival_local?->format('H:i'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function convertedDates(array $validated): array
|
||||||
|
{
|
||||||
|
$departureAirport = Airport::find($validated['from_id']);
|
||||||
|
$arrivalAirport = Airport::find($validated['to_id']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
Carbon::createFromFormat('Y-m-d\TH:i', $validated['departure_date'], $departureAirport->timezone)->utc(),
|
||||||
|
Carbon::createFromFormat('Y-m-d\TH:i', $validated['arrival_date'], $arrivalAirport->timezone)->utc(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordChanges(UserFlight $flight, array $dirty, array $original, array $updated): void
|
||||||
|
{
|
||||||
|
$changes = [];
|
||||||
|
foreach ($dirty as $field => $newValue) {
|
||||||
|
$changes[] = $this->formatChange($field, $flight->getOriginal($field), $newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
UserAction::create([
|
||||||
|
'user_id' => $flight->user_id,
|
||||||
|
'data' => [
|
||||||
|
'changes' => $changes,
|
||||||
|
'original' => $original,
|
||||||
|
'updated' => $updated,
|
||||||
|
],
|
||||||
|
'type' => 'flight_updated',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private array $labelCache = [];
|
||||||
|
|
||||||
|
private function resolveLabel(string $field, mixed $value): string
|
||||||
|
{
|
||||||
|
if (is_null($value)) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = "{$field}:{$value}";
|
||||||
|
|
||||||
|
if (isset($this->labelCache[$cacheKey])) {
|
||||||
|
return $this->labelCache[$cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = match($field) {
|
||||||
|
|
||||||
|
'airline_id' => Airline::find($value)?->display_name ?? $value,
|
||||||
|
'departure_airport_id',
|
||||||
|
'arrival_airport_id' => Airport::find($value)?->display_name ?? $value,
|
||||||
|
'aircraft_id' => Aircraft::find($value)?->display_name_short ?? $value,
|
||||||
|
'seat_type_id' => SeatType::find($value)?->name ?? $value,
|
||||||
|
'flight_class_id' => FlightClass::find($value)?->name ?? $value,
|
||||||
|
'flight_reason_id' => FlightReason::find($value)?->name ?? $value,
|
||||||
|
'crew_type_id' => CrewType::find($value)?->name ?? $value,
|
||||||
|
'departure_date',
|
||||||
|
'arrival_date' => Carbon::parse($value)->format('j F Y \a\t H:iA'),
|
||||||
|
default => (string) $value,
|
||||||
|
};
|
||||||
|
|
||||||
|
return $this->labelCache[$cacheKey] = $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatChange(string $field, mixed $from, mixed $to): array
|
||||||
|
{
|
||||||
|
$label = str($field)->replace('_id', '')->replace('_', ' ')->title();
|
||||||
|
$fromLabel = $this->resolveLabel($field, $from);
|
||||||
|
$toLabel = $this->resolveLabel($field, $to);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'field' => $field,
|
||||||
|
'from' => $fromLabel,
|
||||||
|
'to' => $toLabel,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function flightPayload(array $validated): array
|
||||||
|
{
|
||||||
|
[$departureUtc, $arrivalUtc] = $this->convertedDates($validated);
|
||||||
|
return [
|
||||||
|
'departure_date' => $departureUtc,
|
||||||
|
'arrival_date' => $arrivalUtc,
|
||||||
|
'flight_number' => $validated['flight_number'],
|
||||||
|
'departure_airport_id' => $validated['from_id'],
|
||||||
|
'arrival_airport_id' => $validated['to_id'],
|
||||||
|
'airline_id' => $validated['airline_id'],
|
||||||
|
'aircraft_id' => $validated['aircraft_id'],
|
||||||
|
'aircraft_registration' => $validated['aircraft_registration'],
|
||||||
|
'seat_number' => $validated['seat_number'],
|
||||||
|
'seat_type_id' => $validated['seat_type_id'],
|
||||||
|
'flight_class_id' => $validated['flight_class_id'],
|
||||||
|
'flight_reason_id' => $validated['flight_reason_id'],
|
||||||
|
'note' => $validated['note'],
|
||||||
|
'auto_update' => $validated['auto_update'],
|
||||||
|
'crew_type_id' => $validated['crew_type_id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate($this->rules());
|
||||||
|
|
||||||
|
$newFlight = auth()->user()->flights()->create($this->flightPayload($validated));
|
||||||
|
|
||||||
|
UserAction::create([
|
||||||
|
'user_id' => $newFlight->user_id,
|
||||||
|
'type' => $newFlight->departure_date->isFuture() ? 'flight_booked' : 'flight_logged',
|
||||||
|
'data' => [
|
||||||
|
'flight' => $newFlight->snapshot($newFlight->id),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
return redirect()->route('profile.departure-board', [Auth::user()->name, $newFlight->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function update(Request $request, UserFlight $flight)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $flight);
|
||||||
|
|
||||||
|
$validated = $request->validate($this->rules());
|
||||||
|
|
||||||
|
$flight->fill($this->flightPayload($validated));
|
||||||
|
|
||||||
|
if (!$flight->isDirty()) {
|
||||||
|
return redirect()->route('profile.departure-board', [Auth::user()->name, $flight->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dirty = $flight->getDirty();
|
||||||
|
$original = $flight->snapshot($flight->id);
|
||||||
|
|
||||||
|
$flight->save();
|
||||||
|
|
||||||
|
$updated = $flight->snapshot($flight->id);
|
||||||
|
$this->recordChanges($flight, $dirty, $original, $updated);
|
||||||
|
|
||||||
|
return redirect()->route('profile.departure-board', [$flight->user->name, $flight->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(UserFlight $flight, ?string $referrer = 'departure-board')
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $flight);
|
||||||
|
|
||||||
|
$snapshot = $flight->snapshot($flight->id);
|
||||||
|
$isFuture = now()->utc()->isBefore($flight->departure_date);
|
||||||
|
|
||||||
|
UserAction::create([
|
||||||
|
'user_id' => $flight->user_id,
|
||||||
|
'type' => $isFuture ? 'flight_cancelled' : 'flight_deleted',
|
||||||
|
'data' => [
|
||||||
|
'flight' => $snapshot,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$flight->delete();
|
||||||
|
|
||||||
|
return redirect()->route('profile.'.$referrer, [Auth::user()->name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function staticData() : array {
|
||||||
|
return [
|
||||||
|
'seat_types' => SeatType::orderBy('id')->get()->toArray(),
|
||||||
|
'flight_reasons' => FlightReason::orderBy('id')->get()->toArray(),
|
||||||
|
'flight_classes' => FlightClass::orderBy('id')->get()->toArray(),
|
||||||
|
'crew_types' => CrewType::orderBy('id')->get()->toArray(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add(){
|
||||||
|
return Inertia::render('AddFlight', $this->staticData());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(UserFlight $flight)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $flight);
|
||||||
|
$flight->load('airline', 'aircraft', 'departureAirport.region.country', 'arrivalAirport.region.country', 'seatType', 'flightClass', 'flightReason');
|
||||||
|
$flightData = [
|
||||||
|
'id' => $flight->id,
|
||||||
|
'flight_number' => $flight->flight_number,
|
||||||
|
'departure_date' => $flight->departure_date->setTimezone($flight->departureAirport->timezone)->format('Y-m-d\TH:i'),
|
||||||
|
'arrival_date' => $flight->arrival_date->setTimezone($flight->arrivalAirport->timezone)->format('Y-m-d\TH:i'),
|
||||||
|
'aircraft_registration' => $flight->aircraft_registration,
|
||||||
|
'seat_number' => $flight->seat_number,
|
||||||
|
'note' => $flight->note,
|
||||||
|
'auto_update' => $flight->auto_update,
|
||||||
|
'seat_type' => $flight->seatType->toArray(),
|
||||||
|
'flight_class' => $flight->flightClass->toArray(),
|
||||||
|
'crew_type' => $flight->crewType?->toArray() ?? [],
|
||||||
|
'flight_reason' => $flight->flightReason->toArray(),
|
||||||
|
'airline_options' => $flight->airline
|
||||||
|
? [['value' => $flight->airline->id, 'title' => $flight->airline->display_name, 'logo_url' => $flight->airline->logo_url]]
|
||||||
|
: [],
|
||||||
|
'from_options' => [['value' => $flight->departureAirport->id, 'title' => $flight->departureAirport->display_name, 'country_code' => strtolower($flight->departureAirport->region->country->code)]],
|
||||||
|
'to_options' => [['value' => $flight->arrivalAirport->id, 'title' => $flight->arrivalAirport->display_name, 'country_code' => strtolower($flight->arrivalAirport->region->country->code)]],
|
||||||
|
'aircraft_options' => $flight->aircraft
|
||||||
|
? [['value' => $flight->aircraft->id, 'title' => $flight->aircraft->display_name]]
|
||||||
|
: [],
|
||||||
|
];
|
||||||
|
return Inertia::render('AddFlight', [
|
||||||
|
'flight' => $flightData,
|
||||||
|
...$this->staticData(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Aircraft;
|
||||||
|
use App\Models\Airline;
|
||||||
|
use App\Models\Airport;
|
||||||
|
use App\Models\FlightClass;
|
||||||
|
use App\Models\FlightReason;
|
||||||
|
use App\Models\ImportedFlight;
|
||||||
|
use App\Models\Notification;
|
||||||
|
use App\Models\SeatType;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserAction;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class FlightImportController extends Controller
|
||||||
|
{
|
||||||
|
private function formatTime(?string $time): string
|
||||||
|
{
|
||||||
|
if (!$time) return '00:00';
|
||||||
|
|
||||||
|
[$hours, $minutes] = explode(':', $time);
|
||||||
|
return str_pad($hours, 2, '0', STR_PAD_LEFT) . ':' . $minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function selectOptions($model): array
|
||||||
|
{
|
||||||
|
return $model::orderBy('id')
|
||||||
|
->get(['id', 'name'])
|
||||||
|
->map(fn($item) => ['value' => $item->id, 'title' => $item->name])
|
||||||
|
->values()
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPossibleAircraft(string $aircraftQuery): array
|
||||||
|
{
|
||||||
|
preg_match('/\((\w+)\)/', $aircraftQuery, $matches);
|
||||||
|
$designator = $matches[1] ?? null;
|
||||||
|
|
||||||
|
if (!$designator) return [];
|
||||||
|
|
||||||
|
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) {
|
||||||
|
preg_match('/\((\w{3})\/(\w{4})\)/', $airportQuery, $matches);
|
||||||
|
$iata = $matches[1] ?? null;
|
||||||
|
$icao = $matches[2] ?? null;
|
||||||
|
|
||||||
|
if (!$iata && !$icao) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$airports = Airport::with('region.country')
|
||||||
|
->where(function ($q) use ($iata, $icao) {
|
||||||
|
$q->where('iata_code', 'ilike', $iata)
|
||||||
|
->orWhere('icao_code', 'ilike', $icao);
|
||||||
|
})
|
||||||
|
->orderByRaw("
|
||||||
|
CASE
|
||||||
|
WHEN iata_code = ? AND icao_code = ? THEN 0
|
||||||
|
WHEN iata_code = ? THEN 1
|
||||||
|
WHEN icao_code = ? THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END
|
||||||
|
", [$iata, $icao, $iata, $icao])
|
||||||
|
->limit(10)
|
||||||
|
->orderBy('id')
|
||||||
|
->get(['id', 'name', 'municipality', 'iata_code', 'icao_code', 'region_id'])
|
||||||
|
->map(fn(Airport $airport) => [
|
||||||
|
'value' => $airport->id,
|
||||||
|
'title' => $airport->display_name,
|
||||||
|
'country_code' => strtolower($airport?->region->country->code ?? ''),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
return $airports;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPossibleAirlines(string $airlineQuery) {
|
||||||
|
preg_match('/\((\w{2,3})\/(\w{3,4})\)/', $airlineQuery, $matches);
|
||||||
|
$iata = $matches[1] ?? null;
|
||||||
|
$icao = $matches[2] ?? null;
|
||||||
|
|
||||||
|
if(!$iata && !$icao){
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$airlines = Airline::when($iata || $icao, function ($query) use ($iata, $icao) {
|
||||||
|
$query->orderByRaw("
|
||||||
|
CASE
|
||||||
|
WHEN \"iata_code\" = ? AND \"icao_code\" = ? THEN 0
|
||||||
|
WHEN \"iata_code\" = ? THEN 1
|
||||||
|
WHEN \"icao_code\" = ? THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END
|
||||||
|
", [$iata, $icao, $iata, $icao])
|
||||||
|
->where(function ($q) use ($iata, $icao) {
|
||||||
|
$q->where('iata_code', $iata)
|
||||||
|
->orWhere('icao_code', $icao);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->orderByDesc('active')
|
||||||
|
->limit(10)
|
||||||
|
->get(['id', 'name', 'iata_code', 'icao_code', 'internal_name'])
|
||||||
|
->map(fn($airline) => [
|
||||||
|
'value' => $airline->id,
|
||||||
|
'title' => $airline->display_name,
|
||||||
|
'logo_url' => $airline->logo_url,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
|
||||||
|
return $airlines;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function showFr24Import()
|
||||||
|
{
|
||||||
|
if (Auth::user()->importedFlights()->exists()) {
|
||||||
|
return to_route('reconcile');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('Fr24Import');
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlightImportController.php
|
||||||
|
|
||||||
|
public function reconcile(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
$flightToReconcile = ImportedFlight::where('user_id', $user->id)->orderBy('date', 'asc')->first();
|
||||||
|
|
||||||
|
if (!$flightToReconcile) {
|
||||||
|
return to_route('import.fr24');
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = $flightToReconcile->date
|
||||||
|
? Carbon::createFromFormat('Y-m-d', $flightToReconcile->date)->format('Y-m-d')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$flight = [
|
||||||
|
'imported_flight_id' => $flightToReconcile->id,
|
||||||
|
'flight_classes' => $this->selectOptions(FlightClass::class),
|
||||||
|
'flight_reasons' => $this->selectOptions(FlightReason::class),
|
||||||
|
'seat_types' => $this->selectOptions(SeatType::class),
|
||||||
|
'flight_number' => $flightToReconcile->flight_number ?? '',
|
||||||
|
'date' => $date ?? '',
|
||||||
|
'dep_time' => $this->formatTime($flightToReconcile->dep_time),
|
||||||
|
'arr_time' => $this->formatTime($flightToReconcile->arr_time),
|
||||||
|
'duration' => $this->formatTime($flightToReconcile->duration),
|
||||||
|
'registration' => $flightToReconcile->registration ?? '',
|
||||||
|
'note' => $flightToReconcile->note ?? '',
|
||||||
|
'flight_class' => $flightToReconcile->flight_class !== null ? (int) $flightToReconcile->flight_class : null,
|
||||||
|
'seat_type' => $flightToReconcile->seat_type !== null ? (int) $flightToReconcile->seat_type : null,
|
||||||
|
'flight_reason' => $flightToReconcile->flight_reason !== null ? (int) $flightToReconcile->flight_reason : null,
|
||||||
|
'seat_number' => $flightToReconcile->seat_number ?? '',
|
||||||
|
'airline_options' => $this->getPossibleAirlines($flightToReconcile->airline ?? ''),
|
||||||
|
'to_options' => $this->getPossibleAirports($flightToReconcile->to ?? ''),
|
||||||
|
'from_options' => $this->getPossibleAirports($flightToReconcile->from ?? ''),
|
||||||
|
'aircraft_options' => $this->getPossibleAircraft($flightToReconcile->aircraft ?? ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Inertia::render('ReconcileFlight', [
|
||||||
|
'flight' => $flight,
|
||||||
|
'key' => $flight['imported_flight_id'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'date' => 'required|date',
|
||||||
|
'imported_flight_id' => 'required|exists:imported_flights,id',
|
||||||
|
'flight_number' => 'nullable|string',
|
||||||
|
'from_id' => 'required|integer|exists:airports,id',
|
||||||
|
'to_id' => 'required|integer|exists:airports,id',
|
||||||
|
'dep_time' => 'nullable|date_format:H:i',
|
||||||
|
'arr_time' => 'nullable|date_format:H:i',
|
||||||
|
'duration' => 'required|date_format:H:i',
|
||||||
|
'airline_id' => 'nullable|integer|exists:airlines,id',
|
||||||
|
'aircraft_id' => 'nullable|integer|exists:aircraft,id',
|
||||||
|
'registration' => 'nullable|string',
|
||||||
|
'seat_number' => 'nullable|string',
|
||||||
|
'seat_type_id' => 'required|integer|exists:seat_types,id',
|
||||||
|
'flight_class_id' => 'required|integer|exists:flight_classes,id',
|
||||||
|
'flight_reason_id' => 'required|integer|exists:flight_reasons,id',
|
||||||
|
'note' => 'nullable|string',
|
||||||
|
], [
|
||||||
|
'imported_flight_id.required' => 'The flight you are trying to reconcile needs to be reimported or refreshed.',
|
||||||
|
'from_id.required' => 'Please select a departure airport.',
|
||||||
|
'to_id.required' => 'Please select an arrival airport.',
|
||||||
|
'dep_time.date_format' => 'Departure time must be in HH:MM format, i.e: 01:25',
|
||||||
|
'arr_time.date_format' => 'Arrival time must be in HH:MM format, i.e: 12:25',
|
||||||
|
'duration.date_format' => 'Must be in HH:MM format, e.g: 03:37',
|
||||||
|
'duration.required' => 'A duration is required to be able to accurately calculate the arrival date',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
$departureAirport = Airport::find($validated['from_id']);
|
||||||
|
$arrivalAirport = Airport::find($validated['to_id']);
|
||||||
|
|
||||||
|
// Parse departure in local airport timezone, then convert to UTC
|
||||||
|
$depTime = $validated['dep_time'] ?? '00:00';
|
||||||
|
$departure = Carbon::createFromFormat(
|
||||||
|
'Y-m-d H:i',
|
||||||
|
$validated['date'] . ' ' . $depTime,
|
||||||
|
$departureAirport->timezone
|
||||||
|
)->utc();
|
||||||
|
|
||||||
|
// Calculate duration-based arrival in UTC
|
||||||
|
[$durationHours, $durationMinutes] = explode(':', $validated['duration']);
|
||||||
|
$durationArrival = $departure->copy()
|
||||||
|
->addHours((int) $durationHours)
|
||||||
|
->addMinutes((int) $durationMinutes);
|
||||||
|
|
||||||
|
// If arrival time provided, parse it in arrival airport timezone and convert to UTC
|
||||||
|
if (!empty($validated['arr_time'])) {
|
||||||
|
$arrival = Carbon::createFromFormat(
|
||||||
|
'Y-m-d H:i',
|
||||||
|
$validated['date'] . ' ' . $validated['arr_time'],
|
||||||
|
$arrivalAirport->timezone
|
||||||
|
)->utc();
|
||||||
|
|
||||||
|
// If arrival is not after departure, fall back to duration-based arrival
|
||||||
|
if ($arrival->lte($departure)) {
|
||||||
|
$arrival = $durationArrival;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$arrival = $durationArrival;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newFlight = UserFlight::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'departure_date' => $departure,
|
||||||
|
'arrival_date' => $arrival,
|
||||||
|
'flight_number' => $validated['flight_number'],
|
||||||
|
'departure_airport_id' => $validated['from_id'],
|
||||||
|
'arrival_airport_id' => $validated['to_id'],
|
||||||
|
'airline_id' => $validated['airline_id'] ?? null,
|
||||||
|
'aircraft_id' => $validated['aircraft_id'] ?? null,
|
||||||
|
'aircraft_registration' => $validated['registration'] ?? null,
|
||||||
|
'seat_number' => $validated['seat_number'] ?? null,
|
||||||
|
'seat_type_id' => $validated['seat_type_id'] ?? 0,
|
||||||
|
'flight_class_id' => $validated['flight_class_id'] ?? 0,
|
||||||
|
'flight_reason_id' => $validated['flight_reason_id'] ?? 0,
|
||||||
|
'note' => $validated['note'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
UserAction::create([
|
||||||
|
'user_id' => $newFlight->user_id,
|
||||||
|
'type' => $newFlight->departure_date->isFuture() ? 'flight_booked' : 'flight_imported',
|
||||||
|
'data' => [
|
||||||
|
'flight' => $newFlight->snapshot($newFlight->id),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
ImportedFlight::destroy($validated['imported_flight_id']);
|
||||||
|
return to_route('reconcile');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateCsvFormat(string $path): ?string
|
||||||
|
{
|
||||||
|
$handle = fopen($path, 'r');
|
||||||
|
|
||||||
|
// Must have empty first line
|
||||||
|
$firstLine = fgetcsv($handle);
|
||||||
|
if (!empty(array_filter($firstLine))) {
|
||||||
|
fclose($handle);
|
||||||
|
return 'CSV must match the MyFlightRadar24 export format.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate headers
|
||||||
|
$expectedHeaders = [
|
||||||
|
'date', 'flight_number', 'from', 'to', 'dep_time', 'arr_time',
|
||||||
|
'duration', 'airline', 'aircraft', 'registration', 'seat_number',
|
||||||
|
'seat_type', 'flight_class', 'flight_reason', 'note',
|
||||||
|
'dep_id', 'arr_id', 'airline_id', 'aircraft_id'
|
||||||
|
];
|
||||||
|
|
||||||
|
$headers = array_map(
|
||||||
|
fn($h) => strtolower(trim(str_replace([' ', '"'], ['_', ''], $h))),
|
||||||
|
fgetcsv($handle)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($headers !== $expectedHeaders) {
|
||||||
|
fclose($handle);
|
||||||
|
return 'CSV headers do not match the expected format.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate data rows
|
||||||
|
$row = 1;
|
||||||
|
while (($data = fgetcsv($handle)) !== false) {
|
||||||
|
$row++;
|
||||||
|
|
||||||
|
if (count($data) !== count($expectedHeaders)) {
|
||||||
|
fclose($handle);
|
||||||
|
return "Row {$row} has the wrong number of columns.";
|
||||||
|
}
|
||||||
|
|
||||||
|
$combined = array_combine($expectedHeaders, $data);
|
||||||
|
|
||||||
|
if (empty($combined['date']) || empty($combined['from']) || empty($combined['to'])) {
|
||||||
|
fclose($handle);
|
||||||
|
return "Row {$row} is missing a required field (date, from, or to).";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $combined['date'])) {
|
||||||
|
fclose($handle);
|
||||||
|
return "Row {$row} has an invalid date format. Expected YYYY-MM-DD, got '{$combined['date']}'.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$request->validate([
|
||||||
|
'csv' => ['required', 'file', 'mimes:csv,txt', 'max:10240'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$path = $request->file('csv')->getRealPath();
|
||||||
|
|
||||||
|
$validationError = $this->validateCsvFormat($path);
|
||||||
|
if ($validationError) {
|
||||||
|
return response()->json(['message' => $validationError], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$handle = fopen($path, 'r');
|
||||||
|
|
||||||
|
fgetcsv($handle); // skip blank first line
|
||||||
|
|
||||||
|
// Read and normalise header row
|
||||||
|
$headers = array_map(
|
||||||
|
fn($h) => strtolower(trim(str_replace(' ', '_', $h))),
|
||||||
|
fgetcsv($handle)
|
||||||
|
);
|
||||||
|
|
||||||
|
\Log::debug('CSV headers', $headers);
|
||||||
|
|
||||||
|
$map = [
|
||||||
|
'date' => 'date',
|
||||||
|
'flight_number' => 'flight_number',
|
||||||
|
'from' => 'from',
|
||||||
|
'to' => 'to',
|
||||||
|
'dep_time' => 'dep_time',
|
||||||
|
'arr_time' => 'arr_time',
|
||||||
|
'duration' => 'duration',
|
||||||
|
'airline' => 'airline',
|
||||||
|
'aircraft' => 'aircraft',
|
||||||
|
'registration' => 'registration',
|
||||||
|
'seat_number' => 'seat_number',
|
||||||
|
'seat_type' => 'seat_type',
|
||||||
|
'flight_class' => 'flight_class',
|
||||||
|
'flight_reason' => 'flight_reason',
|
||||||
|
'note' => 'note',
|
||||||
|
];
|
||||||
|
|
||||||
|
$userId = Auth::id();
|
||||||
|
$imported = 0;
|
||||||
|
|
||||||
|
while (($row = fgetcsv($handle)) !== false) {
|
||||||
|
if ($imported === 0) {
|
||||||
|
\Log::debug('First row', $row);
|
||||||
|
\Log::debug('Header count: ' . count($headers) . ', Row count: ' . count($row));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($row) !== count($headers)) {
|
||||||
|
continue; // skip malformed rows
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = array_combine($headers, $row);
|
||||||
|
$data = ['user_id' => $userId];
|
||||||
|
|
||||||
|
foreach ($map as $csvKey => $column) {
|
||||||
|
$data[$column] = $raw[$csvKey] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportedFlight::create($data);
|
||||||
|
$imported++;
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return response()->json(['imported' => $imported]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json(['message' => $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Events\NotificationCreated;
|
||||||
|
use App\Models\Followee;
|
||||||
|
use App\Models\Notification;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
class FollowerController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$followers = Followee::with('user')
|
||||||
|
->where('followee_id', auth()->id())
|
||||||
|
->orderBy('verified') // unverified first
|
||||||
|
->get()
|
||||||
|
->map(fn (Followee $f) => [
|
||||||
|
'user' => $f->user,
|
||||||
|
'verified' => $f->verified,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json($followers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function follow(User $user): JsonResponse
|
||||||
|
{
|
||||||
|
abort_if($user->id === auth()->id(), 403);
|
||||||
|
|
||||||
|
$existing = Followee::where('user_id', auth()->id())
|
||||||
|
->where('followee_id', $user->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$existing->delete();
|
||||||
|
$this->clearFollowingCache(auth()->user());
|
||||||
|
|
||||||
|
Notification::where('user_id', $user->id)
|
||||||
|
->whereIn('title', ['New follower', 'Follow request'])
|
||||||
|
->where('url', $canView ?? true
|
||||||
|
? '/u/' . auth()->user()->name
|
||||||
|
: '/follow-requests')
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
return response()->json(['status' => 'none']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$canView = Gate::allows('viewProfileData', $user);
|
||||||
|
|
||||||
|
Followee::create([
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'followee_id' => $user->id,
|
||||||
|
'verified' => $canView,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->clearFollowingCache(auth()->user());
|
||||||
|
|
||||||
|
if($user->getSetting('notify_on_new_follower')) {
|
||||||
|
Notification::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'title' => $canView ? 'New follower' : 'Follow request',
|
||||||
|
'body' => $canView
|
||||||
|
? auth()->user()->name . ' is now following you.'
|
||||||
|
: auth()->user()->name . ' wants to follow you.',
|
||||||
|
'is_achievement' => false,
|
||||||
|
'url' => $canView ? '/u/' . auth()->user()->name : '/settings/followers',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['status' => $canView ? 'following' : 'requested']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approve(User $follower): JsonResponse
|
||||||
|
{
|
||||||
|
$followee = Followee::where('user_id', $follower->id)
|
||||||
|
->where('followee_id', auth()->id())
|
||||||
|
->pending()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$followee->update(['verified' => true]);
|
||||||
|
|
||||||
|
$this->clearFollowingCache($follower);
|
||||||
|
|
||||||
|
Notification::create([
|
||||||
|
'user_id' => $follower->id,
|
||||||
|
'title' => 'Follow request accepted',
|
||||||
|
'body' => auth()->user()->name . ' accepted your follow request.',
|
||||||
|
'is_achievement' => false,
|
||||||
|
'url' => '/u/' . auth()->user()->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['status' => 'approved']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deny(User $follower): JsonResponse
|
||||||
|
{
|
||||||
|
Followee::where('user_id', $follower->id)
|
||||||
|
->where('followee_id', auth()->id())
|
||||||
|
->pending()
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$this->clearFollowingCache($follower);
|
||||||
|
|
||||||
|
return response()->json(['status' => 'denied']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remove(User $follower): JsonResponse
|
||||||
|
{
|
||||||
|
Followee::where('user_id', $follower->id)
|
||||||
|
->where('followee_id', auth()->id())
|
||||||
|
->verified()
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$this->clearFollowingCache($follower);
|
||||||
|
|
||||||
|
return response()->json(['status' => 'removed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function clearFollowingCache(User $user): void
|
||||||
|
{
|
||||||
|
Cache::forget("user:{$user->id}:following");
|
||||||
|
Cache::forget("user_following_flights_{$user->id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class HomePageController extends Controller
|
||||||
|
{
|
||||||
|
function mostRecentFlights()
|
||||||
|
{
|
||||||
|
return Cache::remember('splash_flights', now()->addHours(12), function () {
|
||||||
|
return UserFlight::query()
|
||||||
|
->with(['departureAirport', 'departureAirport.region.country', 'arrivalAirport', 'arrivalAirport.region.country' ,'user', 'airline'])
|
||||||
|
->whereHas('user', function ($query) {
|
||||||
|
$query->whereRaw(DB::raw("settings->>'profile_privacy' is distinct from 'private'"));
|
||||||
|
})
|
||||||
|
->orderByDesc('departure_date')
|
||||||
|
->limit(50)
|
||||||
|
->get()
|
||||||
|
->values()
|
||||||
|
->toJson();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function splash(){
|
||||||
|
return Inertia::render('Splash');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,43 +8,85 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
|
|
||||||
class LogoController extends Controller
|
class LogoController extends Controller
|
||||||
{
|
{
|
||||||
public function getAirlineLogo(?Airline $airline){
|
|
||||||
|
|
||||||
$logoFile = $airline?->logo ?? 'blank.png';
|
public static function deduplicateLogo(string $logo, array $correctInternalNames)
|
||||||
$path = 'images/logos/tail/' . $logoFile;
|
{
|
||||||
if (!Storage::disk('local')->exists($path)) {
|
$log = fn(string $message) => print($message . PHP_EOL);
|
||||||
$path = 'images/logos/tail/blank.png';
|
|
||||||
|
if (empty($correctInternalNames)) {
|
||||||
|
$nulled = Airline::where('logo', $logo)->get(['name', 'internal_name']);
|
||||||
|
Airline::where('logo', $logo)->update(['logo' => null]);
|
||||||
|
foreach ($nulled as $airline) {
|
||||||
|
$log(" Logo nulled: {$airline->name} ({$airline->internal_name})");
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->file(Storage::disk('local')->path($path), [
|
$existing = Airline::whereIn('internal_name', $correctInternalNames)
|
||||||
'Content-Type' => 'image/png',
|
->pluck('internal_name')
|
||||||
]);
|
->toArray();
|
||||||
|
|
||||||
|
$missing = array_diff($correctInternalNames, $existing);
|
||||||
|
|
||||||
|
if (!empty($missing)) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
'The following internal names do not exist in the database: ' . implode(', ', $missing)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$nulled = Airline::where('logo', $logo)
|
||||||
|
->whereNotIn('internal_name', $correctInternalNames)
|
||||||
|
->get(['name', 'internal_name']);
|
||||||
|
|
||||||
|
Airline::where('logo', $logo)
|
||||||
|
->whereNotIn('internal_name', $correctInternalNames)
|
||||||
|
->update(['logo' => null]);
|
||||||
|
|
||||||
|
foreach ($nulled as $airline) {
|
||||||
|
$log(" Logo nulled: {$airline->name} ({$airline->internal_name})");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getLogoByIATACode(string $code)
|
public static function nullMissingLogoFiles()
|
||||||
{
|
{
|
||||||
$airline = Airline::where('IATA_code', strtoupper($code))
|
$log = fn(string $message) => print($message . PHP_EOL);
|
||||||
->whereNotNull('logo')
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
return $this->getAirlineLogo($airline);
|
$airlines = Airline::whereNotNull('logo')->get(['id', 'name', 'logo']);
|
||||||
|
|
||||||
|
foreach ($airlines as $airline) {
|
||||||
|
$path = storage_path('app/private/images/logos/tail/' . $airline->logo);
|
||||||
|
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
$airline->update(['logo' => null]);
|
||||||
|
$log(" Logo nulled (file missing): {$airline->name} — {$airline->logo}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getLogoByICAOCode(string $code)
|
public static function renameLogoFiles()
|
||||||
{
|
{
|
||||||
$airline = Airline::where('ICAO_code', strtoupper($code))
|
$log = fn(string $message) => print($message . PHP_EOL);
|
||||||
->whereNotNull('logo')
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
return $this->getAirlineLogo($airline);
|
$airlines = Airline::whereNotNull('logo')->get(['id', 'name', 'internal_name', 'logo']);
|
||||||
}
|
|
||||||
|
|
||||||
public function getLogoByCode(string $code){
|
foreach ($airlines as $airline) {
|
||||||
|
$expectedName = $airline->internal_name . '.png';
|
||||||
|
|
||||||
return strlen($code) == 2
|
if ($airline->logo === $expectedName) {
|
||||||
? $this->getLogoByIATACode($code)
|
continue;
|
||||||
: $this->getLogoByICAOCode($code);
|
}
|
||||||
|
|
||||||
|
$oldPath = storage_path('app/private/images/logos/tail/' . $airline->logo);
|
||||||
|
$newPath = storage_path('app/private/images/logos/tail/' . $expectedName);
|
||||||
|
|
||||||
|
if (!file_exists($oldPath)) {
|
||||||
|
$log(" Skipping (file missing): {$airline->name} — {$airline->logo}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rename($oldPath, $newPath);
|
||||||
|
$airline->update(['logo' => $expectedName]);
|
||||||
|
$log(" Renamed: {$airline->logo} → {$expectedName} ({$airline->name})");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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);
|
||||||
|
$notification->markAsRead();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\ProfileUpdateRequest;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Redirect;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ProfileController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the user's profile form.
|
||||||
|
*/
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Profile/Edit', [
|
||||||
|
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
|
||||||
|
'status' => session('status'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the user's profile information.
|
||||||
|
*/
|
||||||
|
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->user()->fill($request->validated());
|
||||||
|
|
||||||
|
if ($request->user()->isDirty('email')) {
|
||||||
|
$request->user()->email_verified_at = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->save();
|
||||||
|
|
||||||
|
return Redirect::route('profile.edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the user's account.
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'password' => ['required', 'current_password'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
Auth::logout();
|
||||||
|
|
||||||
|
$user->delete();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return Redirect::to('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Aircraft;
|
||||||
|
use App\Models\Airline;
|
||||||
|
use App\Models\Airport;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class SearchController extends Controller
|
||||||
|
{
|
||||||
|
public function airlines(){
|
||||||
|
$q = request('q', '');
|
||||||
|
|
||||||
|
return Airline::orderByDesc('active')
|
||||||
|
->where(function ($query) use ($q) {
|
||||||
|
$len = strlen($q);
|
||||||
|
if ($len === 2) {
|
||||||
|
$query->where('iata_code', 'ilike', $q);
|
||||||
|
} elseif ($len === 3) {
|
||||||
|
$query->where('icao_code', 'ilike', $q);
|
||||||
|
} else {
|
||||||
|
$query->where('name', 'ilike', "%{$q}%")
|
||||||
|
->orWhere('iata_code', 'ilike', "%{$q}%")
|
||||||
|
->orWhere('icao_code', 'ilike', "%{$q}%");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->limit(50)
|
||||||
|
->get()
|
||||||
|
->map(fn($airline) => [
|
||||||
|
'value' => $airline->id,
|
||||||
|
'title' => $airline->display_name,
|
||||||
|
'logo_url' => $airline->logo_url,
|
||||||
|
])
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function aircraft()
|
||||||
|
{
|
||||||
|
$q = request('q', '');
|
||||||
|
$replacedQuery = str_replace(['A3', 'A2'], ['A-3', 'A-2'], $q);
|
||||||
|
|
||||||
|
return Aircraft::where('designator', 'ilike', "%{$q}%")
|
||||||
|
->orWhereRaw("CONCAT(manufacturer_code, ' ', model_full_name) ilike ?", ["%{$q}%"])
|
||||||
|
->orWhereRaw("CONCAT(manufacturer_code, ' ', model_full_name) ilike ?", ["%{$replacedQuery}%"])
|
||||||
|
->limit(200)
|
||||||
|
->orderBy('preferred', 'desc')
|
||||||
|
->get(['id', 'manufacturer_code', 'model_full_name', 'designator'])
|
||||||
|
->map(fn($aircraft) => [
|
||||||
|
'value' => $aircraft->id,
|
||||||
|
'title' => $aircraft->display_name,
|
||||||
|
])
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function airports()
|
||||||
|
{
|
||||||
|
$q = request('q', '');
|
||||||
|
$len = strlen($q);
|
||||||
|
|
||||||
|
if ($len < 3) return [];
|
||||||
|
|
||||||
|
return Airport::with('region.country')
|
||||||
|
->when($len === 3, fn($query) => $query->where('iata_code', 'ilike', $q))
|
||||||
|
->when($len >= 4, fn($query) => $query->where(function ($sub) use ($q, $len) {
|
||||||
|
$sub->when($len === 4, fn($s) => $s->where('icao_code', 'ilike', $q))
|
||||||
|
->orWhere('name', 'ilike', "%{$q}%")
|
||||||
|
->orWhere('municipality', 'ilike', "%{$q}%");
|
||||||
|
})->orderByRaw("
|
||||||
|
CASE
|
||||||
|
WHEN icao_code = ? THEN 0
|
||||||
|
WHEN iata_code = ? THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END
|
||||||
|
", [$q, $q]))
|
||||||
|
->limit(15)
|
||||||
|
->get(['id', 'name', 'municipality', 'iata_code', 'icao_code', 'region_id'])
|
||||||
|
->map(fn($airport) => [
|
||||||
|
'value' => $airport->id,
|
||||||
|
'title' => $airport->display_name,
|
||||||
|
'country_code' => strtolower($airport->region->country->code),
|
||||||
|
])
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Settings\SettingsRegistry;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class SettingsController extends Controller
|
||||||
|
{
|
||||||
|
public function show(Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$current = array_merge(SettingsRegistry::defaults(), $user->settings ?? []);
|
||||||
|
$schema = SettingsRegistry::schema();
|
||||||
|
|
||||||
|
$fields = array_map(fn($field) => array_merge($field, [
|
||||||
|
'value' => $current[$field['key']] ?? $field['default'],
|
||||||
|
]), $schema);
|
||||||
|
|
||||||
|
return response()->json(['fields' => $fields]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate(SettingsRegistry::validationRules());
|
||||||
|
$request->user()->updateSettings($validated['settings']);
|
||||||
|
return response()->json(['message' => 'Settings saved.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateSingle(Request $request, string $key)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'value' => ['required'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->user()->updateSetting($key, $validated['value']);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Setting saved.']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Notifications\SupportRequestSubmitted;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class SupportFormController extends Controller
|
||||||
|
{
|
||||||
|
public function view()
|
||||||
|
{
|
||||||
|
return Inertia::render('Contact/Support');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'reason' => ['required', 'string', 'in:bug_report,general_feedback,missing_data'],
|
||||||
|
'message' => ['required', 'string', 'max:5000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::route('mail', config('mail.support_address', 'hello@flightsgoneby.com'))
|
||||||
|
->notify(new SupportRequestSubmitted(
|
||||||
|
name: $request->user()->name,
|
||||||
|
email: $request->user()->email,
|
||||||
|
reason: $validated['reason'],
|
||||||
|
message: $validated['message'],
|
||||||
|
));
|
||||||
|
|
||||||
|
return back()->with('success', 'Your message has been sent.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Followee;
|
||||||
|
use App\Models\Notification;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Settings\SettingsRegistry;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class UserController extends Controller
|
||||||
|
{
|
||||||
|
|
||||||
|
public function settings(?string $category = null){
|
||||||
|
$allowedTabs = ['general', 'followers'];
|
||||||
|
$user = auth()->user();
|
||||||
|
$current = array_merge(SettingsRegistry::defaults(), $user->settings ?? []);
|
||||||
|
$fields = array_map(fn($field) => array_merge($field, [
|
||||||
|
'value' => $current[$field['key']] ?? $field['default'],
|
||||||
|
]), SettingsRegistry::schema());
|
||||||
|
|
||||||
|
return Inertia::render('UserSettings', [
|
||||||
|
'fields' => $fields,
|
||||||
|
'categories' => SettingsRegistry::categories(),
|
||||||
|
'defaultTab' => in_array($category, $allowedTabs, true) ? $category : 'general',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Achievement;
|
||||||
|
use App\Models\Aircraft;
|
||||||
|
use App\Models\Alliance;
|
||||||
|
use App\Models\Continent;
|
||||||
|
use App\Models\Country;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use App\Http\Resources\UserFlightResource;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class UserProfileController extends Controller
|
||||||
|
{
|
||||||
|
public function currentUserCanEditProfile(User $profileUser){
|
||||||
|
return auth()->check() && (auth()->id() === $profileUser->id || auth()->user()->hasRole('admin'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(){
|
||||||
|
if (auth()->check()) {
|
||||||
|
$user = auth()->user();
|
||||||
|
$defaultPage = $user->resolved_settings['default_login_page'];
|
||||||
|
|
||||||
|
$route = match ($defaultPage) {
|
||||||
|
'feed_first' => $user->following()->count() > 0 ? 'feed' : 'profile.view',
|
||||||
|
'feed' => 'feed',
|
||||||
|
'profile' => 'profile.view',
|
||||||
|
'dashboard' => 'dashboard',
|
||||||
|
};
|
||||||
|
|
||||||
|
$args = $route == 'profile.view' ? $user->name : null;
|
||||||
|
|
||||||
|
return redirect()->route($route, $args);
|
||||||
|
}
|
||||||
|
return redirect()->route('splash');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function profileData(User $user, string $view, ?int $selectedFlightId = null) : array {
|
||||||
|
return [
|
||||||
|
'user' => $user,
|
||||||
|
'canView' => Gate::allows('viewProfileData', $user),
|
||||||
|
'canEdit' => $this->currentUserCanEditProfile($user),
|
||||||
|
'initialView' => $view,
|
||||||
|
'selectedFlightId' => $selectedFlightId,
|
||||||
|
'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none',
|
||||||
|
'flightCount' => $user->departedFlights()->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function departureBoard(User $user, ?UserFlight $flight = null){
|
||||||
|
$profileData = $this->profileData($user, 'board', $flight?->id);
|
||||||
|
return Inertia::render('UserProfile', $profileData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function map(User $user){
|
||||||
|
$profileData = $this->profileData($user, 'map');
|
||||||
|
return Inertia::render('UserProfile', $profileData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boardingPasses(User $user){
|
||||||
|
$profileData = $this->profileData($user, 'passes');
|
||||||
|
return Inertia::render('UserProfile', $profileData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, ?string $page = null)
|
||||||
|
{
|
||||||
|
$loggedInUser = auth()->user();
|
||||||
|
$defaultView = $page ?: ($loggedInUser ? $loggedInUser->resolved_settings['default_profile_view'] : 'map');
|
||||||
|
|
||||||
|
return match($defaultView) {
|
||||||
|
'boarding-passes' => $this->boardingPasses($user),
|
||||||
|
'map' => $this->map($user),
|
||||||
|
'departure-board' => $this->departureBoard($user),
|
||||||
|
'achievements' => redirect()->route('profile.achievements', $user->name),
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public function flight(User $user, UserFlight $userFlight)
|
||||||
|
{
|
||||||
|
if($userFlight->user_id !== $user->id){
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('UserFlight', [
|
||||||
|
'flightCount' => $user->departedFlights()->count(),
|
||||||
|
'flight' => $userFlight->snapshot($userFlight->id),
|
||||||
|
'canEdit' => $this->currentUserCanEditProfile($user),
|
||||||
|
'canView' => Gate::allows('viewProfileData', $user),
|
||||||
|
'user' => $user,
|
||||||
|
'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function achievements(User $user)
|
||||||
|
{
|
||||||
|
$canView = Gate::allows('viewProfileData', $user);
|
||||||
|
|
||||||
|
$achievements = Achievement::with(['category', 'difficulty'])
|
||||||
|
->get()
|
||||||
|
->groupBy(fn(Achievement $a) => $a->category->name)
|
||||||
|
->map(fn($group) => $group->sortBy('sort_order')->values());
|
||||||
|
|
||||||
|
$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', [
|
||||||
|
'canView' => $canView,
|
||||||
|
'user' => $user,
|
||||||
|
'canEdit' => auth()->id() === $user->id,
|
||||||
|
'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none',
|
||||||
|
'achievements' => $canView ? $achievements : [],
|
||||||
|
'userAchievements' => $canView ? $userAchievements : [],
|
||||||
|
'loggedInUser' => auth()->user(),
|
||||||
|
'unlockedCount' => $unlockedCount,
|
||||||
|
'unlockedByCategory' => $unlockedByCategory,
|
||||||
|
'totalAchievements' => $achievements->flatten()->count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function copyFlight(UserFlight $flight)
|
||||||
|
{
|
||||||
|
$loggedInUser = auth()->user();
|
||||||
|
|
||||||
|
$exists = UserFlight::where('user_id', $loggedInUser->id)
|
||||||
|
->where('departure_airport_id', $flight->departure_airport_id)
|
||||||
|
->where('arrival_airport_id', $flight->arrival_airport_id)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
return back()->withErrors(['flight' => 'You already have a flight logged between these airports on this date.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$newFlight = $flight->replicate();
|
||||||
|
$newFlight->user_id = $loggedInUser->id;
|
||||||
|
$newFlight->save();
|
||||||
|
|
||||||
|
return redirect()->route('flights.edit', ['flight' => $newFlight->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function achievement(User $user, Achievement $achievement)
|
||||||
|
{
|
||||||
|
$regions = match($achievement->internal_name){
|
||||||
|
'fun_challenges.australian_states' => Country::whereCode('AU')->first()->sortedRegions(),
|
||||||
|
'fun_challenges.chinese_provinces' => Country::whereCode('CN')->first()->sortedRegions(),
|
||||||
|
'fun_challenges.canadian_provinces' => Country::whereCode('CA')->first()->sortedRegions(),
|
||||||
|
'fun_challenges.brazilian_states' => Country::whereCode('BR')->first()->sortedRegions(),
|
||||||
|
'fun_challenges.us_states' => Country::whereCode('US')->first()->sortedRegions(),
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
$allianceInternalName = match($achievement->internal_name){
|
||||||
|
'airlines_alliances.all_star_alliance' => 'star_alliance',
|
||||||
|
'airlines_alliances.all_oneworld' => 'oneworld',
|
||||||
|
'airlines_alliances.all_skyteam' => 'skyteam',
|
||||||
|
'airlines_alliances.all_vanilla_alliance' => 'vanilla_alliance',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
$continents = match($achievement->internal_name){
|
||||||
|
'countries_continents.all_continent_pairs_one_way', 'countries_continents.all_continent_pairs_both_ways' => Continent::all()->toArray(),
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
$alliance = null;
|
||||||
|
$airlines = [];
|
||||||
|
|
||||||
|
if ($allianceInternalName) {
|
||||||
|
$alliance = Alliance::where('internal_name', $allianceInternalName)
|
||||||
|
->with('airlines')
|
||||||
|
->firstOrFail();
|
||||||
|
$airlines = $alliance->airlines()->with(['country', 'mergedFrom'])->orderBy('name')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
$aircraftFamilies = match($achievement->internal_name){
|
||||||
|
'aircraft.all_boeing_7x7' => Aircraft::BOEING_FAMILIES,
|
||||||
|
'aircraft.all_airbus_a3xx' => Aircraft::AIRBUS_FAMILIES,
|
||||||
|
'aircraft.all_boeing_737' => Aircraft::BOEING_737_VARIANTS,
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
$canView = Gate::allows('viewProfileData', $user);
|
||||||
|
|
||||||
|
return Inertia::render('Profile/UserAchievement', [
|
||||||
|
'user' => $user,
|
||||||
|
'achievement' => $achievement,
|
||||||
|
'loggedInUser' => auth()->user(),
|
||||||
|
'userAchievement' => $canView ? $user->achievements()->where('achievement_id', $achievement->id)->first() : null,
|
||||||
|
'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none',
|
||||||
|
'regions' => $regions,
|
||||||
|
'alliance' => $alliance,
|
||||||
|
'airlines' => $airlines,
|
||||||
|
'continents' => $continents,
|
||||||
|
'aircraft_families' => $aircraftFamilies,
|
||||||
|
'achievementCount' => $user->unlockedAchievements()->count(),
|
||||||
|
'canView' => $canView,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Middleware;
|
||||||
|
|
||||||
|
class HandleInertiaRequests extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The root template that is loaded on the first page visit.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $rootView = 'app';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the current asset version.
|
||||||
|
*/
|
||||||
|
public function version(Request $request): ?string
|
||||||
|
{
|
||||||
|
return parent::version($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the props that are shared by default.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function share(Request $request): array
|
||||||
|
{
|
||||||
|
|
||||||
|
return [
|
||||||
|
...parent::share($request),
|
||||||
|
'logo_api_url' => config('app.logo_api_url'),
|
||||||
|
'auth' => [
|
||||||
|
'user' => $request->user(),
|
||||||
|
'roles' => $request->user()?->getRoleNames() ?? [],
|
||||||
|
'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [],
|
||||||
|
'apiToken' => session('api_token'),
|
||||||
|
],
|
||||||
|
'flash' => [
|
||||||
|
'success' => $request->session()->get('success'),
|
||||||
|
'error' => $request->session()->get('error'),
|
||||||
|
],
|
||||||
|
'achievement_notifications' => fn() => $request->user()
|
||||||
|
? $request->user()
|
||||||
|
->notifications()
|
||||||
|
->with('achievement')
|
||||||
|
->where('is_achievement', true)
|
||||||
|
->whereNull('read_at')
|
||||||
|
->latest()
|
||||||
|
->get()
|
||||||
|
: [],
|
||||||
|
'unread_notification_count' => $request->user()?->notifications()
|
||||||
|
->whereNull('read_at')
|
||||||
|
->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>', now())
|
||||||
|
->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Listeners\IssueApiToken;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class RefreshApiToken
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (!$request->user() || !session('api_token_id')) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $request->user()
|
||||||
|
->tokens()
|
||||||
|
->find(session('api_token_id'));
|
||||||
|
|
||||||
|
// Refresh if expired or within 30 minutes of expiry
|
||||||
|
if (!$token || $token->expires_at->subMinutes(30)->isPast()) {
|
||||||
|
app(IssueApiToken::class)->createToken($request->user());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class SanctumOrTrustedOrigin
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
// Authenticated via Sanctum (cookie or token) — let it through, auth()->user() is set.
|
||||||
|
if ($user = $request->user('sanctum')) {
|
||||||
|
Auth::setUser($user);
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unauthenticated, but coming from our own frontend — let it through too.
|
||||||
|
$origin = $request->headers->get('Origin') ?? $request->headers->get('Referer');
|
||||||
|
$trusted = config('app.trusted_frontend_origins', []);
|
||||||
|
|
||||||
|
foreach ($trusted as $trustedOrigin) {
|
||||||
|
if ($origin && str_starts_with($origin, $trustedOrigin)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(403, 'Forbidden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Auth\Events\Lockout;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class LoginRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'string', 'email'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to authenticate the request's credentials.
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function authenticate(): void
|
||||||
|
{
|
||||||
|
$this->ensureIsNotRateLimited();
|
||||||
|
|
||||||
|
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||||
|
RateLimiter::hit($this->throttleKey());
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => trans('auth.failed'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::clear($this->throttleKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the login request is not rate limited.
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function ensureIsNotRateLimited(): void
|
||||||
|
{
|
||||||
|
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event(new Lockout($this));
|
||||||
|
|
||||||
|
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => trans('auth.throttle', [
|
||||||
|
'seconds' => $seconds,
|
||||||
|
'minutes' => ceil($seconds / 60),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the rate limiting throttle key for the request.
|
||||||
|
*/
|
||||||
|
public function throttleKey(): string
|
||||||
|
{
|
||||||
|
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class ProfileUpdateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'lowercase',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique(User::class)->ignore($this->user()->id),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin UserFlight */
|
||||||
|
class UserFlightResource extends JsonResource
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Events\Login;
|
||||||
|
|
||||||
|
class IssueApiToken
|
||||||
|
{
|
||||||
|
public function handle(Login $event): void
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $event->user;
|
||||||
|
|
||||||
|
$this->createToken($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createToken(User $user): void
|
||||||
|
{
|
||||||
|
// Delete only this session's existing token (if any)
|
||||||
|
if ($tokenId = session('api_token_id')) {
|
||||||
|
$user->tokens()->where('id', $tokenId)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
$newToken = $user->createToken(
|
||||||
|
'frontend',
|
||||||
|
['*'],
|
||||||
|
now()->addHours(4)
|
||||||
|
);
|
||||||
|
|
||||||
|
session([
|
||||||
|
'api_token' => $newToken->plainTextToken,
|
||||||
|
'api_token_id' => $newToken->accessToken->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Events\Logout;
|
||||||
|
|
||||||
|
class RevokeApiToken
|
||||||
|
{
|
||||||
|
public function handle(Logout $event): void
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $event->user;
|
||||||
|
|
||||||
|
if ($tokenId = session('api_token_id')) {
|
||||||
|
$user?->tokens()->where('id', $tokenId)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
session()->forget(['api_token', 'api_token_id']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property string $name
|
||||||
|
* @property string $internal_name
|
||||||
|
* @property string $short_description
|
||||||
|
* @property string $long_description
|
||||||
|
* @property string $icon
|
||||||
|
* @property bool $progressive
|
||||||
|
* @property string|null $difficulty_description
|
||||||
|
* @property int $achievement_category_id
|
||||||
|
* @property int $achievement_difficulty_id
|
||||||
|
*
|
||||||
|
* @property-read AchievementCategory $category
|
||||||
|
* @property-read AchievementDifficulty $difficulty
|
||||||
|
* @property-read Collection<int, UserAchievement> $userAchievements
|
||||||
|
* @property-read Collection<int, User> $users
|
||||||
|
*/
|
||||||
|
class Achievement extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'internal_name',
|
||||||
|
'short_description',
|
||||||
|
'long_description',
|
||||||
|
'icon',
|
||||||
|
'progressive',
|
||||||
|
'difficulty_description',
|
||||||
|
'achievement_category_id',
|
||||||
|
'achievement_difficulty_id',
|
||||||
|
'threshold',
|
||||||
|
'has_page',
|
||||||
|
'sort_order',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'has_page' => 'boolean',
|
||||||
|
'progressive' => 'boolean',
|
||||||
|
'threshold' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'internal_name';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Relationships
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
public function category(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AchievementCategory::class, 'achievement_category_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function difficulty(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AchievementDifficulty::class, 'achievement_difficulty_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function userAchievements(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserAchievement::class, 'achievement_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Users who have unlocked (or are progressing toward) this achievement. */
|
||||||
|
public function users(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(User::class, 'user_achievements')
|
||||||
|
->withPivot('progress')
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property string $internal_name
|
||||||
|
* @property string $name
|
||||||
|
* @property string $description
|
||||||
|
*
|
||||||
|
* @property-read Collection<int, Achievement> $achievements
|
||||||
|
*/
|
||||||
|
class AchievementCategory extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'internal_name',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Relationships
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
public function achievements(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Achievement::class, 'achievement_category_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property string $internal_name
|
||||||
|
* @property string $name
|
||||||
|
* @property string $description
|
||||||
|
*
|
||||||
|
* @property-read Collection<int, Achievement> $achievements
|
||||||
|
*/
|
||||||
|
class AchievementDifficulty extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'internal_name',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Relationships
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
public function achievements(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Achievement::class, 'achievement_difficulty_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Aircraft extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'designator',
|
||||||
|
'manufacturer_code',
|
||||||
|
'model_full_name',
|
||||||
|
'aircraft_description',
|
||||||
|
'engine_type',
|
||||||
|
'engine_count',
|
||||||
|
'wtc',
|
||||||
|
'preferred'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'engine_count' => 'integer',
|
||||||
|
'preferred' => 'boolean'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $appends = [
|
||||||
|
'display_name',
|
||||||
|
'display_name_short'
|
||||||
|
];
|
||||||
|
|
||||||
|
public const array BOEING_FAMILIES = [
|
||||||
|
'707' => ['B701', 'B703', 'B720'],
|
||||||
|
'717' => ['B712', 'B717'],
|
||||||
|
'727' => ['B721', 'B722', 'B727'],
|
||||||
|
'737' => ['B731', 'B732', 'B733', 'B734', 'B735', 'B736', 'B737', 'B738', 'B739', 'B37M', 'B38M', 'B39M'],
|
||||||
|
'747' => ['B741', 'B742', 'B743', 'B744', 'B748', 'B74D', 'B74R', 'B74S'],
|
||||||
|
'757' => ['B752', 'B753', 'B757'],
|
||||||
|
'767' => ['B762', 'B763', 'B764', 'B767'],
|
||||||
|
'777' => ['B772', 'B773', 'B77L', 'B77W', 'B778', 'B779'],
|
||||||
|
'787' => ['B788', 'B789', 'B78X'],
|
||||||
|
];
|
||||||
|
|
||||||
|
public const array BOEING_737_VARIANTS = [
|
||||||
|
'737-100' => ['B731'],
|
||||||
|
'737-200' => ['B732'],
|
||||||
|
'737-300' => ['B733'],
|
||||||
|
'737-400' => ['B734'],
|
||||||
|
'737-500' => ['B735'],
|
||||||
|
'737-600' => ['B736'],
|
||||||
|
'737-700' => ['B737', 'E737'],
|
||||||
|
'737-800' => ['B738', 'P8'],
|
||||||
|
'737-900' => ['B739'],
|
||||||
|
'737 Max 7' => ['B37M'],
|
||||||
|
'737 Max 8' => ['B38M'],
|
||||||
|
'737 Max 9' => ['B39M'],
|
||||||
|
'737 Max 10' => ['B3XM']
|
||||||
|
];
|
||||||
|
|
||||||
|
public const array AIRBUS_FAMILIES = [
|
||||||
|
'A300' => ['A30B', 'A300', 'A306'],
|
||||||
|
'A310' => ['A310', 'A312', 'A313'],
|
||||||
|
'A318' => ['A318'],
|
||||||
|
'A319' => ['A319', 'A31X'],
|
||||||
|
'A320' => ['A320', 'A20N'],
|
||||||
|
'A321' => ['A321', 'A21N'],
|
||||||
|
'A330' => ['A330', 'A332', 'A333', 'A338', 'A339'],
|
||||||
|
'A340' => ['A340', 'A342', 'A343', 'A345', 'A346'],
|
||||||
|
'A350' => ['A350', 'A358', 'A359', 'A35K'],
|
||||||
|
'A380' => ['A380', 'A388'],
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
protected function displayName() : Attribute{
|
||||||
|
return Attribute::make(
|
||||||
|
get: function () {
|
||||||
|
return "{$this->manufacturer_code} {$this->model_full_name} ({$this->designator})";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function displayNameShort(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: function () {
|
||||||
|
$name = "{$this->manufacturer_code} {$this->model_full_name}";
|
||||||
|
return trim(preg_replace('/\s*\(.*?\)/', '', $name));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
-4
@@ -2,22 +2,29 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Observers\AirlineObserver;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
#[ObservedBy(AirlineObserver::class)]
|
||||||
class Airline extends Model
|
class Airline extends Model
|
||||||
{
|
{
|
||||||
protected $table = 'airlines';
|
protected $table = 'airlines';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'IATA_code',
|
'iata_code',
|
||||||
'ICAO_code',
|
'icao_code',
|
||||||
'name',
|
'name',
|
||||||
'internal_name',
|
'internal_name',
|
||||||
'country_code',
|
'country_id',
|
||||||
'country_name',
|
'alliance_id',
|
||||||
'active',
|
'active',
|
||||||
'logo',
|
'logo',
|
||||||
|
'merged_into_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -25,4 +32,57 @@ class Airline extends Model
|
|||||||
];
|
];
|
||||||
|
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
protected $appends = [
|
||||||
|
'display_name',
|
||||||
|
'logo_url',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function displayName() : Attribute{
|
||||||
|
return Attribute::make(
|
||||||
|
get: function () {
|
||||||
|
$codes = array_filter([$this->iata_code, $this->icao_code]);
|
||||||
|
$codeString = count($codes) ? ' (' . implode('/', $codes) . ')' : '';
|
||||||
|
return "{$this->name}{$codeString}";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function logoUrl() : Attribute{
|
||||||
|
return Attribute::make(
|
||||||
|
get: function () {
|
||||||
|
$user = auth()->user();
|
||||||
|
$apiUrl = config('app.logo_api_url');
|
||||||
|
if ($user && !$user->getSetting('ai_tail_logos')) {
|
||||||
|
return $apiUrl .'/airline/blank/logo/tail';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $apiUrl . "/airline/$this->internal_name/logo/tail";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function alliance(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Alliance::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function country(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Country::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mergedInto(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Airline::class, 'merged_into_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mergedFrom(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Airline::class, 'merged_into_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canonical(): self
|
||||||
|
{
|
||||||
|
return $this->mergedInto ?? $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class Airport extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'type',
|
||||||
|
'name',
|
||||||
|
'latitude_deg',
|
||||||
|
'longitude_deg',
|
||||||
|
'elevation_ft',
|
||||||
|
'timezone',
|
||||||
|
'region_id',
|
||||||
|
'municipality',
|
||||||
|
'icao_code',
|
||||||
|
'iata_code',
|
||||||
|
'local_code',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'latitude_deg' => 'float',
|
||||||
|
'longitude_deg' => 'float',
|
||||||
|
'elevation_ft' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $appends = [
|
||||||
|
'display_code',
|
||||||
|
'display_name',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function displayName() : Attribute{
|
||||||
|
return Attribute::make(
|
||||||
|
get: function () {
|
||||||
|
$codes = array_filter([$this->iata_code, $this->icao_code]);
|
||||||
|
$codeString = count($codes) ? ' (' . implode('/', $codes) . ')' : '';
|
||||||
|
return "{$this->municipality} / {$this->name}{$codeString}";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
protected function displayCode(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn () => $this->iata_code ?? $this->icao_code ?? $this->local_code ?? '---'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function region(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Region::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Alliance extends Model
|
||||||
|
{
|
||||||
|
|
||||||
|
protected $table = 'alliances';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'internal_name',
|
||||||
|
'name',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'internal_name' => 'string',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALLIANCE_CHALLENGES = [
|
||||||
|
'star_alliance' => 'airlines_alliances.all_star_alliance',
|
||||||
|
'oneworld' => 'airlines_alliances.all_oneworld',
|
||||||
|
'vanilla_alliance' => 'airlines_alliances.all_vanilla_alliance',
|
||||||
|
'skyteam' => 'airlines_alliances.all_skyteam',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function airlines(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Airline::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function recalculateChallengeThresholds() : void {
|
||||||
|
$alliances = Alliance::all();
|
||||||
|
foreach ($alliances as $alliance) {
|
||||||
|
$airlineCount = $alliance->airlines()->count();
|
||||||
|
$challenge = self::ALLIANCE_CHALLENGES[$alliance->internal_name];
|
||||||
|
Achievement::where('internal_name', $challenge)->update(['threshold' => $airlineCount]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Continent extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'code',
|
||||||
|
'name',
|
||||||
|
'internal_name',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function countries(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Country::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function regions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Region::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Country extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'code',
|
||||||
|
'name',
|
||||||
|
'continent',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function regions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Region::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedRegions(): array
|
||||||
|
{
|
||||||
|
return $this
|
||||||
|
->regions()
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class CrewType extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'crew_types';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'internal_name',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class FlightClass extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'internal_name',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class FlightReason extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class Followee extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'followee_id',
|
||||||
|
'verified',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'verified' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function followee(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'followee_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeVerified($query)
|
||||||
|
{
|
||||||
|
return $query->where('verified', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopePending($query)
|
||||||
|
{
|
||||||
|
return $query->where('verified', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class IgnoredMissingLivery extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'ignored_missing_liveries';
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = ['filename'];
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class ImportedFlight extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'date',
|
||||||
|
'flight_number',
|
||||||
|
'from',
|
||||||
|
'to',
|
||||||
|
'dep_time',
|
||||||
|
'arr_time',
|
||||||
|
'duration',
|
||||||
|
'airline',
|
||||||
|
'aircraft',
|
||||||
|
'registration',
|
||||||
|
'seat_number',
|
||||||
|
'seat_type',
|
||||||
|
'flight_class',
|
||||||
|
'flight_reason',
|
||||||
|
'note',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Observers\NotificationObserver;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property int $user_id
|
||||||
|
* @property string $title
|
||||||
|
* @property string $body
|
||||||
|
* @property string|null $url
|
||||||
|
* @property bool $is_achievement
|
||||||
|
* @property int|null $achievement_id
|
||||||
|
* @property \Carbon\Carbon|null $read_at
|
||||||
|
* @property \Carbon\Carbon|null $dismissed_at
|
||||||
|
* @property \Carbon\Carbon|null $expires_at
|
||||||
|
*
|
||||||
|
* @property-read User $user
|
||||||
|
* @property-read Achievement|null $achievement
|
||||||
|
*
|
||||||
|
* @method static Builder unread()
|
||||||
|
* @method static Builder undismissed()
|
||||||
|
* @method static Builder active()
|
||||||
|
* @method static Builder forAchievements()
|
||||||
|
*/
|
||||||
|
#[ObservedBy(NotificationObserver::class)]
|
||||||
|
class Notification extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'title',
|
||||||
|
'body',
|
||||||
|
'url',
|
||||||
|
'is_achievement',
|
||||||
|
'achievement_id',
|
||||||
|
'read_at',
|
||||||
|
'expires_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_achievement' => 'boolean',
|
||||||
|
'read_at' => 'datetime',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Relationships
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function achievement(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Achievement::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Scopes
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Notifications the user hasn't opened yet. */
|
||||||
|
public function scopeUnread(Builder $query): void
|
||||||
|
{
|
||||||
|
$query->whereNull('read_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Not expired (no expiry set, or expiry is in the future). */
|
||||||
|
public function scopeActive(Builder $query): void
|
||||||
|
{
|
||||||
|
$query->where(function (Builder $q) {
|
||||||
|
$q->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>', now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only achievement notifications. */
|
||||||
|
public function scopeForAchievements(Builder $query): void
|
||||||
|
{
|
||||||
|
$query->where('is_achievement', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
public function markAsRead(): void
|
||||||
|
{
|
||||||
|
if (! $this->read_at) {
|
||||||
|
$this->update(['read_at' => now()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isExpired(): bool
|
||||||
|
{
|
||||||
|
return $this->expires_at !== null && $this->expires_at->isPast();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class Region extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'code',
|
||||||
|
'local_code',
|
||||||
|
'name',
|
||||||
|
'continent',
|
||||||
|
'country_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function country(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Country::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function continent(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Continent::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class SeatType extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
];
|
||||||
|
public $timestamps = false;
|
||||||
|
}
|
||||||
+196
-15
@@ -2,31 +2,212 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use App\Observers\UserObserver;
|
||||||
|
use App\Settings\SettingsRegistry;
|
||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use App\Traits\HasAchievements;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
use NotificationChannels\WebPush\HasPushSubscriptions;
|
||||||
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
use App\Notifications\ResetPasswordNotification;
|
||||||
|
|
||||||
#[Fillable(['name', 'email', 'password'])]
|
|
||||||
|
#[Fillable(['name', 'email', 'password', 'distance_unit', 'settings'])]
|
||||||
#[Hidden(['password', 'remember_token'])]
|
#[Hidden(['password', 'remember_token'])]
|
||||||
class User extends Authenticatable
|
#[ObservedBy(UserObserver::class)]
|
||||||
|
class User extends Authenticatable implements MustVerifyEmail
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
|
||||||
use HasFactory, Notifiable;
|
|
||||||
|
|
||||||
/**
|
/** @use HasFactory<UserFactory> */
|
||||||
* Get the attributes that should be cast.
|
use HasFactory, HasAchievements, HasApiTokens, HasRoles, HasPushSubscriptions, Notifiable;
|
||||||
*
|
|
||||||
* @return array<string, string>
|
protected $casts = [
|
||||||
*/
|
'email_verified_at' => 'datetime',
|
||||||
protected function casts(): array
|
'password' => 'hashed',
|
||||||
|
'settings' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $appends = ['resolved_settings'];
|
||||||
|
|
||||||
|
public function calculateAchievementsAndClearCache(): void
|
||||||
{
|
{
|
||||||
return [
|
$this->clearCache();
|
||||||
'email_verified_at' => 'datetime',
|
$this->calculateAchievements();
|
||||||
'password' => 'hashed',
|
}
|
||||||
];
|
|
||||||
|
public function clearCache(): void
|
||||||
|
{
|
||||||
|
Cache::forget("user_flights_{$this->id}");
|
||||||
|
|
||||||
|
$this
|
||||||
|
->followers()
|
||||||
|
->get()
|
||||||
|
->each(fn ($follower) => Cache::forget("user_following_flights_{$follower->id}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function achievements(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserAchievement::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSetting(string $key): mixed
|
||||||
|
{
|
||||||
|
$defaults = SettingsRegistry::defaults();
|
||||||
|
return $this->settings[$key] ?? $defaults[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateSettings(array $values): void
|
||||||
|
{
|
||||||
|
$current = array_merge(SettingsRegistry::defaults(), $this->settings ?? []);
|
||||||
|
$this->update(['settings' => array_merge($current, $values)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSetting($settingName, $value) : void{
|
||||||
|
$this->updateSettings([$settingName => $value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolvedSettings(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn() => array_merge(SettingsRegistry::defaults(), $this->settings ?? [])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 sendPasswordResetNotification($token): void
|
||||||
|
{
|
||||||
|
$this->notify(new ResetPasswordNotification($token));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolveRouteBinding($value, $field = null): ?User
|
||||||
|
{
|
||||||
|
return $this->where('name', 'ilike', $value)->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function flights(): HasMany {
|
||||||
|
return $this->hasMany(UserFlight::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function departedFlights() : HasMany {
|
||||||
|
return $this->flights()->where('departure_date', '<=', now('UTC'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upcomingFlights() : HasMany {
|
||||||
|
return $this->flights()->where('departure_date', '>=', now('UTC'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function flightsWithRelationshipsLoaded(?string $filter = null): Collection
|
||||||
|
{
|
||||||
|
$key = "user_flights_{$this->id}";
|
||||||
|
|
||||||
|
$json = Cache::remember($key, now()->addDays(30), function () {
|
||||||
|
return $this->flights()
|
||||||
|
->with([
|
||||||
|
'departureAirport.region.country',
|
||||||
|
'departureAirport.region.continent',
|
||||||
|
'arrivalAirport.region.country',
|
||||||
|
'arrivalAirport.region.continent',
|
||||||
|
'airline.country',
|
||||||
|
'airline.alliance',
|
||||||
|
'aircraft',
|
||||||
|
'seatType',
|
||||||
|
'flightReason',
|
||||||
|
'flightClass',
|
||||||
|
'crewType'
|
||||||
|
])
|
||||||
|
->orderBy('departure_date', 'desc')
|
||||||
|
->get()
|
||||||
|
->values()
|
||||||
|
->toJson();
|
||||||
|
});
|
||||||
|
|
||||||
|
$collection = collect(json_decode($json));
|
||||||
|
$today = now('UTC')->toDateString();
|
||||||
|
|
||||||
|
return match ($filter) {
|
||||||
|
'departed' => $collection->filter(fn($f) => Carbon::parse($f->departure_date)->toDateString() <= $today)->values(),
|
||||||
|
'upcoming' => $collection->filter(fn($f) => Carbon::parse($f->departure_date)->toDateString() > $today)->values(),
|
||||||
|
default => $collection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ImportedFlights(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ImportedFlight::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function following(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Followee::class, 'user_id')->verified();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pendingFollowers(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(User::class, 'followees', 'followee_id', 'user_id')
|
||||||
|
->withPivot('verified')
|
||||||
|
->wherePivot('verified', false)
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
public function followers(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(User::class, 'followees', 'followee_id', 'user_id')
|
||||||
|
->withPivot('verified')
|
||||||
|
->wherePivot('verified', true)
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isFollowing(User $user): bool
|
||||||
|
{
|
||||||
|
return $this->following()
|
||||||
|
->where('followee_id', $user->id)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function followStatus(User $user): string
|
||||||
|
{
|
||||||
|
$followee = $this->following()->where('followee_id', $user->id)->first();
|
||||||
|
|
||||||
|
if (!$followee) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $followee->verified ? 'following' : 'requested';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notifications(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Notification::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property int $user_id
|
||||||
|
* @property int $achievement_id
|
||||||
|
* @property int|null $progress
|
||||||
|
*
|
||||||
|
* @property-read User $user
|
||||||
|
* @property-read Achievement $achievement
|
||||||
|
*/
|
||||||
|
class UserAchievement extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'achievement_id',
|
||||||
|
'progress',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'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
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function achievement(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Achievement::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class UserAction extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'type',
|
||||||
|
'data'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'user_id' => 'integer',
|
||||||
|
'data' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $appends = [
|
||||||
|
'display_type',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function displayType(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn () => match ($this->type) {
|
||||||
|
'flight_booked' => 'Flight Booked',
|
||||||
|
'flight_cancelled' => 'Flight Cancelled',
|
||||||
|
'flight_updated' => 'Flight Updated',
|
||||||
|
'flight_imported' => 'Flight Imported from FR24',
|
||||||
|
'flight_logged' => 'Flight Logged',
|
||||||
|
'flight_deleted' => 'Flight Deleted',
|
||||||
|
'flight_departing' => 'Flight Departed',
|
||||||
|
'flight_arriving' => 'Flight Landed',
|
||||||
|
default => 'Unknown Action'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\AirlineApiController;
|
||||||
|
use App\Observers\FlightObserver;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
#[ObservedBy(FlightObserver::class)]
|
||||||
|
class UserFlight extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'user_flights';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'departure_date',
|
||||||
|
'arrival_date',
|
||||||
|
'flight_number',
|
||||||
|
'departure_airport_id',
|
||||||
|
'arrival_airport_id',
|
||||||
|
'airline_id',
|
||||||
|
'aircraft_id',
|
||||||
|
'aircraft_registration',
|
||||||
|
'seat_number',
|
||||||
|
'seat_type_id',
|
||||||
|
'flight_class_id',
|
||||||
|
'flight_reason_id',
|
||||||
|
'crew_type_id',
|
||||||
|
'note',
|
||||||
|
'auto_update',
|
||||||
|
'departure_processed_at',
|
||||||
|
'arrival_processed_at'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'departure_date' => 'immutable_datetime',
|
||||||
|
'arrival_date' => 'immutable_datetime',
|
||||||
|
'auto_update' => 'boolean',
|
||||||
|
'departure_processed_at' => 'datetime',
|
||||||
|
'arrival_processed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $appends = [
|
||||||
|
'departure_date_display',
|
||||||
|
'departure_time_display',
|
||||||
|
'arrival_date_display',
|
||||||
|
'arrival_time_display',
|
||||||
|
'arrival_day_difference',
|
||||||
|
'duration',
|
||||||
|
'duration_display',
|
||||||
|
'distance',
|
||||||
|
'livery',
|
||||||
|
'scope',
|
||||||
|
'range',
|
||||||
|
'region_range',
|
||||||
|
'class_seat_combined',
|
||||||
|
'other_users_on_flight'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (UserFlight $flight) {
|
||||||
|
$flight->departure_processed_at = now();
|
||||||
|
$flight->arrival_processed_at = now();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function calculateGreatCircleDistance(): float{
|
||||||
|
$earthRadiusKm = 6371;
|
||||||
|
[$depLat, $depLong] = [$this->departureAirport->latitude_deg, $this->departureAirport->longitude_deg];
|
||||||
|
[$arrLat, $arrLong] = [$this->arrivalAirport->latitude_deg, $this->arrivalAirport->longitude_deg];
|
||||||
|
|
||||||
|
|
||||||
|
$latDelta = deg2rad($arrLat - $depLat);
|
||||||
|
$longDelta = deg2rad($arrLong - $depLong);
|
||||||
|
|
||||||
|
$a = sin($latDelta / 2) * sin($latDelta / 2)
|
||||||
|
+ cos(deg2rad($depLat)) * cos(deg2rad($arrLat))
|
||||||
|
* sin($longDelta / 2) * sin($longDelta / 2);
|
||||||
|
|
||||||
|
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
|
||||||
|
|
||||||
|
return $earthRadiusKm * $c;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function departureDateDisplay(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn() => $this->departure_date
|
||||||
|
->toMutable()
|
||||||
|
->setTimezone($this->departureAirport->timezone)
|
||||||
|
->format('j M Y')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function classSeatCombined(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn() => $this->flightClass?->name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function departureTimeDisplay(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn() => $this->departure_date
|
||||||
|
->toMutable()
|
||||||
|
->setTimezone($this->departureAirport->timezone)
|
||||||
|
->format('g:iA')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function arrivalDateDisplay(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn() => $this->arrival_date
|
||||||
|
?->copy()
|
||||||
|
->setTimezone($this->arrivalAirport->timezone)
|
||||||
|
->format('j M Y')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function arrivalTimeDisplay(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn() => $this->arrival_date
|
||||||
|
?->copy()
|
||||||
|
->setTimezone($this->arrivalAirport->timezone)
|
||||||
|
->format('g:iA')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function scope(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn() => $this->departureAirport->region->country_id == $this->arrivalAirport->region->country_id ? 'domestic' : 'international'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
protected function range(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn() => $this->departureAirport->region->continent_id == $this->arrivalAirport->region->continent_id ? 'intracontinental' : 'intercontinental'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function regionRange(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn() => $this->departureAirport->region_id == $this->arrivalAirport->region_id ? 'intraregional' : 'interregional'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function arrivalDayDifference(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: function () {
|
||||||
|
if (!$this->arrival_date) return 0;
|
||||||
|
|
||||||
|
$departureLocal = $this->departure_date->copy()->setTimezone($this->departureAirport->timezone);
|
||||||
|
$arrivalLocal = $this->arrival_date->copy()->setTimezone($this->arrivalAirport->timezone);
|
||||||
|
|
||||||
|
return (int) abs(
|
||||||
|
Carbon::parse($arrivalLocal->toDateString())
|
||||||
|
->diffInDays(Carbon::parse($departureLocal->toDateString()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function duration(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn() => $this->departure_date->diffInMinutes($this->arrival_date)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function durationDisplay(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: function () {
|
||||||
|
$hours = intdiv($this->duration, 60);
|
||||||
|
$minutes = $this->duration % 60;
|
||||||
|
|
||||||
|
return $hours . 'h ' . str_pad($minutes, 2, '0', STR_PAD_LEFT) . 'm';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function distance(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn() => round($this->calculateGreatCircleDistance())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDomestic() : bool{
|
||||||
|
return $this->departureAirport->region->country_id == $this->arrivalAirport->region->country_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isInternational() : bool{
|
||||||
|
return !$this->isDomestic();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function otherUsersOnFlight(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: function () {
|
||||||
|
return once(fn() => UserFlight::where('user_id', '!=', $this->user_id)
|
||||||
|
->where('departure_airport_id', $this->departure_airport_id)
|
||||||
|
->where('arrival_airport_id', $this->arrival_airport_id)
|
||||||
|
->where('flight_number', $this->flight_number)
|
||||||
|
->where('airline_id', $this->airline_id)
|
||||||
|
->whereRaw('DATE(departure_date AT TIME ZONE \'UTC\') = ?', [
|
||||||
|
Carbon::parse($this->departure_date)->utc()->toDateString()
|
||||||
|
])
|
||||||
|
->with('user')
|
||||||
|
->get()
|
||||||
|
->pluck('user.name'));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function snapshot($userFlightId): array
|
||||||
|
{
|
||||||
|
return UserFlight::with([
|
||||||
|
'departureAirport',
|
||||||
|
'departureAirport.region.country',
|
||||||
|
'arrivalAirport',
|
||||||
|
'arrivalAirport.region.country',
|
||||||
|
'aircraft',
|
||||||
|
'airline',
|
||||||
|
'airline.country',
|
||||||
|
'airline.alliance',
|
||||||
|
'flightClass',
|
||||||
|
'seatType',
|
||||||
|
'flightReason',
|
||||||
|
'crewType',
|
||||||
|
])->find($userFlightId)->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function departureAirport(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Airport::class, 'departure_airport_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function arrivalAirport(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Airport::class, 'arrival_airport_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function crewType(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(CrewType::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function airline(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Airline::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function aircraft(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Aircraft::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function seatType(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(SeatType::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function flightClass(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(FlightClass::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function flightReason(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(FlightReason::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function livery(): Attribute{
|
||||||
|
return Attribute::make(
|
||||||
|
get: function () {
|
||||||
|
$user = auth()?->user();
|
||||||
|
|
||||||
|
$useAi = !$user || $user->getSetting('ai_liveries');
|
||||||
|
|
||||||
|
$apiUrl = config('app.logo_api_url');
|
||||||
|
|
||||||
|
$attribution = '';
|
||||||
|
$attributionUrl = '';
|
||||||
|
$isAi = false;
|
||||||
|
|
||||||
|
if (!$this->aircraft) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if ($this->airline && $useAi){
|
||||||
|
$path = "images/liveries/{$this->airline->internal_name}_{$this->aircraft->designator}.png";
|
||||||
|
if (Storage::disk('local')->exists($path)) {
|
||||||
|
$finalPath = $apiUrl."/airline/{$this->airline->internal_name}/livery/{$this->aircraft->designator}";
|
||||||
|
$attribution = 'This image is AI generated and may not be an accurate representation of the aircraft.';
|
||||||
|
$isAi = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(empty($finalPath)){
|
||||||
|
$path = "images/livery_templates/{$this->aircraft->designator}.png";
|
||||||
|
if (Storage::disk('local')->exists($path)) {
|
||||||
|
$finalPath = $apiUrl."/aircraft/{$this->aircraft->designator}/livery";
|
||||||
|
$attribution = '© NoRebbo Blank Aircraft Templates';
|
||||||
|
$attributionUrl = 'https://www.norebbo.com/category/aircraft-templates/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(empty($finalPath)){
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'image_url' => $finalPath,
|
||||||
|
'attribution' => $attribution,
|
||||||
|
'attribution_url' => $attributionUrl,
|
||||||
|
'is_ai' => $isAi,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\Notification as NotificationModel;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use NotificationChannels\WebPush\WebPushChannel;
|
||||||
|
use NotificationChannels\WebPush\WebPushMessage;
|
||||||
|
|
||||||
|
class PushNotification extends Notification
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected NotificationModel $notification
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function via($notifiable): array
|
||||||
|
{
|
||||||
|
return [WebPushChannel::class];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toWebPush($notifiable, $notification): WebPushMessage
|
||||||
|
{
|
||||||
|
return (new WebPushMessage)
|
||||||
|
->title($this->notification->title)
|
||||||
|
->body($this->notification->body)
|
||||||
|
->icon( '/img/app_icons/icon-192.png')
|
||||||
|
->data([
|
||||||
|
'url' => $this->notification->url,
|
||||||
|
'notification_id' => $this->notification->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Support\Facades\Lang;
|
||||||
|
|
||||||
|
class ResetPasswordNotification extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public $token;
|
||||||
|
|
||||||
|
public function __construct($token)
|
||||||
|
{
|
||||||
|
$this->token = $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function via($notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail($notifiable)
|
||||||
|
{
|
||||||
|
$url = url(route('password.reset', [
|
||||||
|
'token' => $this->token,
|
||||||
|
'email' => $notifiable->getEmailForPasswordReset(),
|
||||||
|
], false));
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->theme('flightsgoneby')
|
||||||
|
->subject(Lang::get('Reset your password'))
|
||||||
|
->line(Lang::get('You are receiving this email because we received a password reset request for your account.'))
|
||||||
|
->action(Lang::get('Reset Password'), $url)
|
||||||
|
->line(Lang::get('This password reset link will expire in :count minutes.', ['count' => config('auth.passwords.'.config('auth.defaults.passwords').'.expire')]))
|
||||||
|
->line(Lang::get('If you did not request a password reset, no further action is required.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class SupportRequestSubmitted extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public string $name,
|
||||||
|
public string $email,
|
||||||
|
public string $reason,
|
||||||
|
public string $message,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function via($notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail($notifiable): MailMessage
|
||||||
|
{
|
||||||
|
return (new MailMessage)
|
||||||
|
->theme('flightsgoneby')
|
||||||
|
->subject('New Support Request: ' . $this->reasonLabel())
|
||||||
|
->replyTo($this->email, $this->name)
|
||||||
|
->greeting('New Support Request')
|
||||||
|
->line('Reason: ' . $this->reasonLabel())
|
||||||
|
->line('From: ' . $this->name . ' (' . $this->email . ')')
|
||||||
|
->line($this->message);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function reasonLabel(): string
|
||||||
|
{
|
||||||
|
return match ($this->reason) {
|
||||||
|
'bug_report' => 'Bug Report',
|
||||||
|
'general_feedback' => 'General Feedback',
|
||||||
|
'missing_data' => 'Missing Data',
|
||||||
|
default => $this->reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Observers;
|
||||||
|
|
||||||
|
use App\Models\Achievement;
|
||||||
|
use App\Models\Airline;
|
||||||
|
use App\Models\Alliance;
|
||||||
|
|
||||||
|
class AirlineObserver
|
||||||
|
{
|
||||||
|
public function created(Airline $airline): void
|
||||||
|
{
|
||||||
|
if ($airline->alliance_id !== null) {
|
||||||
|
$this->syncAllianceThresholds();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updated(Airline $airline): void
|
||||||
|
{
|
||||||
|
if ($airline->wasChanged('alliance_id')) {
|
||||||
|
$this->syncAllianceThresholds();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleted(Airline $airline): void
|
||||||
|
{
|
||||||
|
$this->syncAllianceThresholds();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncAllianceThresholds(): void
|
||||||
|
{
|
||||||
|
Alliance::withCount('airlines')->each(function (Alliance $alliance) {
|
||||||
|
Achievement::where('internal_name', "airlines_alliances.all_{$alliance->internal_name}")
|
||||||
|
->update(['threshold' => $alliance->airlines_count]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Observers;
|
||||||
|
|
||||||
|
use App\Enums\FlightNotificationType;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use App\Services\FollowerNotificationService;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class FlightObserver
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate after a flight is created.
|
||||||
|
*/
|
||||||
|
public function created(UserFlight $flight): void
|
||||||
|
{
|
||||||
|
$flight->user->calculateAchievementsAndClearCache();
|
||||||
|
new FollowerNotificationService()->notifyFlightEvent($flight, FlightNotificationType::forCreation($flight));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate after a flight is updated.
|
||||||
|
* Cabin class, flight type, airline, etc. may have changed,
|
||||||
|
* which could unlock or revoke achievements.
|
||||||
|
*/
|
||||||
|
public function updated(UserFlight $flight): void
|
||||||
|
{
|
||||||
|
\Log::info('Observer fired for flight ' . $flight->id);
|
||||||
|
$flight->user->calculateAchievementsAndClearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate after a flight is deleted.
|
||||||
|
* Previously earned achievements may no longer be valid.
|
||||||
|
*/
|
||||||
|
public function deleted(UserFlight $flight): void
|
||||||
|
{
|
||||||
|
$flight->user->calculateAchievementsAndClearCache();
|
||||||
|
if ($flight->departure_date->isFuture()) {
|
||||||
|
new FollowerNotificationService()->notifyFlightEvent($flight, FlightNotificationType::Cancelled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Observers;
|
||||||
|
|
||||||
|
use App\Events\NotificationCreated;
|
||||||
|
use App\Models\Notification;
|
||||||
|
use App\Notifications\PushNotification;
|
||||||
|
|
||||||
|
class NotificationObserver
|
||||||
|
{
|
||||||
|
public function created(Notification $notification): void
|
||||||
|
{
|
||||||
|
\Log::info('Notification created: ' . $notification->id);
|
||||||
|
|
||||||
|
broadcast(new NotificationCreated($notification));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$notification->user->notify(new \App\Notifications\PushNotification($notification));
|
||||||
|
\Log::info('WebPush notify() called for user ' . $notification->user_id);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Log::error('WebPush send failed: ' . $e->getMessage(), ['exception' => $e]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Observers;
|
||||||
|
|
||||||
|
use App\Models\Notification;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class UserObserver
|
||||||
|
{
|
||||||
|
public function created(User $user): void
|
||||||
|
{
|
||||||
|
User::role('admin')
|
||||||
|
->where('id', '!=', $user->id)
|
||||||
|
->get(['id'])
|
||||||
|
->each(fn (User $admin) => Notification::create([
|
||||||
|
'user_id' => $admin->id,
|
||||||
|
'title' => 'New user registered',
|
||||||
|
'body' => "{$user->name} just signed up.",
|
||||||
|
'url' => null,
|
||||||
|
'is_achievement' => false,
|
||||||
|
'achievement_id' => null,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Notification;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Access\Response;
|
||||||
|
|
||||||
|
class NotificationPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any models.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view the model.
|
||||||
|
*/
|
||||||
|
public function view(User $user, Notification $notification): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create models.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the model.
|
||||||
|
*/
|
||||||
|
public function update(User $user, Notification $notification): bool
|
||||||
|
{
|
||||||
|
return $user->id === $notification->user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the model.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, Notification $notification): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can restore the model.
|
||||||
|
*/
|
||||||
|
public function restore(User $user, Notification $notification): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can permanently delete the model.
|
||||||
|
*/
|
||||||
|
public function forceDelete(User $user, Notification $notification): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use Illuminate\Auth\Access\Response;
|
||||||
|
|
||||||
|
class UserFlightPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any models.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view the model.
|
||||||
|
*/
|
||||||
|
public function view(User $user, UserFlight $userFlight): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create models.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the model.
|
||||||
|
*/
|
||||||
|
public function update(User $user, UserFlight $userFlight): bool
|
||||||
|
{
|
||||||
|
return $user->id === $userFlight->user_id || $user->hasRole('admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the model.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, UserFlight $userFlight): bool
|
||||||
|
{
|
||||||
|
return ($user->id === $userFlight->user_id) || $user->hasRole('admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can restore the model.
|
||||||
|
*/
|
||||||
|
public function restore(User $user, UserFlight $userFlight): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can permanently delete the model.
|
||||||
|
*/
|
||||||
|
public function forceDelete(User $user, UserFlight $userFlight): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Access\Response;
|
||||||
|
|
||||||
|
class UserPolicy
|
||||||
|
{
|
||||||
|
|
||||||
|
public function viewProfileData(?User $viewer, User $profileUser): bool
|
||||||
|
{
|
||||||
|
if ($viewer && ($viewer->id === $profileUser->id || $viewer->hasRole('admin'))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
$isPrivate = $profileUser->resolved_settings['profile_privacy'] == 'private';
|
||||||
|
|
||||||
|
if (!$isPrivate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $viewer && $viewer->isFollowing($profileUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any models.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view the model.
|
||||||
|
*/
|
||||||
|
public function view(User $user, User $model): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create models.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the model.
|
||||||
|
*/
|
||||||
|
public function update(User $user, User $model): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the model.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, User $model): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can restore the model.
|
||||||
|
*/
|
||||||
|
public function restore(User $user, User $model): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can permanently delete the model.
|
||||||
|
*/
|
||||||
|
public function forceDelete(User $user, User $model): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,18 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Models\Airline;
|
||||||
|
use App\Models\Notification;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use App\Observers\AirlineObserver;
|
||||||
|
use App\Observers\FlightObserver;
|
||||||
|
use App\Observers\NotificationObserver;
|
||||||
|
use Illuminate\Support\Facades\Vite;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -19,6 +30,15 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
if(!app()->isProduction()){
|
||||||
|
Mail::alwaysTo(config('app.test_email'));
|
||||||
|
}
|
||||||
|
Vite::prefetch(concurrency: 3);
|
||||||
|
RateLimiter::for('api', function (Request $request) {
|
||||||
|
return $request->user('sanctum')
|
||||||
|
? Limit::perMinute(60)->by($request->user('sanctum')->id)
|
||||||
|
: Limit::perMinute(10)->by($request->ip());
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Achievements;
|
||||||
|
|
||||||
|
use App\Events\NotificationCreated;
|
||||||
|
use App\Models\Achievement;
|
||||||
|
use App\Models\Notification;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserAchievement;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use App\Services\Achievements\Checkers\AchievementCheckerInterface;
|
||||||
|
use App\Services\Achievements\Checkers\AircraftChecker;
|
||||||
|
use App\Services\Achievements\Checkers\CountriesAndContinentsChecker;
|
||||||
|
use App\Services\Achievements\Checkers\AirlinesAndAlliancesChecker;
|
||||||
|
use App\Services\Achievements\Checkers\FunChallengesChecker;
|
||||||
|
use App\Services\Achievements\Checkers\GeneralFlyingChecker;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class AchievementService
|
||||||
|
{
|
||||||
|
/** Cached achievement lookups so checkers don't each hit the DB. */
|
||||||
|
private Collection $achievementCache;
|
||||||
|
|
||||||
|
/** The user currently being evaluated — set per calculate() call. */
|
||||||
|
private User $user;
|
||||||
|
|
||||||
|
private array $checkers = [
|
||||||
|
GeneralFlyingChecker::class,
|
||||||
|
CountriesAndContinentsChecker::class,
|
||||||
|
AircraftChecker::class,
|
||||||
|
AirlinesAndAlliancesChecker::class,
|
||||||
|
FunChallengesChecker::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->achievementCache = collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Orchestration
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Collection<int,UserFlight> $flights
|
||||||
|
*/
|
||||||
|
private Collection $flights;
|
||||||
|
|
||||||
|
public function calculate(User $user): void
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
$this->achievementCache = Achievement::all()->keyBy('internal_name');
|
||||||
|
|
||||||
|
// Load once with every relationship any checker could need
|
||||||
|
$this->flights = $user->flights()->with([
|
||||||
|
'airline.alliance',
|
||||||
|
'aircraft',
|
||||||
|
'flightClass',
|
||||||
|
'departureAirport.region',
|
||||||
|
'arrivalAirport.region',
|
||||||
|
'departureAirport.region.continent',
|
||||||
|
'arrivalAirport.region.continent',
|
||||||
|
])->where('departure_date', '<=', now('UTC'))->get();
|
||||||
|
|
||||||
|
foreach ($this->checkers as $checkerClass) {
|
||||||
|
$checker = new $checkerClass($this);
|
||||||
|
$checker->check($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int,UserFlight>
|
||||||
|
*/
|
||||||
|
public function getFlights(): Collection
|
||||||
|
{
|
||||||
|
return $this->flights;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Award / revoke
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a user_achievement row and fire a notification if this
|
||||||
|
* is a newly unlocked achievement.
|
||||||
|
*/
|
||||||
|
public function award(Achievement $achievement, User $user, ?int $progress = null): void
|
||||||
|
{
|
||||||
|
$existing = UserAchievement::where('user_id', $user->id)
|
||||||
|
->where('achievement_id', $achievement->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$alreadyUnlocked = $existing !== null;
|
||||||
|
|
||||||
|
UserAchievement::updateOrCreate(
|
||||||
|
['user_id' => $user->id, 'achievement_id' => $achievement->id],
|
||||||
|
['progress' => $progress],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $alreadyUnlocked) {
|
||||||
|
$this->createAchievementNotification($user, $achievement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update progress on a progressive achievement without marking it
|
||||||
|
* as unlocked (i.e. progress exists but threshold not yet met).
|
||||||
|
* Creates the row if it doesn't exist yet.
|
||||||
|
*/
|
||||||
|
public function updateProgress(Achievement $achievement, User $user, int $progress): void
|
||||||
|
{
|
||||||
|
UserAchievement::updateOrCreate(
|
||||||
|
['user_id' => $user->id, 'achievement_id' => $achievement->id],
|
||||||
|
['progress' => $progress],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a user_achievement row and its associated notification
|
||||||
|
* if the achievement is no longer valid for this user.
|
||||||
|
*/
|
||||||
|
public function revoke(Achievement $achievement, User $user): void
|
||||||
|
{
|
||||||
|
UserAchievement::where('user_id', $user->id)
|
||||||
|
->where('achievement_id', $achievement->id)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
Notification::where('user_id', $user->id)
|
||||||
|
->where('achievement_id', $achievement->id)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Helpers for checkers
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
public function resolveAchievement(string $internalName): ?Achievement
|
||||||
|
{
|
||||||
|
$achievement = $this->achievementCache->get($internalName);
|
||||||
|
return $achievement instanceof Achievement ? $achievement : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentUser(): User
|
||||||
|
{
|
||||||
|
return $this->user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Notifications
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
private function createAchievementNotification(User $user, Achievement $achievement): void
|
||||||
|
{
|
||||||
|
Notification::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'title' => $achievement->name,
|
||||||
|
'body' => $achievement->short_description,
|
||||||
|
'is_achievement' => true,
|
||||||
|
'achievement_id' => $achievement->id,
|
||||||
|
'expires_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Achievements\Checkers;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
interface AchievementCheckerInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check all achievements in this category for the given user.
|
||||||
|
* Implementations should call $this->service->award() or $this->service->revoke()
|
||||||
|
* — never touch user_achievements directly.
|
||||||
|
*/
|
||||||
|
public function check(): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Achievements\Checkers;
|
||||||
|
|
||||||
|
use App\Models\Aircraft;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class AircraftChecker extends BaseChecker
|
||||||
|
{
|
||||||
|
private const array DOUBLE_DECKER_DESIGNATORS = [
|
||||||
|
// A380
|
||||||
|
'A380', 'A388',
|
||||||
|
// 747 variants
|
||||||
|
'B741', 'B742', 'B743', 'B744', 'B748', 'B74D', 'B74R', 'B74S',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function check(): void
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var $flights Collection<int, UserFlight>
|
||||||
|
*/
|
||||||
|
$flights = $this->flights();
|
||||||
|
|
||||||
|
|
||||||
|
$flightsWithAircraft = $flights->filter(fn($f) => $f->aircraft !== null);
|
||||||
|
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flightsWithAircraft->contains(
|
||||||
|
fn(UserFlight $f) => in_array($f->aircraft->aircraft_description, ['LandPlane', 'SeaPlane'])
|
||||||
|
),
|
||||||
|
'aircraft.fly_on_a_plane'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flightsWithAircraft->contains(
|
||||||
|
fn(UserFlight $f) => $f->aircraft->aircraft_description === 'Helicopter'
|
||||||
|
),
|
||||||
|
'aircraft.fly_on_a_helicopter'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flightsWithAircraft->contains(
|
||||||
|
fn(UserFlight $f) => $f->aircraft->engine_type === 'Jet'
|
||||||
|
),
|
||||||
|
'aircraft.fly_on_a_jet'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flightsWithAircraft->contains(
|
||||||
|
fn(UserFlight $f) => $f->aircraft->engine_type === 'Turboprop/Turboshaft' || $f->aircraft->engine_type === 'Piston'
|
||||||
|
),
|
||||||
|
'aircraft.fly_on_a_prop'
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Engine count achievements ---
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flightsWithAircraft->contains(fn(UserFlight $f) => $f->aircraft->engine_count === 1 && $f->aircraft->aircraft_description !== 'Helicopter'),
|
||||||
|
'aircraft.single_engine'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flightsWithAircraft->contains(fn(UserFlight $f) => $f->aircraft->engine_count === 2 && $f->aircraft->aircraft_description !== 'Helicopter'),
|
||||||
|
'aircraft.twin_engine'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flightsWithAircraft->contains(fn(UserFlight $f) => $f->aircraft->engine_count === 3 && $f->aircraft->aircraft_description !== 'Helicopter'),
|
||||||
|
'aircraft.tri_engine'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flightsWithAircraft->contains(fn(UserFlight $f) => $f->aircraft->engine_count === 4 && $f->aircraft->aircraft_description !== 'Helicopter'),
|
||||||
|
'aircraft.quad_engine'
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Double decker ---
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flightsWithAircraft->contains(
|
||||||
|
fn(UserFlight $f) => in_array($f->aircraft->designator, self::DOUBLE_DECKER_DESIGNATORS)
|
||||||
|
),
|
||||||
|
'aircraft.double_decker'
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Smaller manufacturer (scheduled service only) ---
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flightsWithAircraft->contains(
|
||||||
|
fn(UserFlight $f) => $f->airline && $f->flight_number && !in_array(strtolower($f->aircraft->manufacturer_code), ['boeing', 'airbus'])
|
||||||
|
),
|
||||||
|
'aircraft.smaller_manufacturer'
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Boeing 7x7 families ---
|
||||||
|
|
||||||
|
$flownBoeingFamilies = collect(Aircraft::BOEING_FAMILIES)
|
||||||
|
->filter(fn($designators) =>
|
||||||
|
$flightsWithAircraft->contains(
|
||||||
|
fn(UserFlight $f) => in_array($f->aircraft->designator, $designators)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$this->awardProgress($flownBoeingFamilies, 'aircraft.all_boeing_7x7');
|
||||||
|
|
||||||
|
// --- Airbus A3xx families ---
|
||||||
|
|
||||||
|
$flownAirbusFamilies = collect(Aircraft::AIRBUS_FAMILIES)
|
||||||
|
->filter(fn($designators) =>
|
||||||
|
$flightsWithAircraft->contains(
|
||||||
|
fn(UserFlight $f) => in_array($f->aircraft->designator, $designators)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$this->awardProgress($flownAirbusFamilies, 'aircraft.all_airbus_a3xx');
|
||||||
|
|
||||||
|
$flown737Variants = collect(Aircraft::BOEING_737_VARIANTS)
|
||||||
|
->filter(fn($designators) =>
|
||||||
|
$flightsWithAircraft->contains(
|
||||||
|
fn(UserFlight $f) => in_array($f->aircraft->designator, $designators)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
->count();
|
||||||
|
$this->awardProgress($flown737Variants, 'aircraft.all_boeing_737');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Achievements\Checkers;
|
||||||
|
|
||||||
|
use App\Models\Airline;
|
||||||
|
use App\Models\Alliance;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class AirlinesAndAlliancesChecker extends BaseChecker
|
||||||
|
{
|
||||||
|
const array US_3 = ['american-airlines', 'delta', 'united-airlines'];
|
||||||
|
const array ME_3 = ['emirates', 'etihad-airways', 'qatar-airways'];
|
||||||
|
const array CN_3 = ['china-southern-airlines', 'china-eastern', 'air-china'];
|
||||||
|
|
||||||
|
public function check(): void
|
||||||
|
{
|
||||||
|
$flights = $this->flights();
|
||||||
|
|
||||||
|
$alliances = Alliance::withCount('airlines')->pluck('id', 'internal_name');
|
||||||
|
|
||||||
|
$flownAllianceAirlines = $flights
|
||||||
|
->map(fn(UserFlight $f) => $f->airline?->canonical())
|
||||||
|
->filter(fn(?Airline $a) => $a?->alliance !== null)
|
||||||
|
->groupBy(fn(Airline $a) => $a->alliance->internal_name)
|
||||||
|
->map(fn($group) => $group->pluck('internal_name')->unique()->count());
|
||||||
|
|
||||||
|
$check = fn(string $alliance): int => $flownAllianceAirlines->get($alliance, 0);
|
||||||
|
|
||||||
|
$this->awardProgress($check('skyteam'), 'airlines_alliances.all_skyteam');
|
||||||
|
$this->awardProgress($check('oneworld'), 'airlines_alliances.all_oneworld');
|
||||||
|
$this->awardProgress($check('star_alliance'), 'airlines_alliances.all_star_alliance');
|
||||||
|
$this->awardProgress($check('vanilla_alliance'), 'airlines_alliances.all_vanilla_alliance');
|
||||||
|
|
||||||
|
$flownAirlines = $flights
|
||||||
|
->pluck('airline.internal_name')
|
||||||
|
->filter()
|
||||||
|
->unique();
|
||||||
|
|
||||||
|
$checkGroup = fn(array $group): int => $flownAirlines->intersect($group)->count();
|
||||||
|
|
||||||
|
$this->awardProgress($checkGroup(self::ME_3), 'airlines_alliances.me3');
|
||||||
|
$this->awardProgress($checkGroup(self::US_3), 'airlines_alliances.us3');
|
||||||
|
$this->awardProgress($checkGroup(self::CN_3), 'airlines_alliances.cn3');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Achievements\Checkers;
|
||||||
|
|
||||||
|
use App\Models\Achievement;
|
||||||
|
use App\Services\Achievements\AchievementService;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
abstract class BaseChecker implements AchievementCheckerInterface
|
||||||
|
{
|
||||||
|
public function __construct(protected AchievementService $service) {}
|
||||||
|
|
||||||
|
protected function flights(): Collection
|
||||||
|
{
|
||||||
|
return $this->service->getFlights();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an achievement ID from its internal name.
|
||||||
|
* Results are cached on the service so repeated lookups are free.
|
||||||
|
*/
|
||||||
|
protected function achievement(string $internalName): ?Achievement
|
||||||
|
{
|
||||||
|
return $this->service->resolveAchievement($internalName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Award a boolean (non-progressive) achievement if the condition is met,
|
||||||
|
* or revoke it if the condition is no longer met.
|
||||||
|
*/
|
||||||
|
protected function awardIf(bool $condition, string $internalName): void
|
||||||
|
{
|
||||||
|
$achievement = $this->achievement($internalName);
|
||||||
|
|
||||||
|
if (! $achievement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($condition) {
|
||||||
|
$this->service->award($achievement, $this->currentUser());
|
||||||
|
} else {
|
||||||
|
$this->service->revoke($achievement, $this->currentUser());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Award a progressive achievement, updating progress and
|
||||||
|
* unlocking/revoking based on whether progress meets the threshold.
|
||||||
|
*/
|
||||||
|
protected function awardProgress(int $progress, string $internalName): void
|
||||||
|
{
|
||||||
|
$achievement = $this->achievement($internalName);
|
||||||
|
if (! $achievement) return;
|
||||||
|
|
||||||
|
if ($progress >= $achievement->threshold) {
|
||||||
|
$this->service->award($achievement, $this->currentUser(), $progress);
|
||||||
|
} else {
|
||||||
|
$this->service->updateProgress($achievement, $this->currentUser(), $progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user currently being evaluated.
|
||||||
|
* Set by AchievementService before calling check().
|
||||||
|
*/
|
||||||
|
protected function currentUser(): \App\Models\User
|
||||||
|
{
|
||||||
|
return $this->service->currentUser();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Achievements\Checkers;
|
||||||
|
|
||||||
|
use App\Models\Continent;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class CountriesAndContinentsChecker extends BaseChecker
|
||||||
|
{
|
||||||
|
private const INHABITED_CONTINENTS = [
|
||||||
|
'africa', 'asia', 'europe', 'north_america', 'oceania', 'south_america',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function check(): void
|
||||||
|
{
|
||||||
|
$flights = $this->flights();
|
||||||
|
// Resolve internal_name → id once, used throughout
|
||||||
|
$continents = Continent::pluck('id', 'internal_name');
|
||||||
|
|
||||||
|
$arrivalContinentIds = $flights->pluck('arrivalAirport.region.continent_id')->unique();
|
||||||
|
|
||||||
|
$has = fn(string $name) => $arrivalContinentIds->contains($continents[$name]);
|
||||||
|
|
||||||
|
// --- Simple "fly to X continent" achievements ---
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flights->contains(fn(UserFlight $f) =>
|
||||||
|
$f->departureAirport->region->continent_id !== $f->arrivalAirport->region->continent_id
|
||||||
|
),
|
||||||
|
'countries_continents.intercontinental'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->awardIf($has('africa'), 'countries_continents.fly_to_africa');
|
||||||
|
$this->awardIf($has('asia'), 'countries_continents.fly_to_asia');
|
||||||
|
$this->awardIf($has('oceania'), 'countries_continents.fly_to_oceania');
|
||||||
|
$this->awardIf($has('antarctica'), 'countries_continents.fly_to_antarctica');
|
||||||
|
$this->awardIf($has('europe'), 'countries_continents.fly_to_europe');
|
||||||
|
$this->awardIf($has('south_america'), 'countries_continents.fly_to_south_america');
|
||||||
|
$this->awardIf($has('north_america'), 'countries_continents.fly_to_north_america');
|
||||||
|
|
||||||
|
// --- All inhabited continents ---
|
||||||
|
|
||||||
|
$inhabitedIds = collect(self::INHABITED_CONTINENTS)->map(fn($name) => $continents[$name]);
|
||||||
|
|
||||||
|
$visitedInhabited = $arrivalContinentIds
|
||||||
|
->intersect($inhabitedIds)
|
||||||
|
->unique()
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$this->awardProgress($visitedInhabited, 'countries_continents.all_inhabited_continents');
|
||||||
|
|
||||||
|
// --- All continents including Antarctica ---
|
||||||
|
|
||||||
|
$this->awardProgress(
|
||||||
|
$arrivalContinentIds->unique()->count(),
|
||||||
|
'countries_continents.all_continents'
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Continent route pairs ---
|
||||||
|
|
||||||
|
$this->checkContinentPairs($flights);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, UserFlight> $flights
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function checkContinentPairs(Collection $flights): void
|
||||||
|
{
|
||||||
|
$oneWayRoutes = collect();
|
||||||
|
$directedRoutes = collect();
|
||||||
|
|
||||||
|
foreach ($flights as $flight) {
|
||||||
|
$dep = $flight->departureAirport->region->continent->internal_name;
|
||||||
|
$arr = $flight->arrivalAirport->region->continent->internal_name;
|
||||||
|
|
||||||
|
if (!in_array($dep, self::INHABITED_CONTINENTS) || !in_array($arr, self::INHABITED_CONTINENTS)) continue;
|
||||||
|
if ($dep === $arr) {
|
||||||
|
$depCountry = $flight->departureAirport->region->country_id;
|
||||||
|
$arrCountry = $flight->arrivalAirport->region->country_id;
|
||||||
|
if ($depCountry === $arrCountry) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directed route key e.g. "europe→asia"
|
||||||
|
$directedRoutes->push("{$dep}→{$arr}");
|
||||||
|
|
||||||
|
// Undirected — sort alphabetically so "europe→asia" and "asia→europe" are the same
|
||||||
|
$undirected = implode('→', collect([$dep, $arr])->sort()->values()->all());
|
||||||
|
$oneWayRoutes->push($undirected);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->awardProgress(
|
||||||
|
$oneWayRoutes->unique()->count(),
|
||||||
|
'countries_continents.all_continent_pairs_one_way'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->awardProgress(
|
||||||
|
$directedRoutes->unique()->count(),
|
||||||
|
'countries_continents.all_continent_pairs_both_ways'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Achievements\Checkers;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
|
||||||
|
class FunChallengesChecker extends BaseChecker
|
||||||
|
{
|
||||||
|
const array US_STATES = [
|
||||||
|
'US-AK', 'US-AL', 'US-AR', 'US-AZ', 'US-CA', 'US-CO', 'US-CT', 'US-DC',
|
||||||
|
'US-DE', 'US-FL', 'US-GA', 'US-HI', 'US-IA', 'US-ID', 'US-IL', 'US-IN',
|
||||||
|
'US-KS', 'US-KY', 'US-LA', 'US-MA', 'US-MD', 'US-ME', 'US-MI', 'US-MN',
|
||||||
|
'US-MO', 'US-MS', 'US-MT', 'US-NC', 'US-ND', 'US-NE', 'US-NH', 'US-NJ',
|
||||||
|
'US-NM', 'US-NV', 'US-NY', 'US-OH', 'US-OK', 'US-OR', 'US-PA', 'US-RI',
|
||||||
|
'US-SC', 'US-SD', 'US-TN', 'US-TX', 'US-UT', 'US-VA', 'US-VT', 'US-WA',
|
||||||
|
'US-WI', 'US-WV', 'US-WY',
|
||||||
|
];
|
||||||
|
|
||||||
|
const array AUSTRALIAN_STATES = [
|
||||||
|
'AU-ACT', 'AU-NSW', 'AU-NT', 'AU-QLD', 'AU-SA', 'AU-TAS', 'AU-VIC', 'AU-WA',
|
||||||
|
];
|
||||||
|
|
||||||
|
const array CHINESE_PROVINCES = [
|
||||||
|
'CN-11', 'CN-12', 'CN-13', 'CN-14', 'CN-15', 'CN-21', 'CN-22', 'CN-23',
|
||||||
|
'CN-31', 'CN-32', 'CN-33', 'CN-34', 'CN-35', 'CN-36', 'CN-37', 'CN-41',
|
||||||
|
'CN-42', 'CN-43', 'CN-44', 'CN-45', 'CN-46', 'CN-50', 'CN-51', 'CN-52',
|
||||||
|
'CN-53', 'CN-54', 'CN-61', 'CN-62', 'CN-63', 'CN-64', 'CN-65',
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
|
const array BRAZILIAN_STATES = [
|
||||||
|
'BR-AC', 'BR-AL', 'BR-AM', 'BR-AP', 'BR-BA', 'BR-CE', 'BR-DF', 'BR-ES',
|
||||||
|
'BR-GO', 'BR-MA', 'BR-MG', 'BR-MS', 'BR-MT', 'BR-PA', 'BR-PB', 'BR-PE',
|
||||||
|
'BR-PI', 'BR-PR', 'BR-RJ', 'BR-RN', 'BR-RO', 'BR-RR', 'BR-RS', 'BR-SC',
|
||||||
|
'BR-SE', 'BR-SP', 'BR-TO',
|
||||||
|
];
|
||||||
|
|
||||||
|
const array CANADIAN_PROVINCES = [
|
||||||
|
'CA-AB', 'CA-BC', 'CA-MB', 'CA-NB', 'CA-NL', 'CA-NS', 'CA-NT',
|
||||||
|
'CA-NU', 'CA-ON', 'CA-PE', 'CA-QC', 'CA-SK', 'CA-YT',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function check(): void
|
||||||
|
{
|
||||||
|
$flights = $this->flights();
|
||||||
|
|
||||||
|
$airlineLetters = $flights
|
||||||
|
->filter(fn(UserFlight $f) => $f->airline?->iata_code !== null)
|
||||||
|
->map(fn(UserFlight $f) => strtoupper($f->airline->iata_code[0]))
|
||||||
|
->filter(fn($letter) => ctype_alpha($letter))
|
||||||
|
->unique()
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$this->awardProgress($airlineLetters, 'fun_challenges.airline_alphabet');
|
||||||
|
|
||||||
|
|
||||||
|
$airportLetters = $flights
|
||||||
|
->flatMap(fn(UserFlight $f) => [
|
||||||
|
$f->departureAirport?->iata_code,
|
||||||
|
$f->arrivalAirport?->iata_code,
|
||||||
|
])
|
||||||
|
->filter()
|
||||||
|
->map(fn($code) => strtoupper($code[0]))
|
||||||
|
->unique()
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$this->awardProgress($airportLetters, 'fun_challenges.airport_alphabet');
|
||||||
|
|
||||||
|
// --- US States ---
|
||||||
|
$visitedUsStates = $flights
|
||||||
|
->flatMap(fn(UserFlight $f) => [
|
||||||
|
$f->departureAirport?->region?->code,
|
||||||
|
$f->arrivalAirport?->region?->code,
|
||||||
|
])
|
||||||
|
->filter()
|
||||||
|
->filter(fn($code) => in_array($code, self::US_STATES))
|
||||||
|
->unique()
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$this->awardProgress($visitedUsStates, 'fun_challenges.us_states');
|
||||||
|
|
||||||
|
// --- Australian States ---
|
||||||
|
$visitedAustralianStates = $flights
|
||||||
|
->flatMap(fn(UserFlight $f) => [
|
||||||
|
$f->departureAirport?->region?->code,
|
||||||
|
$f->arrivalAirport?->region?->code,
|
||||||
|
])
|
||||||
|
->filter()
|
||||||
|
->filter(fn($code) => in_array($code, self::AUSTRALIAN_STATES))
|
||||||
|
->unique()
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$this->awardProgress($visitedAustralianStates, 'fun_challenges.australian_states');
|
||||||
|
|
||||||
|
// --- Chinese Provinces ---
|
||||||
|
$visitedChineseProvinces = $flights
|
||||||
|
->flatMap(fn(UserFlight $f) => [
|
||||||
|
$f->departureAirport?->region?->code,
|
||||||
|
$f->arrivalAirport?->region?->code,
|
||||||
|
])
|
||||||
|
->filter()
|
||||||
|
->filter(fn($code) => in_array($code, self::CHINESE_PROVINCES))
|
||||||
|
->unique()
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$this->awardProgress($visitedChineseProvinces, 'fun_challenges.chinese_provinces');
|
||||||
|
|
||||||
|
// --- Brazilian States ---
|
||||||
|
$visitedBrazilianStates = $flights
|
||||||
|
->flatMap(fn(UserFlight $f) => [
|
||||||
|
$f->departureAirport?->region?->code,
|
||||||
|
$f->arrivalAirport?->region?->code,
|
||||||
|
])
|
||||||
|
->filter()
|
||||||
|
->filter(fn($code) => in_array($code, self::BRAZILIAN_STATES))
|
||||||
|
->unique()
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$this->awardProgress($visitedBrazilianStates, 'fun_challenges.brazilian_states');
|
||||||
|
|
||||||
|
// --- Canadian Provinces ---
|
||||||
|
$visitedCanadianProvinces = $flights
|
||||||
|
->flatMap(fn(UserFlight $f) => [
|
||||||
|
$f->departureAirport?->region?->code,
|
||||||
|
$f->arrivalAirport?->region?->code,
|
||||||
|
])
|
||||||
|
->filter()
|
||||||
|
->filter(fn($code) => in_array($code, self::CANADIAN_PROVINCES))
|
||||||
|
->unique()
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$this->awardProgress($visitedCanadianProvinces, 'fun_challenges.canadian_provinces');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Achievements\Checkers;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class GeneralFlyingChecker extends BaseChecker
|
||||||
|
{
|
||||||
|
public function check(): void
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var $flights Collection<int, UserFlight>
|
||||||
|
*/
|
||||||
|
$flights = $this->flights();
|
||||||
|
$count = $flights->count();
|
||||||
|
|
||||||
|
// --- Boolean achievements ---
|
||||||
|
|
||||||
|
$this->awardIf($count >= 1, 'general_flying.first_flight');
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flights->contains(fn ($f) => $f->isDomestic()),
|
||||||
|
'general_flying.domestic_flight'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flights->contains(fn ($f) => $f->isInternational()),
|
||||||
|
'general_flying.international_flight'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flights->contains(fn ($f) => $f->flightClass->internal_name === 'business'),
|
||||||
|
'general_flying.business_class'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flights->contains(fn ($f) => $f->flightClass->internal_name === 'first'),
|
||||||
|
'general_flying.first_class'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flights->contains(fn ($f) => $f->flightClass->internal_name === 'premium_economy'),
|
||||||
|
'general_flying.premium_economy'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flights->contains(fn ($f) => in_array($f->flightClass->internal_name, ['business', 'first'])),
|
||||||
|
'general_flying.business_or_first'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flights->contains(fn ($f) => $f->flightClass->internal_name === 'private'),
|
||||||
|
'general_flying.fly_private'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flights->contains(fn ($f) => $f->flightClass->internal_name === 'general_aviation'),
|
||||||
|
'general_flying.general_aviation'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->awardIf(
|
||||||
|
$flights->filter(fn (UserFlight $f) => $f->isDomestic())
|
||||||
|
->map(fn (UserFlight $f) => $f->departureAirport->region->country_id)
|
||||||
|
->unique()
|
||||||
|
->count() >= 2,
|
||||||
|
'general_flying.domestic_two_countries'
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Progressive achievements ---
|
||||||
|
|
||||||
|
$totalDistance = $flights->sum('distance');
|
||||||
|
|
||||||
|
$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');
|
||||||
|
$this->awardProgress($count,'general_flying.100_flights');
|
||||||
|
$this->awardProgress($count,'general_flying.500_flights');
|
||||||
|
$this->awardProgress($count,'general_flying.1000_flights');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\DTOs\MissingLivery;
|
||||||
|
use App\Models\IgnoredMissingLivery;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class AdminService
|
||||||
|
{
|
||||||
|
/** @return Collection<int, MissingLivery> */
|
||||||
|
function getMissingLiveries(): Collection
|
||||||
|
{
|
||||||
|
|
||||||
|
/* $existingFiles = collect(glob(public_path('img/liveries/generated/*')))
|
||||||
|
->map(fn ($path) => pathinfo($path, PATHINFO_FILENAME))
|
||||||
|
->toArray();*/
|
||||||
|
|
||||||
|
|
||||||
|
$existingFiles = collect(glob(Storage::disk('local')->path('images/liveries').'/*.png'))
|
||||||
|
->map(fn ($path) => pathinfo($path, PATHINFO_FILENAME))
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$combos = UserFlight::with(['aircraft', 'airline'])
|
||||||
|
->select('airline_id', 'aircraft_id')
|
||||||
|
->whereNotNull('airline_id')
|
||||||
|
->whereNotNull('aircraft_id')
|
||||||
|
->distinct()
|
||||||
|
->get()
|
||||||
|
->filter(fn ($flight) => $flight->aircraft && $flight->airline)
|
||||||
|
->map(fn ($flight) => [
|
||||||
|
'airline_name' => $flight->airline->name,
|
||||||
|
'aircraft_display_name' => $flight->aircraft->display_name,
|
||||||
|
'filename' => $flight->airline->internal_name . '_' . $flight->aircraft->designator,
|
||||||
|
'clipboard_text' => $flight->airline->name . ' ' . $flight->aircraft->display_name_short,
|
||||||
|
])
|
||||||
|
->filter(fn ($combo) => !in_array($combo['filename'], $existingFiles));
|
||||||
|
|
||||||
|
$ignoredFiles = IgnoredMissingLivery::whereIn('filename', $combos->pluck('filename'))->pluck('filename')->toArray();
|
||||||
|
|
||||||
|
return $combos
|
||||||
|
->filter(fn ($combo) => !in_array($combo['filename'], $ignoredFiles))
|
||||||
|
->sortBy('airline_name')
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\FlightNotificationType;
|
||||||
|
use App\Models\Notification;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
|
||||||
|
class FollowerNotificationService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Notify a user's followers about something that happened to one of their flights.
|
||||||
|
*/
|
||||||
|
public function notifyFlightEvent(UserFlight $flight, FlightNotificationType $type): void
|
||||||
|
{
|
||||||
|
$this->notify(
|
||||||
|
user: $flight->user,
|
||||||
|
settingKey: $type->settingKey(),
|
||||||
|
title: $type->title($flight->user),
|
||||||
|
body: $type->body($flight),
|
||||||
|
url: $type->url($flight),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic entry point for any future "notify all followers about X" need
|
||||||
|
* that isn't tied to a flight event.
|
||||||
|
*/
|
||||||
|
public function notify(User $user, string $settingKey, string $title, string $body, string $url): void
|
||||||
|
{
|
||||||
|
$user->followers()
|
||||||
|
->get()
|
||||||
|
->filter(fn (User $follower) => $follower->getSetting($settingKey))
|
||||||
|
->each(fn (User $follower) => Notification::create([
|
||||||
|
'user_id' => $follower->id,
|
||||||
|
'title' => $title,
|
||||||
|
'body' => $body,
|
||||||
|
'url' => $url,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Settings;
|
||||||
|
|
||||||
|
class SettingsRegistry
|
||||||
|
{
|
||||||
|
|
||||||
|
public static function categories(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Units of Measurement' => 'Select either metric or incorrect units.',
|
||||||
|
'FlightsGoneBy Settings' => 'Settings for Site Behaviour',
|
||||||
|
'AI Generated Content' => 'Airline tail logos and liveries are AI generated with human cleanup. If you would rather not see any AI, then our blank aircraft templates are human created.',
|
||||||
|
'Account & Privacy' => 'Everything to do with your account.',
|
||||||
|
'Notifications' => 'What you want to hear about (or not!)',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function schema(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'key' => 'distance_unit',
|
||||||
|
'type' => 'select',
|
||||||
|
'label' => 'Distance Units',
|
||||||
|
'category' => 'FlightsGoneBy Settings',
|
||||||
|
'default' => 'km',
|
||||||
|
'options' => [
|
||||||
|
['value' => 'km', 'label' => 'Kilometres (km)'],
|
||||||
|
['value' => 'mi', 'label' => 'Miles (mi)'],
|
||||||
|
['value' => 'nm', 'label' => 'Nautical miles (nm)'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'category' => 'Account & Privacy',
|
||||||
|
'key' => 'profile_privacy',
|
||||||
|
'type' => 'select',
|
||||||
|
'label' => 'Account Privacy',
|
||||||
|
'default' => 'public',
|
||||||
|
'options' => [
|
||||||
|
['value' => 'public', 'label' => 'Public Profile Viewable By Everyone'],
|
||||||
|
['value' => 'private', 'label' => 'Private Profile Viewable Only By You and Your Approved Followers'],
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'default_login_page',
|
||||||
|
'type' => 'select',
|
||||||
|
'label' => 'Default Page',
|
||||||
|
'category' => 'FlightsGoneBy Settings',
|
||||||
|
'default' => 'feed_first',
|
||||||
|
'options' => [
|
||||||
|
['value' => 'feed_first', 'label' => 'My Feed if Following People, My Profile if Not'],
|
||||||
|
['value' => 'profile', 'label' => 'My Profile'],
|
||||||
|
['value' => 'feed', 'label' => 'My Feed'],
|
||||||
|
['value' => 'dashboard', 'label' => 'My Dashboard'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'default_profile_view',
|
||||||
|
'type' => 'select',
|
||||||
|
'label' => 'Default View When Loading a Profile',
|
||||||
|
'category' => 'FlightsGoneBy Settings',
|
||||||
|
'default' => 'departure-board',
|
||||||
|
'options' => [
|
||||||
|
['value' => 'departure-board', 'label' => 'Departure Board'],
|
||||||
|
['value' => 'map', 'label' => 'Map'],
|
||||||
|
['value' => 'boarding-passes', 'label' => 'Boarding Passes'],
|
||||||
|
['value' => 'achievements', 'label' => 'Achievements'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'show_map_legend',
|
||||||
|
'type' => 'checkbox',
|
||||||
|
'label' => 'Expand Map Legend By Default',
|
||||||
|
'category' => 'FlightsGoneBy Settings',
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'hide_map_filters',
|
||||||
|
'type' => 'checkbox',
|
||||||
|
'label' => 'Hide Map Filters By Default',
|
||||||
|
'category' => 'FlightsGoneBy Settings',
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'hide_impossible_achievements',
|
||||||
|
'type' => 'checkbox',
|
||||||
|
'label' => 'Hide Impossible Achievements By Default',
|
||||||
|
'category' => 'FlightsGoneBy Settings',
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'category' => 'AI Generated Content',
|
||||||
|
'key' => 'ai_liveries',
|
||||||
|
'type' => 'checkbox',
|
||||||
|
'label' => 'Show AI Generated Livery Images',
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'category' => 'AI Generated Content',
|
||||||
|
'key' => 'ai_tail_logos',
|
||||||
|
'type' => 'checkbox',
|
||||||
|
'label' => 'Show AI Generated Tail Logos',
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'departure_board_columns',
|
||||||
|
'category' => 'FlightsGoneBy Settings',
|
||||||
|
'type' => 'multiselect',
|
||||||
|
'label' => 'Which columns to show on the Departure Board',
|
||||||
|
'default' => ['airline', 'flight_number', 'departure_airport', 'arrival_airport', 'departure_date', 'departure_time', 'arrival_time', 'duration', 'distance', 'aircraft', 'registration', 'class_seat_combined'],
|
||||||
|
'options' => [
|
||||||
|
['value' => 'airline', 'label' => 'Airline Logo w/ Info Popup'],
|
||||||
|
['value' => 'airline.name', 'label' => 'Airline Name'],
|
||||||
|
['value' => 'airline.iata_code', 'label' => 'Airline IATA Code'],
|
||||||
|
['value' => 'airline.icao_code', 'label' => 'Airline ICAO Code'],
|
||||||
|
['value' => 'flight_number', 'label' => 'Flight Number'],
|
||||||
|
['value' => 'departure_airport', 'label' => 'From'],
|
||||||
|
['value' => 'arrival_airport', 'label' => 'To'],
|
||||||
|
['value' => 'departure_date', 'label' => 'Departure Date'],
|
||||||
|
['value' => 'departure_time', 'label' => 'Departure Time'],
|
||||||
|
['value' => 'arrival_time', 'label' => 'Arrival Time'],
|
||||||
|
['value' => 'duration', 'label' => 'Duration'],
|
||||||
|
['value' => 'distance', 'label' => 'Distance'],
|
||||||
|
['value' => 'aircraft', 'label' => 'Aircraft Designator w/ Info Popup'],
|
||||||
|
['value' => 'registration', 'label' => 'Aircraft Registration'],
|
||||||
|
['value' => 'aircraft.manufacturer_code', 'label' => 'Aircraft Manufacturer'],
|
||||||
|
['value' => 'aircraft.model_full_name', 'label' => 'Aircraft Model'],
|
||||||
|
['value' => 'flight_class', 'label' => 'Class'],
|
||||||
|
['value' => 'seat_type_id', 'label' => 'Seat Type'],
|
||||||
|
['value' => 'seat_number', 'label' => 'Seat Number'],
|
||||||
|
['value' => 'flight_reason_id', 'label' => 'Reason'],
|
||||||
|
|
||||||
|
['value' => 'aircraft.display_name_short', 'label' => 'Aircraft Full Name'],
|
||||||
|
['value' => 'class_seat_combined', 'label' => 'Class/Seat Type/Seat Number Combined'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'category' => 'Notifications',
|
||||||
|
'key' => 'notify_on_new_follower',
|
||||||
|
'type' => 'checkbox',
|
||||||
|
'label' => 'Notify Me When I Receive a New Follower',
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'category' => 'Notifications',
|
||||||
|
'key' => 'notify_on_historic_flight_added',
|
||||||
|
'type' => 'checkbox',
|
||||||
|
'label' => 'Notify Me When Someone I Follow Logs a Historic Flight',
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'category' => 'Notifications',
|
||||||
|
'key' => 'notify_on_upcoming_flight_added',
|
||||||
|
'type' => 'checkbox',
|
||||||
|
'label' => 'Notify Me When Someone I Follow Logs an Upcoming Flight',
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'category' => 'Notifications',
|
||||||
|
'key' => 'notify_on_flight_cancellation',
|
||||||
|
'type' => 'checkbox',
|
||||||
|
'label' => 'Notify Me When Someone I Follow Cancels an Upcoming Flight',
|
||||||
|
'default' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'category' => 'Notifications',
|
||||||
|
'key' => 'notify_on_flight_departure',
|
||||||
|
'type' => 'checkbox',
|
||||||
|
'label' => 'Notify Me When Someone I Follow Departs',
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'category' => 'Notifications',
|
||||||
|
'key' => 'notify_on_flight_arrival',
|
||||||
|
'type' => 'checkbox',
|
||||||
|
'label' => 'Notify Me When Someone I Follow Arrives',
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function defaults(): array
|
||||||
|
{
|
||||||
|
return collect(static::schema())
|
||||||
|
->pluck('default', 'key')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function validationRules(): array
|
||||||
|
{
|
||||||
|
$rules = [];
|
||||||
|
foreach (static::schema() as $field) {
|
||||||
|
$key = "settings.{$field['key']}";
|
||||||
|
$rules[$key] = match ($field['type']) {
|
||||||
|
'select' => ['sometimes', 'required', 'string', 'in:' . implode(',', array_column($field['options'], 'value'))],
|
||||||
|
'checkbox' => ['sometimes', 'boolean'],
|
||||||
|
'text' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
|
'multiselect' => ['sometimes', 'nullable', 'array'],
|
||||||
|
default => ['sometimes', 'nullable'],
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($field['type'] === 'multiselect') {
|
||||||
|
$rules["{$key}.*"] = ['string'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Achievements\AchievementService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin User
|
||||||
|
*/
|
||||||
|
trait HasAchievements
|
||||||
|
{
|
||||||
|
public function calculateAchievements(): void
|
||||||
|
{
|
||||||
|
app(AchievementService::class)->calculate($this);
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
-2
@@ -1,18 +1,123 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\HandleInertiaRequests;
|
||||||
|
use App\Http\Middleware\RefreshApiToken;
|
||||||
|
use App\Http\Middleware\SanctumOrTrustedOrigin;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Spatie\Permission\Middleware\PermissionMiddleware;
|
||||||
|
use Spatie\Permission\Middleware\RoleMiddleware;
|
||||||
|
use Spatie\Permission\Middleware\RoleOrPermissionMiddleware;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
channels: __DIR__.'/../routes/channels.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
|
apiPrefix: '',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
$middleware->web(append: [
|
||||||
|
HandleInertiaRequests::class,
|
||||||
|
AddLinkHeadersForPreloadedAssets::class,
|
||||||
|
RefreshApiToken::class
|
||||||
|
]);
|
||||||
|
$middleware->alias([
|
||||||
|
'role' => RoleMiddleware::class,
|
||||||
|
'permission' => PermissionMiddleware::class,
|
||||||
|
'role_or_permission' => RoleOrPermissionMiddleware::class,
|
||||||
|
'sanctum.or.trusted' => SanctumOrTrustedOrigin::class,
|
||||||
|
]);
|
||||||
//
|
//
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
|
||||||
})->create();
|
if ($request->getHost() !== config('app.api_domain')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($e->getPrevious() instanceof ModelNotFoundException) {
|
||||||
|
return response()->json(['message' => 'Resource not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Not found.'], 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
$exceptions->respond(function ($response, Throwable $e, Request $request) {
|
||||||
|
$status = $response->getStatusCode();
|
||||||
|
|
||||||
|
// API domain: never touch the response, let Laravel's own JSON rendering stand.
|
||||||
|
if ($request->getHost() === config('app.api_domain')) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = [
|
||||||
|
403 => [
|
||||||
|
'title' => "The Cockpit is Off Limits",
|
||||||
|
'message' => 'You don\'t have permission to access this page.',
|
||||||
|
],
|
||||||
|
404 => [
|
||||||
|
'title' => 'You Flight Has Been Cancelled',
|
||||||
|
'message' => 'The page you are looking for doesn\'t exist or has been moved.',
|
||||||
|
],
|
||||||
|
419 => [
|
||||||
|
'title' => 'Page Expired',
|
||||||
|
'message' => 'Your session has expired. Please refresh the page and try again.',
|
||||||
|
],
|
||||||
|
429 => [
|
||||||
|
'title' => 'Too Many Requests',
|
||||||
|
'message' => 'You\'re making too many requests. Please slow down and try again.',
|
||||||
|
],
|
||||||
|
500 => [
|
||||||
|
'title' => 'This Plane Has Made An Emergency Landing',
|
||||||
|
'message' => 'Something went wrong on our end. Please try again later.',
|
||||||
|
],
|
||||||
|
503 => [
|
||||||
|
'title' => 'Service Unavailable',
|
||||||
|
'message' => 'We\'re down for maintenance. Please check back soon.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$isLocal = app()->environment(['local', 'testing']);
|
||||||
|
$friendlyErrorsOnLocal = [404, 403];
|
||||||
|
|
||||||
|
$shouldHandle = isset($errors[$status]) && (
|
||||||
|
!$isLocal || in_array($status, $friendlyErrorsOnLocal)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$shouldHandle) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('Error', [
|
||||||
|
'statusCode' => $status,
|
||||||
|
'statusTitle' => $errors[$status]['title'],
|
||||||
|
'statusMessage' => $errors[$status]['message'],
|
||||||
|
'auth' => [
|
||||||
|
'user' => $request->user(),
|
||||||
|
'roles' => $request->user()?->getRoleNames() ?? [],
|
||||||
|
'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->toResponse($request)
|
||||||
|
->setStatusCode($status);
|
||||||
|
})
|
||||||
|
->shouldRenderJsonWhen(function ($request, Throwable $e) {
|
||||||
|
if ($request->getHost() === config('app.api_domain')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $request->expectsJson();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->create();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user