Compare commits
80
Commits
c7fe3268c7
..
1.1.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60a3bce3bf | ||
|
|
39c0ce1b44 | ||
|
|
792cb082fd | ||
|
|
ae77fec0da | ||
|
|
2a5d2d7875 | ||
|
|
dbb3dac7f6 | ||
|
|
731a6aa09a | ||
|
|
dc476fedd6 | ||
|
|
a11e8f1428 | ||
|
|
90630489df | ||
|
|
2d55aa8488 | ||
|
|
fd3b5b0405 | ||
|
|
1f23cbc033 | ||
|
|
e9ad1b45e0 | ||
|
|
64e164887b | ||
|
|
4b8fb1119b | ||
|
|
0230c77a2a | ||
|
|
f0306a6dd8 | ||
|
|
35c439dba4 | ||
|
|
3a082a57ff | ||
|
|
c016a4aaaf | ||
|
|
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 |
+17
-8
@@ -7,6 +7,8 @@ APP_URL=http://localhost
|
||||
APP_DOMAIN=flightsgoneby.test
|
||||
API_DOMAIN=api.flightsgoneby.test
|
||||
|
||||
TRUSTED_FRONTEND_ORIGINS=https://app.example.com
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
@@ -44,7 +46,6 @@ SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
@@ -58,14 +59,14 @@ REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_MAILER=mailgun
|
||||
MAIL_FROM_ADDRESS="test@yourddomain.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
LOCAL_DISK_ROOT=
|
||||
TEST_EMAIL=""
|
||||
MAILGUN_DOMAIN=
|
||||
MAILGUN_SECRET=
|
||||
MAILGUN_ENDPOINT=api.mailgun.net
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
@@ -73,4 +74,12 @@ AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
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}"
|
||||
|
||||
@@ -23,3 +23,5 @@ Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
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,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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,219 +2,33 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\DTOs\FlightStatData;
|
||||
use App\Models\Aircraft;
|
||||
use App\Models\IataEquipmentCode;
|
||||
use App\Models\Notification;
|
||||
use App\Enums\FlightNotificationType;
|
||||
use App\Models\UserFlight;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use App\Services\FollowerNotificationService;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
#[Signature('app:update-departed-flights')]
|
||||
#[Description('Command description')]
|
||||
class UpdateDepartedFlights extends Command
|
||||
{
|
||||
/**
|
||||
* Fetch live flight data from FlightStats.
|
||||
* Returns null if the request fails or no data is found.
|
||||
*/
|
||||
protected function fetchFlightData(string $airlineCode, string $flightNumber, CarbonImmutable $date): ?FlightStatData
|
||||
{
|
||||
$url = sprintf(
|
||||
'https://www.flightstats.com/v2/api-next/flight-tracker/%s/%s/%d/%d/%d',
|
||||
$airlineCode,
|
||||
$flightNumber,
|
||||
$date->year,
|
||||
$date->month,
|
||||
$date->day,
|
||||
);
|
||||
|
||||
$response = Http::withOptions([
|
||||
'verify' => config('app.verify_ssl'),
|
||||
])->get($url);
|
||||
|
||||
if (!$response->successful()) {
|
||||
Log::warning("FlightStats request failed for {$airlineCode}{$flightNumber}: HTTP {$response->status()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$flightData = $response->json('data');
|
||||
|
||||
if (empty($flightData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FlightStatData::fromApiResponse($flightData);
|
||||
}
|
||||
/**
|
||||
* Attempt to resolve the best matching Aircraft record for a given IATA equipment code.
|
||||
* Prefers passenger variants over freighters/BBJs where multiple matches exist.
|
||||
*/
|
||||
protected function guessAircraftFromIata(string $iataCode): ?Aircraft
|
||||
{
|
||||
$equipment = IataEquipmentCode::where('iata_code', $iataCode)->first();
|
||||
|
||||
if (!$equipment) {
|
||||
Log::info("Unknown IATA equipment code: {$iataCode}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidates = Aircraft::where('designator', $equipment->icao_code)->get();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
Log::info("No aircraft found for ICAO: {$equipment->icao_code} (IATA: {$iataCode})");
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($candidates->count() === 1) {
|
||||
return $candidates->first();
|
||||
}
|
||||
|
||||
// Prefer passenger variants — deprioritise freighters, BBJs, and convertibles
|
||||
$deprioritised = ['freighter', 'bbj', 'combi', 'mixed', 'cargo', 'prestige', 'winglet', 'sharklet', 'freight'];
|
||||
|
||||
$pattern = implode('|', $deprioritised);
|
||||
|
||||
$passengerVariants = $candidates->filter(
|
||||
fn(Aircraft $a) => !preg_match("/({$pattern})/i", $a->display_name_short)
|
||||
);
|
||||
|
||||
if ($passengerVariants->count() === 1) {
|
||||
return $passengerVariants->first();
|
||||
}
|
||||
|
||||
if ($passengerVariants->isNotEmpty()) {
|
||||
return $passengerVariants->first();
|
||||
}
|
||||
|
||||
return $candidates->first();
|
||||
}
|
||||
|
||||
protected function notifyDataError(UserFlight $flight): void
|
||||
{
|
||||
Notification::create([
|
||||
'user_id' => $flight->user_id,
|
||||
'title' => "Auto update failed for {$flight->flight_number}",
|
||||
'body' => "There was an error fetching flight data for {$flight->flight_number}. Please manually check the aircraft type, registration and departure/arrival times.",
|
||||
'url' => '/flights/' . $flight->id . '/edit'
|
||||
]);
|
||||
|
||||
$flight->update(['auto_update' => false]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
public function handle(): void
|
||||
{
|
||||
$now = now()->utc();
|
||||
$oneHourAgo = $now->copy()->subHours(1);
|
||||
|
||||
$userFlights = UserFlight::where('arrival_date', '<=', $now->copy()->subHour()->toDateTimeString())
|
||||
->where('auto_update', true)
|
||||
->whereNotNull('flight_number')
|
||||
$userFlights = UserFlight::where('departure_date', '<=', $now->toDateTimeString())
|
||||
->where(function ($query) {
|
||||
$query->whereNull('departure_processed_at')
|
||||
->orWhereColumn('departure_processed_at', '<', 'departure_date');
|
||||
})
|
||||
->get();
|
||||
|
||||
$this->info("Found {$userFlights->count()} flights.");
|
||||
|
||||
foreach ($userFlights as $flight) {
|
||||
preg_match('/^([A-Z]{2,3})(\d+)$/i', $flight->flight_number, $matches);
|
||||
|
||||
if (empty($matches)) {
|
||||
$this->warn("Could not parse flight number: {$flight->flight_number}");
|
||||
$this->notifyDataError($flight);
|
||||
continue;
|
||||
}
|
||||
|
||||
$airlineCode = strtoupper($matches[1]);
|
||||
$flightNumber = $matches[2];
|
||||
|
||||
$arrivalDate = $flight->arrival_date->setTimezone($flight->arrivalAirport->timezone);
|
||||
|
||||
$data = $this->fetchFlightData($airlineCode, $flightNumber, $arrivalDate);
|
||||
|
||||
if (!$data) {
|
||||
$this->warn("No flight data returned for {$airlineCode}{$flightNumber}");
|
||||
$this->notifyDataError($flight);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($data->departure_iata !== $flight->departureAirport->iata_code ||
|
||||
$data->arrival_iata !== $flight->arrivalAirport->iata_code) {
|
||||
$this->warn("Airport mismatch for {$airlineCode}{$flightNumber} — API: {$data->departure_iata}→{$data->arrival_iata}, expected: {$flight->departureAirport->iata_code}→{$flight->arrivalAirport->iata_code}");
|
||||
$this->notifyDataError($flight);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
|
||||
if ($data->aircraft_registration && $data->aircraft_registration !== $flight->aircraft_registration) {
|
||||
$updates['aircraft_registration'] = $data->aircraft_registration;
|
||||
}
|
||||
|
||||
if ($data->estimated_departure_utc?->ne($flight->departure_date)) {
|
||||
$updates['departure_date'] = $data->estimated_departure_utc;
|
||||
}
|
||||
|
||||
if ($data->estimated_arrival_utc?->ne($flight->arrival_date)) {
|
||||
$updates['arrival_date'] = $data->estimated_arrival_utc;
|
||||
}
|
||||
|
||||
if ($data->equipment_iata) {
|
||||
$currentAircraft = $flight->aircraft;
|
||||
|
||||
if ($currentAircraft?->iata_code !== $data->equipment_iata) {
|
||||
$match = $this->guessAircraftFromIata($data->equipment_iata);
|
||||
|
||||
if ($match) {
|
||||
$updates['aircraft_id'] = $match->id;
|
||||
} else {
|
||||
Log::info("No aircraft match for IATA code {$data->equipment_iata} on flight {$airlineCode}{$flightNumber}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($updates)) {
|
||||
$flight->update($updates);
|
||||
$this->info("Updated flight {$airlineCode}{$flightNumber}: " . implode(', ', array_keys($updates)));
|
||||
|
||||
$changeDescriptions = [];
|
||||
if (isset($updates['aircraft_registration'])) {
|
||||
$changeDescriptions[] = "Registration updated to {$updates['aircraft_registration']}";
|
||||
}
|
||||
if (isset($updates['departure_date'])) {
|
||||
$changeDescriptions[] = "Departure updated to {$updates['departure_date']}";
|
||||
}
|
||||
if (isset($updates['arrival_date'])) {
|
||||
$changeDescriptions[] = "Arrival updated to {$updates['arrival_date']}";
|
||||
}
|
||||
if (isset($updates['aircraft_id'])) {
|
||||
$aircraft = Aircraft::find($updates['aircraft_id']);
|
||||
$changeDescriptions[] = "Aircraft type updated to {$aircraft->display_name_short}";
|
||||
}
|
||||
|
||||
Notification::create([
|
||||
'user_id' => $flight->user_id,
|
||||
'title' => "Flight {$airlineCode}{$flightNumber} updated",
|
||||
'body' => implode("\n", $changeDescriptions),
|
||||
]);
|
||||
} else {
|
||||
$this->info("No changes for {$airlineCode}{$flightNumber}");
|
||||
|
||||
Notification::create([
|
||||
'user_id' => $flight->user_id,
|
||||
'title' => "Flight {$airlineCode}{$flightNumber} updated — no changes",
|
||||
'body' => "Your flight was completed and no updates were made to aircraft, registration, or departure/arrival times.",
|
||||
]);
|
||||
}
|
||||
|
||||
$flight->update(['auto_update' => false]);
|
||||
$flight->update(['departure_processed_at' => $now]);
|
||||
new FollowerNotificationService()->notifyFlightEvent($flight, FlightNotificationType::Departed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,38 @@ readonly class FlightStatData
|
||||
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'])
|
||||
@@ -25,9 +50,13 @@ readonly class FlightStatData
|
||||
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 historic 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';
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Achievement;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class AchievementController extends Controller
|
||||
{
|
||||
public function index(User $user)
|
||||
{
|
||||
$achievements = Achievement::with(['category', 'difficulty'])
|
||||
->get()
|
||||
->groupBy(fn(Achievement $a) => $a->category->name)
|
||||
->map(fn($group) => $group->sortBy('id')->values());
|
||||
|
||||
$userAchievements = $user->achievements()
|
||||
->with('achievement')
|
||||
->orderBy('achievement_id')
|
||||
->get()
|
||||
->keyBy('achievement_id');
|
||||
|
||||
return Inertia::render('UserAchievements', [
|
||||
'user' => $user,
|
||||
'canEdit' => auth()->id() === $user->id,
|
||||
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
|
||||
'achievements' => $achievements,
|
||||
'userAchievements' => $userAchievements,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?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(),
|
||||
'missingAirlineCount' => $this->adminService->getMissingAirlines()->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(),
|
||||
]);
|
||||
}
|
||||
|
||||
function reconcileMissingAirlines(){
|
||||
$missingAirlines = $this->adminService->getMissingAirlines()->values();
|
||||
|
||||
return Inertia::render('Admin/MissingAirlines', [
|
||||
'title' => $missingAirlines->count() . ' Missing Airlines',
|
||||
'missingAirlines' => $missingAirlines,
|
||||
...$this->staticAdminData(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function ignoreMissingLivery(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'filename' => 'required|string',
|
||||
]);
|
||||
|
||||
IgnoredMissingLivery::createOrFirst($validated);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AdminToolsController extends Controller
|
||||
{
|
||||
function missingLiveries(){
|
||||
|
||||
|
||||
/* $existingFiles = collect(glob(public_path('img/liveries/generated/*')))
|
||||
->map(fn ($path) => pathinfo($path, PATHINFO_FILENAME))
|
||||
->toArray();*/
|
||||
|
||||
$existingFiles = collect(glob('C:\\Users\\josh\\WebstormProjects\\Watermark-Remover\\images\\liveries_processed\\*'))
|
||||
->map(fn ($path) => pathinfo($path, PATHINFO_FILENAME))
|
||||
->toArray();
|
||||
|
||||
|
||||
$combos = \App\Models\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,
|
||||
])
|
||||
->filter(fn ($combo) => !in_array($combo['filename'], $existingFiles))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'count' => $combos->count(),
|
||||
'liveries' => $combos,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -47,6 +47,12 @@ class AirlineApiController extends ApiController
|
||||
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;
|
||||
|
||||
@@ -62,7 +68,7 @@ class AirlineApiController extends ApiController
|
||||
}
|
||||
|
||||
function getByCode(string $code){
|
||||
$lookupColumn = strlen($code) === 3 ? 'ICAO_code' : 'IATA_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);
|
||||
|
||||
|
||||
@@ -7,17 +7,22 @@ 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(string $username): JsonResponse
|
||||
public function nextFlight(?User $user): JsonResponse
|
||||
{
|
||||
$user = User::where('name', 'ilike', $username)->first();
|
||||
$user = $user?->exists ? $user : auth()->user();
|
||||
|
||||
if (!$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())
|
||||
@@ -36,25 +41,33 @@ class UserApiController extends ApiController
|
||||
'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 ?? 'undefined',
|
||||
'airlineName' => $flight->airline?->name,
|
||||
'aircraftType' => $flight->aircraft?->manufacturer_code . ' ' . $flight->aircraft?->model_full_name,
|
||||
'logoUrl' => $flight->airline?->logo_url ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
public function flights(string $username): JsonResponse
|
||||
public function viewableFlights(User $user)
|
||||
{
|
||||
$user = User::where('name', 'ilike', $username)->first();
|
||||
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'User not found'], 404);
|
||||
if (Gate::denies('viewProfileData', $user)) {
|
||||
return collect([]);
|
||||
}
|
||||
return $user->flightsWithRelationshipsLoaded();
|
||||
}
|
||||
|
||||
return response()->json($user->FlightController()->flights());
|
||||
public function viewableDepartedFlights(User $user)
|
||||
{
|
||||
if (Gate::denies('viewProfileData', $user)) {
|
||||
return collect([]);
|
||||
}
|
||||
return $user->flightsWithRelationshipsLoaded('departed');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class AuthenticatedSessionController extends Controller
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
return redirect()->intended(route('home', absolute: false));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,23 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
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
|
||||
@@ -28,4 +30,49 @@ class FeedController extends Controller
|
||||
'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();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,13 @@ 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;
|
||||
@@ -24,7 +28,24 @@ class FlightController extends Controller
|
||||
return [
|
||||
'flight_number' => ['nullable', 'string', 'max:10'],
|
||||
'departure_date' => ['required', 'date'],
|
||||
'arrival_date' => ['required', 'date'],
|
||||
'arrival_date' => ['required', 'date', function ($attribute, $value, $fail) {
|
||||
$from = request()->input('from_id');
|
||||
$to = request()->input('to_id');
|
||||
|
||||
if (!$from || !$to) return;
|
||||
|
||||
$departureAirport = Airport::find($from);
|
||||
$arrivalAirport = Airport::find($to);
|
||||
|
||||
if (!$departureAirport || !$arrivalAirport) return;
|
||||
|
||||
$departureUtc = Carbon::createFromFormat('Y-m-d\TH:i', request()->input('departure_date'), $departureAirport->timezone)->utc();
|
||||
$arrivalUtc = Carbon::createFromFormat('Y-m-d\TH:i', $value, $arrivalAirport->timezone)->utc();
|
||||
|
||||
if ($arrivalUtc->lessThanOrEqualTo($departureUtc)) {
|
||||
$fail('The arrival time must be after the departure time, accounting for time zones.');
|
||||
}
|
||||
}],
|
||||
'from_id' => ['required', 'integer', 'exists:airports,id'],
|
||||
'to_id' => ['required', 'integer', 'exists:airports,id'],
|
||||
'airline_id' => ['nullable', 'integer', 'exists:airlines,id'],
|
||||
@@ -43,25 +64,81 @@ class FlightController extends Controller
|
||||
public function lookup(Request $request)
|
||||
{
|
||||
$number = strtoupper(trim($request->query('number', '')));
|
||||
|
||||
// Extract the airline code prefix — letters at the start e.g. "QF" from "QF1"
|
||||
preg_match('/^([A-Z]{2,3})/', $number, $matches);
|
||||
preg_match(
|
||||
'/^([A-Z]{3}|[A-Z]{2}|[A-Z]\d|\d[A-Z])(\d+)$/',
|
||||
$number,
|
||||
$matches
|
||||
);
|
||||
$code = $matches[1] ?? null;
|
||||
$isIata = strlen($code) === 2;
|
||||
$codeColumn = $isIata ? 'IATA_code' : 'ICAO_code';
|
||||
$flightNumber = $matches[2] ?? null;
|
||||
$isIata = $code !== null && strlen($code) === 2;
|
||||
$codeColumn = $isIata ? 'iata_code' : 'icao_code';
|
||||
|
||||
$airlines = $code
|
||||
? Airline::where($codeColumn, $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 ($airline) => ['value' => $airline->id, 'title' => $airline->display_name, 'logo_url' => $airline->logo_url])
|
||||
->map(fn($a) => ['value' => $a->id, 'title' => $a->display_name, 'country_code' => strtolower($a->region->country->code)])
|
||||
->values()
|
||||
->toArray()
|
||||
: collect()->toArray();
|
||||
->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' => [],
|
||||
'to_options' => [],
|
||||
'aircraft_options' => [],
|
||||
'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'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -161,8 +238,6 @@ class FlightController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate($this->rules());
|
||||
@@ -177,12 +252,11 @@ class FlightController extends Controller
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
return redirect()->route('profile.departure-board', [Auth::user()->name, $newFlight->id]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public function update(Request $request, UserFlight $flight)
|
||||
{
|
||||
$this->authorize('update', $flight);
|
||||
@@ -203,31 +277,27 @@ class FlightController extends Controller
|
||||
$updated = $flight->snapshot($flight->id);
|
||||
$this->recordChanges($flight, $dirty, $original, $updated);
|
||||
|
||||
return redirect()->route('profile.departure-board', [Auth::user()->name, $flight->id]);
|
||||
return redirect()->route('profile.departure-board', [$flight->user->name, $flight->id]);
|
||||
}
|
||||
|
||||
public function delete(UserFlight $flight)
|
||||
public function delete(UserFlight $flight, ?string $referrer = 'departure-board')
|
||||
{
|
||||
$this->authorize('delete', $flight);
|
||||
|
||||
$snapshot = $flight->snapshot($flight->id);
|
||||
|
||||
if(now()->utc()->isBefore($flight->departure_date)){
|
||||
$action = 'flight_deleted';
|
||||
} else {
|
||||
$action = 'flight_cancelled';
|
||||
}
|
||||
$isFuture = now()->utc()->isBefore($flight->departure_date);
|
||||
|
||||
UserAction::create([
|
||||
'user_id' => $flight->user_id,
|
||||
'type' => $action,
|
||||
'type' => $isFuture ? 'flight_cancelled' : 'flight_deleted',
|
||||
'data' => [
|
||||
'flight' => $snapshot,
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
$flight->delete();
|
||||
return redirect()->route('profile.departure-board', [Auth::user()->name]);
|
||||
|
||||
return redirect()->route('profile.'.$referrer, [Auth::user()->name]);
|
||||
}
|
||||
|
||||
public function staticData() : array {
|
||||
|
||||
@@ -8,7 +8,9 @@ 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;
|
||||
@@ -35,37 +37,21 @@ class FlightImportController extends Controller
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getPossibleAircraft(string $aircraftQuery) {
|
||||
public function getPossibleAircraft(string $aircraftQuery): array
|
||||
{
|
||||
preg_match('/\((\w+)\)/', $aircraftQuery, $matches);
|
||||
$designator = $matches[1] ?? null;
|
||||
|
||||
$sortOverrides = [
|
||||
'B788' => "CASE WHEN model_full_name ILIKE '%BBJ%' THEN 1 ELSE 0 END",
|
||||
'B789' => "CASE WHEN model_full_name ILIKE '%BBJ%' THEN 1 ELSE 0 END",
|
||||
];
|
||||
if (!$designator) return [];
|
||||
|
||||
if(!$designator){
|
||||
$aircraft = [];
|
||||
} else {
|
||||
|
||||
$aircraft = Aircraft::when($designator, fn($query) => $query->where('designator', 'ilike', $designator))
|
||||
->when(
|
||||
isset($sortOverrides[$designator]),
|
||||
fn($q) => $q->orderByRaw($sortOverrides[$designator])
|
||||
)
|
||||
return Aircraft::where('designator', 'ilike', $designator)
|
||||
->orderByDesc('preferred')
|
||||
->orderBy('model_full_name')
|
||||
->limit(10)
|
||||
->get(['id', 'manufacturer_code', 'model_full_name', 'designator'])
|
||||
->map(fn($aircraft) => [
|
||||
'value' => $aircraft->id,
|
||||
'title' => $aircraft->display_name,
|
||||
])
|
||||
->get(['id', 'manufacturer_code', 'model_full_name', 'designator', 'preferred'])
|
||||
->map(fn($a) => ['value' => $a->id, 'title' => $a->display_name])
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
}
|
||||
|
||||
return $aircraft;
|
||||
}
|
||||
|
||||
public function getPossibleAirports(string $airportQuery) {
|
||||
@@ -116,20 +102,20 @@ class FlightImportController extends Controller
|
||||
$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
|
||||
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);
|
||||
$q->where('iata_code', $iata)
|
||||
->orWhere('icao_code', $icao);
|
||||
});
|
||||
})
|
||||
->orderByDesc('active')
|
||||
->limit(10)
|
||||
->get(['id', 'name', 'IATA_code', 'ICAO_code', 'internal_name'])
|
||||
->get(['id', 'name', 'iata_code', 'icao_code', 'internal_name'])
|
||||
->map(fn($airline) => [
|
||||
'value' => $airline->id,
|
||||
'title' => $airline->display_name,
|
||||
@@ -142,6 +128,18 @@ class FlightImportController extends Controller
|
||||
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();
|
||||
@@ -149,16 +147,14 @@ class FlightImportController extends Controller
|
||||
$flightToReconcile = ImportedFlight::where('user_id', $user->id)->orderBy('date', 'asc')->first();
|
||||
|
||||
if (!$flightToReconcile) {
|
||||
return null;
|
||||
return to_route('import.fr24');
|
||||
}
|
||||
|
||||
$date = null;
|
||||
if ($flightToReconcile->date) {
|
||||
$date = Carbon::createFromFormat('Y-m-d', $flightToReconcile->date)->format('Y-m-d');
|
||||
}
|
||||
$date = $flightToReconcile->date
|
||||
? Carbon::createFromFormat('Y-m-d', $flightToReconcile->date)->format('Y-m-d')
|
||||
: null;
|
||||
|
||||
|
||||
return [
|
||||
$flight = [
|
||||
'imported_flight_id' => $flightToReconcile->id,
|
||||
'flight_classes' => $this->selectOptions(FlightClass::class),
|
||||
'flight_reasons' => $this->selectOptions(FlightReason::class),
|
||||
@@ -170,15 +166,20 @@ class FlightImportController extends Controller
|
||||
'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 ?? '',
|
||||
'flight_class' => $flightToReconcile->flight_class ?? '',
|
||||
'seat_type' => $flightToReconcile->seat_type ?? '',
|
||||
'flight_reason' => $flightToReconcile->flight_reason ?? '',
|
||||
'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)
|
||||
@@ -270,11 +271,11 @@ class FlightImportController extends Controller
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
ImportedFlight::destroy($validated['imported_flight_id']);
|
||||
return to_route('reconcile');
|
||||
}
|
||||
|
||||
|
||||
private function validateCsvFormat(string $path): ?string
|
||||
{
|
||||
$handle = fopen($path, 'r');
|
||||
@@ -347,7 +348,7 @@ class FlightImportController extends Controller
|
||||
|
||||
$handle = fopen($path, 'r');
|
||||
|
||||
fgetcsv($handle);
|
||||
fgetcsv($handle); // skip blank first line
|
||||
|
||||
// Read and normalise header row
|
||||
$headers = array_map(
|
||||
@@ -356,9 +357,6 @@ class FlightImportController extends Controller
|
||||
);
|
||||
|
||||
\Log::debug('CSV headers', $headers);
|
||||
$firstRow = fgetcsv($handle);
|
||||
\Log::debug('First row', $firstRow ?? ['empty']);
|
||||
\Log::debug('Header count: ' . count($headers) . ', Row count: ' . count($firstRow ?? []));
|
||||
|
||||
$map = [
|
||||
'date' => 'date',
|
||||
@@ -382,6 +380,11 @@ class FlightImportController extends Controller
|
||||
$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
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserFlight;
|
||||
use App\Http\Resources\UserFlightResource;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class FlightProfileController extends Controller
|
||||
{
|
||||
public function profileData(User $user, string $view, ?int $selectedFlightId = null) : array {
|
||||
return [
|
||||
'user' => $user,
|
||||
'canEdit' => auth()->check() && auth()->id() === $user->id,
|
||||
'initialView' => $view,
|
||||
'selectedFlightId' => $selectedFlightId,
|
||||
'flight_api_url' => '/data/user/'.$user->name.'/flights',
|
||||
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
|
||||
];
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
return $this->departureBoard($user);
|
||||
}
|
||||
|
||||
public function flight(User $user, UserFlight $userFlight)
|
||||
{
|
||||
if($userFlight->user_id !== $user->id){
|
||||
abort(404);
|
||||
}
|
||||
|
||||
|
||||
return Inertia::render('UserFlight', [
|
||||
'flightCount' => $user->flights()->count(),
|
||||
'flight' => $userFlight->snapshot($userFlight->id),
|
||||
'user' => $user,
|
||||
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,13 @@ class SearchController extends Controller
|
||||
->where(function ($query) use ($q) {
|
||||
$len = strlen($q);
|
||||
if ($len === 2) {
|
||||
$query->where('IATA_code', 'ilike', $q);
|
||||
$query->where('iata_code', 'ilike', $q);
|
||||
} elseif ($len === 3) {
|
||||
$query->where('ICAO_code', 'ilike', $q);
|
||||
$query->where('icao_code', 'ilike', $q);
|
||||
} else {
|
||||
$query->where('name', 'ilike', "%{$q}%")
|
||||
->orWhere('IATA_code', 'ilike', "%{$q}%")
|
||||
->orWhere('ICAO_code', 'ilike', "%{$q}%");
|
||||
->orWhere('iata_code', 'ilike', "%{$q}%")
|
||||
->orWhere('icao_code', 'ilike', "%{$q}%");
|
||||
}
|
||||
})
|
||||
->limit(50)
|
||||
@@ -44,7 +44,7 @@ class SearchController extends Controller
|
||||
->orWhereRaw("CONCAT(manufacturer_code, ' ', model_full_name) ilike ?", ["%{$q}%"])
|
||||
->orWhereRaw("CONCAT(manufacturer_code, ' ', model_full_name) ilike ?", ["%{$replacedQuery}%"])
|
||||
->limit(200)
|
||||
->orderBy('id', 'asc')
|
||||
->orderBy('preferred', 'desc')
|
||||
->get(['id', 'manufacturer_code', 'model_full_name', 'designator'])
|
||||
->map(fn($aircraft) => [
|
||||
'value' => $aircraft->id,
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -3,29 +3,31 @@
|
||||
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 follow(User $user): JsonResponse
|
||||
{
|
||||
$existing = Followee::where('user_id', auth()->id())
|
||||
->where('followee_id', $user->id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
return response()->json(['following' => false]);
|
||||
}
|
||||
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());
|
||||
|
||||
Followee::create([
|
||||
'user_id' => auth()->id(),
|
||||
'followee_id' => $user->id,
|
||||
return Inertia::render('UserSettings', [
|
||||
'fields' => $fields,
|
||||
'categories' => SettingsRegistry::categories(),
|
||||
'defaultTab' => in_array($category, $allowedTabs, true) ? $category : 'general',
|
||||
]);
|
||||
|
||||
return response()->json(['following' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserFlight;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserFlightController extends Controller
|
||||
{
|
||||
|
||||
protected User $user;
|
||||
|
||||
function __construct(User $user){
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
public function flights(){
|
||||
return UserFlight::where('user_id', $this->user->id)
|
||||
->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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,13 @@ class HandleInertiaRequests extends Middleware
|
||||
'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()
|
||||
|
||||
@@ -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,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']);
|
||||
}
|
||||
}
|
||||
@@ -39,13 +39,22 @@ class Achievement extends Model
|
||||
'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
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
+42
-3
@@ -15,10 +15,12 @@ class Aircraft extends Model
|
||||
'engine_type',
|
||||
'engine_count',
|
||||
'wtc',
|
||||
'preferred'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'engine_count' => 'integer',
|
||||
'preferred' => 'boolean'
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
@@ -26,11 +28,48 @@ class Aircraft extends Model
|
||||
'display_name_short'
|
||||
];
|
||||
|
||||
const array IATA_ALIAS_MAP = [
|
||||
'7S8' => '73H',
|
||||
'7S9' => '73J'
|
||||
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 () {
|
||||
|
||||
+30
-4
@@ -2,24 +2,29 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Observers\AirlineObserver;
|
||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[ObservedBy(AirlineObserver::class)]
|
||||
class Airline extends Model
|
||||
{
|
||||
protected $table = 'airlines';
|
||||
|
||||
protected $fillable = [
|
||||
'IATA_code',
|
||||
'ICAO_code',
|
||||
'iata_code',
|
||||
'icao_code',
|
||||
'name',
|
||||
'internal_name',
|
||||
'country_id',
|
||||
'alliance_id',
|
||||
'active',
|
||||
'logo',
|
||||
'merged_into_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -35,7 +40,7 @@ class Airline extends Model
|
||||
protected function displayName() : Attribute{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
$codes = array_filter([$this->IATA_code, $this->ICAO_code]);
|
||||
$codes = array_filter([$this->iata_code, $this->icao_code]);
|
||||
$codeString = count($codes) ? ' (' . implode('/', $codes) . ')' : '';
|
||||
return "{$this->name}{$codeString}";
|
||||
}
|
||||
@@ -45,7 +50,13 @@ class Airline extends Model
|
||||
protected function logoUrl() : Attribute{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
return config('app.logo_api_url') . "/airline/$this->internal_name/logo/tail";
|
||||
$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";
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -59,4 +70,19 @@ class Airline extends Model
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Alliance extends Model
|
||||
{
|
||||
|
||||
protected $table = 'alliances';
|
||||
|
||||
protected $fillable = [
|
||||
@@ -18,8 +19,25 @@ class Alliance extends Model
|
||||
'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]);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,13 @@ class Country extends Model
|
||||
{
|
||||
return $this->hasMany(Region::class);
|
||||
}
|
||||
|
||||
function sortedRegions(): array
|
||||
{
|
||||
return $this
|
||||
->regions()
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ class Followee extends Model
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'followee_id',
|
||||
'verified',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'verified' => 'boolean',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
@@ -21,4 +26,14 @@ class Followee extends Model
|
||||
{
|
||||
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,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'];
|
||||
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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;
|
||||
@@ -26,6 +28,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* @method static Builder active()
|
||||
* @method static Builder forAchievements()
|
||||
*/
|
||||
#[ObservedBy(NotificationObserver::class)]
|
||||
class Notification extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
|
||||
+174
-24
@@ -2,38 +2,60 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Http\Controllers\UserFlightController;
|
||||
use App\Observers\UserObserver;
|
||||
use App\Settings\SettingsRegistry;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
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\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use App\Traits\HasAchievements;
|
||||
use App\Models\Notification;
|
||||
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'])]
|
||||
class User extends Authenticatable
|
||||
#[ObservedBy(UserObserver::class)]
|
||||
class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, HasAchievements, HasApiTokens;
|
||||
use HasFactory, HasAchievements, HasApiTokens, HasRoles, HasPushSubscriptions, Notifiable;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'settings' => 'array',
|
||||
];
|
||||
|
||||
protected $appends = ['resolved_settings'];
|
||||
|
||||
public function calculateAchievementsAndClearCache(): void
|
||||
{
|
||||
$this->clearCache();
|
||||
$this->calculateAchievements();
|
||||
}
|
||||
|
||||
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
|
||||
@@ -41,20 +63,125 @@ class User extends Authenticatable
|
||||
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 FlightController(): UserFlightController
|
||||
{
|
||||
return new UserFlightController($this);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
$dateFormat = auth()->user()?->resolved_settings['date_format'] ?? 'j M Y';
|
||||
$timeFormat = auth()->user()?->resolved_settings['time_format'] === '24hr' ? 'Hi' : 'g:iA';
|
||||
|
||||
$collection = collect(json_decode($json))
|
||||
->map(function ($flight) use ($dateFormat, $timeFormat) {
|
||||
$departure = Carbon::parse($flight->departure_date)
|
||||
->setTimezone($flight->departure_airport->timezone);
|
||||
|
||||
$arrival = Carbon::parse($flight->arrival_date)
|
||||
->setTimezone($flight->arrival_airport->timezone);
|
||||
|
||||
$flight->departure_date_display = $departure->format($dateFormat);
|
||||
$flight->departure_time_display = $departure->format($timeFormat);
|
||||
|
||||
$flight->arrival_date_display = $arrival->format($dateFormat);
|
||||
$flight->arrival_time_display = $arrival->format($timeFormat);
|
||||
|
||||
return $flight;
|
||||
});
|
||||
|
||||
$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);
|
||||
@@ -62,17 +189,40 @@ class User extends Authenticatable
|
||||
|
||||
public function following(): HasMany
|
||||
{
|
||||
return $this->hasMany(Followee::class, 'user_id');
|
||||
return $this->hasMany(Followee::class, 'user_id')->verified();
|
||||
}
|
||||
|
||||
public function followers(): HasMany
|
||||
public function pendingFollowers(): BelongsToMany
|
||||
{
|
||||
return $this->hasMany(Followee::class, 'followee_id');
|
||||
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();
|
||||
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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
@@ -26,6 +27,21 @@ class UserAchievement extends Model
|
||||
'progress' => 'integer',
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
'unlocked',
|
||||
];
|
||||
|
||||
protected function unlocked(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (!$this->achievement) return false;
|
||||
if (!$this->achievement->progressive || !$this->achievement->threshold) return true;
|
||||
return ($this->progress ?? 0) >= $this->achievement->threshold;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Relationships
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -33,6 +33,8 @@ class UserAction extends Model
|
||||
'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'
|
||||
}
|
||||
);
|
||||
|
||||
+112
-9
@@ -2,11 +2,17 @@
|
||||
|
||||
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';
|
||||
@@ -28,12 +34,16 @@ class UserFlight extends Model
|
||||
'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 = [
|
||||
@@ -45,9 +55,23 @@ class UserFlight extends Model
|
||||
'duration',
|
||||
'duration_display',
|
||||
'distance',
|
||||
'livery_url',
|
||||
'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];
|
||||
@@ -76,6 +100,13 @@ class UserFlight extends Model
|
||||
);
|
||||
}
|
||||
|
||||
protected function classSeatCombined(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn() => $this->flightClass?->name
|
||||
);
|
||||
}
|
||||
|
||||
protected function departureTimeDisplay(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
@@ -106,6 +137,26 @@ class UserFlight extends Model
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -157,6 +208,25 @@ class UserFlight extends Model
|
||||
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([
|
||||
@@ -221,20 +291,53 @@ class UserFlight extends Model
|
||||
return $this->belongsTo(FlightReason::class);
|
||||
}
|
||||
|
||||
public function liveryUrl(): Attribute{
|
||||
public function livery(): Attribute{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if($this->airline && $this->aircraft) {
|
||||
$fileName = "{$this->airline->internal_name}_{$this->aircraft->designator}.png";
|
||||
$file = public_path("img/liveries/generated/$fileName");
|
||||
$user = auth()?->user();
|
||||
|
||||
if (file_exists($file)) {
|
||||
return "/img/liveries/generated/$fileName";
|
||||
}
|
||||
}
|
||||
$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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,22 @@
|
||||
|
||||
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->calculateAchievements();
|
||||
$flight->user->calculateAchievementsAndClearCache();
|
||||
new FollowerNotificationService()->notifyFlightEvent($flight, FlightNotificationType::forCreation($flight));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,7 +27,8 @@ class FlightObserver
|
||||
*/
|
||||
public function updated(UserFlight $flight): void
|
||||
{
|
||||
$flight->user->calculateAchievements();
|
||||
\Log::info('Observer fired for flight ' . $flight->id);
|
||||
$flight->user->calculateAchievementsAndClearCache();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,6 +37,9 @@ class FlightObserver
|
||||
*/
|
||||
public function deleted(UserFlight $flight): void
|
||||
{
|
||||
$flight->user->calculateAchievements();
|
||||
$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,
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ class UserFlightPolicy
|
||||
*/
|
||||
public function update(User $user, UserFlight $userFlight): bool
|
||||
{
|
||||
return $user->id === $userFlight->user_id;
|
||||
return $user->id === $userFlight->user_id || $user->hasRole('admin');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +45,7 @@ class UserFlightPolicy
|
||||
*/
|
||||
public function delete(User $user, UserFlight $userFlight): bool
|
||||
{
|
||||
return $user->id === $userFlight->user_id;
|
||||
return ($user->id === $userFlight->user_id) || $user->hasRole('admin');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,17 @@
|
||||
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\Facades\RateLimiter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -24,8 +30,15 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
if(!app()->isProduction()){
|
||||
Mail::alwaysTo(config('app.test_email'));
|
||||
}
|
||||
Vite::prefetch(concurrency: 3);
|
||||
UserFlight::observe(FlightObserver::class);
|
||||
Airline::observe(AirlineObserver::class);
|
||||
RateLimiter::for('api', function (Request $request) {
|
||||
return $request->user('sanctum')
|
||||
? Limit::perMinute(60)->by($request->user('sanctum')->id)
|
||||
: Limit::perMinute(10)->by($request->ip());
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services\Achievements;
|
||||
|
||||
use App\Events\NotificationCreated;
|
||||
use App\Models\Achievement;
|
||||
use App\Models\Notification;
|
||||
use App\Models\User;
|
||||
@@ -59,7 +60,7 @@ class AchievementService
|
||||
'arrivalAirport.region',
|
||||
'departureAirport.region.continent',
|
||||
'arrivalAirport.region.continent',
|
||||
])->get();
|
||||
])->where('departure_date', '<=', now('UTC'))->get();
|
||||
|
||||
foreach ($this->checkers as $checkerClass) {
|
||||
$checker = new $checkerClass($this);
|
||||
|
||||
@@ -2,37 +2,13 @@
|
||||
|
||||
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 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'],
|
||||
];
|
||||
|
||||
private 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'],
|
||||
];
|
||||
|
||||
private const array DOUBLE_DECKER_DESIGNATORS = [
|
||||
// A380
|
||||
'A380', 'A388',
|
||||
@@ -121,7 +97,7 @@ class AircraftChecker extends BaseChecker
|
||||
|
||||
// --- Boeing 7x7 families ---
|
||||
|
||||
$flownBoeingFamilies = collect(self::BOEING_FAMILIES)
|
||||
$flownBoeingFamilies = collect(Aircraft::BOEING_FAMILIES)
|
||||
->filter(fn($designators) =>
|
||||
$flightsWithAircraft->contains(
|
||||
fn(UserFlight $f) => in_array($f->aircraft->designator, $designators)
|
||||
@@ -133,7 +109,7 @@ class AircraftChecker extends BaseChecker
|
||||
|
||||
// --- Airbus A3xx families ---
|
||||
|
||||
$flownAirbusFamilie = collect(self::AIRBUS_FAMILIES)
|
||||
$flownAirbusFamilies = collect(Aircraft::AIRBUS_FAMILIES)
|
||||
->filter(fn($designators) =>
|
||||
$flightsWithAircraft->contains(
|
||||
fn(UserFlight $f) => in_array($f->aircraft->designator, $designators)
|
||||
@@ -141,6 +117,16 @@ class AircraftChecker extends BaseChecker
|
||||
)
|
||||
->count();
|
||||
|
||||
$this->awardProgress($flownAirbusFamilie, 'aircraft.all_airbus_a3xx');
|
||||
$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');
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services\Achievements\Checkers;
|
||||
|
||||
use App\Models\Airline;
|
||||
use App\Models\Alliance;
|
||||
use App\Models\User;
|
||||
use App\Models\UserFlight;
|
||||
@@ -20,9 +21,10 @@ class AirlinesAndAlliancesChecker extends BaseChecker
|
||||
$alliances = Alliance::withCount('airlines')->pluck('id', 'internal_name');
|
||||
|
||||
$flownAllianceAirlines = $flights
|
||||
->filter(fn(UserFlight $f) => $f->airline?->alliance !== null)
|
||||
->groupBy(fn(UserFlight $f) => $f->airline->alliance->internal_name)
|
||||
->map(fn($group) => $group->pluck('airline.internal_name')->unique()->count());
|
||||
->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);
|
||||
|
||||
|
||||
@@ -76,6 +76,13 @@ class CountriesAndContinentsChecker extends BaseChecker
|
||||
$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}");
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ class FunChallengesChecker extends BaseChecker
|
||||
$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(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();
|
||||
|
||||
@@ -70,6 +70,12 @@ class GeneralFlyingChecker extends BaseChecker
|
||||
|
||||
// --- 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');
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\DTOs\MissingLivery;
|
||||
use App\Models\IgnoredMissingLivery;
|
||||
use App\Models\UserFlight;
|
||||
use Illuminate\Support\Enumerable;
|
||||
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();
|
||||
}
|
||||
|
||||
function getMissingAirlines(): Enumerable|Collection
|
||||
{
|
||||
$flights = UserFlight::whereNotNull('flight_number')
|
||||
->whereNull('airline_id')
|
||||
->with('user:id,name') // adjust to your actual user relation/columns
|
||||
->get(['id', 'flight_number', 'user_id']);
|
||||
|
||||
return $flights
|
||||
->map(function ($flight) {
|
||||
// Extract the 2-character IATA-style prefix (letters and/or digits)
|
||||
preg_match('/^([A-Za-z0-9]{2})\d+/', trim($flight->flight_number), $matches);
|
||||
|
||||
return [
|
||||
'code' => $matches[1] ?? null,
|
||||
'flight_number' => $flight->flight_number,
|
||||
'link' => "/u/{$flight->user->name}/flight/{$flight->id}",
|
||||
];
|
||||
})
|
||||
->filter(fn ($f) => $f['code'] !== null) // drop any that didn't match the pattern
|
||||
->groupBy('code')
|
||||
->map(function ($group, $code) {
|
||||
return [
|
||||
'code' => $code,
|
||||
'flights' => $group->map(fn ($f) => [
|
||||
'flight_number' => $f['flight_number'],
|
||||
'link' => $f['link'],
|
||||
])->values()->all(),
|
||||
];
|
||||
})
|
||||
->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,234 @@
|
||||
<?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!)',
|
||||
'Localization' => 'Units of measurement, date and time preferences.',
|
||||
];
|
||||
}
|
||||
|
||||
public static function schema(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'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' => 'distance_unit',
|
||||
'type' => 'select',
|
||||
'label' => 'Distance Units',
|
||||
'category' => 'Localization',
|
||||
'default' => 'km',
|
||||
'options' => [
|
||||
['value' => 'km', 'label' => 'Kilometres (km)'],
|
||||
['value' => 'mi', 'label' => 'Miles (mi)'],
|
||||
['value' => 'nm', 'label' => 'Nautical miles (nm)'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'time_format',
|
||||
'type' => 'select',
|
||||
'label' => 'Time Format',
|
||||
'category' => 'Localization',
|
||||
'default' => 'ampm',
|
||||
'options' => [
|
||||
['value' => 'ampm', 'label' => 'AM/PM'],
|
||||
['value' => '24hr', 'label' => '24-Hour'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'date_format',
|
||||
'type' => 'select',
|
||||
'label' => 'Date Format',
|
||||
'category' => 'Localization',
|
||||
'default' => 'j M Y',
|
||||
'options' => [
|
||||
['value' => 'j M Y', 'label' => '20 Feb 2015'],
|
||||
['value' => 'Y-m-d', 'label' => '2015-02-20'],
|
||||
['value' => 'd/m/Y', 'label' => '20/02/2015'],
|
||||
['value' => 'm/d/Y', 'label' => '02/20/2015'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'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;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ trait HasAchievements
|
||||
{
|
||||
public function calculateAchievements(): void
|
||||
{
|
||||
/** @var User $this */
|
||||
app(AchievementService::class)->calculate($this);
|
||||
}
|
||||
}
|
||||
|
||||
+104
-5
@@ -1,24 +1,123 @@
|
||||
<?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\Configuration\Exceptions;
|
||||
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__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
channels: __DIR__.'/../routes/channels.php',
|
||||
health: '/up',
|
||||
apiPrefix: '',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->web(append: [
|
||||
\App\Http\Middleware\HandleInertiaRequests::class,
|
||||
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
|
||||
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 {
|
||||
//
|
||||
})->create();
|
||||
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
|
||||
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();
|
||||
|
||||
@@ -12,9 +12,14 @@
|
||||
"php": "^8.4",
|
||||
"ext-pdo": "*",
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
"laravel-notification-channels/webpush": "^11.0",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/reverb": "^1.0",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^3.0",
|
||||
"spatie/laravel-permission": "^8.0",
|
||||
"symfony/http-client": "^8.1",
|
||||
"symfony/mailgun-mailer": "^8.1",
|
||||
"tightenco/ziggy": "^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
|
||||
Generated
+1784
-1
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -41,7 +41,8 @@ return [
|
||||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
'verify_ssl' => env('VERIFY_SSL', true),
|
||||
|
||||
'test_email' => env('TEST_EMAIL', ''),
|
||||
'contact_email' => env('CONTACT_EMAIL', ''),
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
@@ -58,6 +59,7 @@ return [
|
||||
'api_domain' => env('API_DOMAIN', 'api.flightsgoneby.com'),
|
||||
'logo_api_url' => env('LOGO_API_URL', 'https://api.flightsgoneby.com'),
|
||||
'timezone_api_key' => env('TIMEZONE_API_KEY', '1234567890'),
|
||||
'trusted_frontend_origins' => array_filter(explode(',', env('TRUSTED_FRONTEND_ORIGINS', ''))),
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Broadcaster
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default broadcaster that will be used by the
|
||||
| framework when an event needs to be broadcast. You may set this to
|
||||
| any of the connections defined in the "connections" array below.
|
||||
|
|
||||
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('BROADCAST_CONNECTION', 'null'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Broadcast Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the broadcast connections that will be used
|
||||
| to broadcast events to other systems or over WebSockets. Samples of
|
||||
| each available type of connection are provided inside this array.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'reverb' => [
|
||||
'driver' => 'reverb',
|
||||
'key' => env('REVERB_APP_KEY'),
|
||||
'secret' => env('REVERB_APP_SECRET'),
|
||||
'app_id' => env('REVERB_APP_ID'),
|
||||
'options' => [
|
||||
'host' => env('REVERB_HOST'),
|
||||
'port' => env('REVERB_PORT', 443),
|
||||
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'client_options' => [
|
||||
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||
],
|
||||
],
|
||||
|
||||
'pusher' => [
|
||||
'driver' => 'pusher',
|
||||
'key' => env('PUSHER_APP_KEY'),
|
||||
'secret' => env('PUSHER_APP_SECRET'),
|
||||
'app_id' => env('PUSHER_APP_ID'),
|
||||
'options' => [
|
||||
'cluster' => env('PUSHER_APP_CLUSTER'),
|
||||
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
|
||||
'port' => env('PUSHER_PORT', 443),
|
||||
'scheme' => env('PUSHER_SCHEME', 'https'),
|
||||
'encrypted' => true,
|
||||
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'client_options' => [
|
||||
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||
],
|
||||
],
|
||||
|
||||
'ably' => [
|
||||
'driver' => 'ably',
|
||||
'key' => env('ABLY_KEY'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'driver' => 'log',
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'null',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cross-Origin Resource Sharing (CORS) Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure your settings for cross-origin resource sharing
|
||||
| or "CORS". This determines what cross-origin operations may execute
|
||||
| in web browsers. You are free to adjust these settings as needed.
|
||||
|
|
||||
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||
|
|
||||
*/
|
||||
|
||||
'paths' => ['*'],
|
||||
|
||||
'allowed_methods' => ['*'],
|
||||
|
||||
'allowed_origins' => [
|
||||
'https://flightsgoneby.com',
|
||||
'https://www.flightsgoneby.com',
|
||||
'http://flightsgoneby.test:8000',
|
||||
'http://localhost:8000',
|
||||
],
|
||||
|
||||
'allowed_origins_patterns' => [],
|
||||
|
||||
'allowed_headers' => ['*'],
|
||||
|
||||
'exposed_headers' => [],
|
||||
|
||||
'max_age' => 0,
|
||||
|
||||
'supports_credentials' => true,
|
||||
|
||||
];
|
||||
@@ -32,7 +32,7 @@ return [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'root' => env('LOCAL_DISK_ROOT', storage_path('app/private')),
|
||||
'serve' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
|
||||
+3
-1
@@ -96,7 +96,9 @@ return [
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
'mailgun' => [
|
||||
'transport' => 'mailgun',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
use Spatie\Permission\DefaultTeamResolver;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
return [
|
||||
|
||||
'models' => [
|
||||
|
||||
/*
|
||||
* When using the "HasPermissions" trait from this package, we need to know which
|
||||
* Eloquent model should be used to retrieve your permissions. Of course, it
|
||||
* is often just the "Permission" model but you may use whatever you like.
|
||||
*
|
||||
* The model you want to use as a Permission model needs to implement the
|
||||
* `Spatie\Permission\Contracts\Permission` contract.
|
||||
*/
|
||||
|
||||
'permission' => Permission::class,
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* Eloquent model should be used to retrieve your roles. Of course, it
|
||||
* is often just the "Role" model but you may use whatever you like.
|
||||
*
|
||||
* The model you want to use as a Role model needs to implement the
|
||||
* `Spatie\Permission\Contracts\Role` contract.
|
||||
*/
|
||||
|
||||
'role' => Role::class,
|
||||
|
||||
/*
|
||||
* When using the "Teams" feature from this package, we need to know which
|
||||
* Eloquent model should be used to retrieve your teams. Of course, it
|
||||
* is often just the "Team" model but you may use whatever you like.
|
||||
*/
|
||||
'team' => null,
|
||||
|
||||
/*
|
||||
* When using the "HasModels" trait and passing raw IDs to syncModels,
|
||||
* attachModels, or detachModels, this model class will be used to
|
||||
* resolve those IDs. If null, defaults to the guard's model.
|
||||
*/
|
||||
'default_model' => null,
|
||||
],
|
||||
|
||||
'table_names' => [
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* table should be used to retrieve your roles. We have chosen a basic
|
||||
* default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'roles' => 'roles',
|
||||
|
||||
/*
|
||||
* When using the "HasPermissions" trait from this package, we need to know which
|
||||
* table should be used to retrieve your permissions. We have chosen a basic
|
||||
* default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'permissions' => 'permissions',
|
||||
|
||||
/*
|
||||
* When using the "HasPermissions" trait from this package, we need to know which
|
||||
* table should be used to retrieve your models permissions. We have chosen a
|
||||
* basic default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'model_has_permissions' => 'model_has_permissions',
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* table should be used to retrieve your models roles. We have chosen a
|
||||
* basic default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'model_has_roles' => 'model_has_roles',
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* table should be used to retrieve your roles permissions. We have chosen a
|
||||
* basic default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'role_has_permissions' => 'role_has_permissions',
|
||||
],
|
||||
|
||||
'column_names' => [
|
||||
/*
|
||||
* Change this if you want to name the related pivots other than defaults
|
||||
*/
|
||||
'role_pivot_key' => null, // default 'role_id',
|
||||
'permission_pivot_key' => null, // default 'permission_id',
|
||||
|
||||
/*
|
||||
* Change this if you want to name the related model primary key other than
|
||||
* `model_id`.
|
||||
*
|
||||
* For example, this would be nice if your primary keys are all UUIDs. In
|
||||
* that case, name this `model_uuid`.
|
||||
*/
|
||||
|
||||
'model_morph_key' => 'model_id',
|
||||
|
||||
/*
|
||||
* Change this if you want to use the teams feature and your related model's
|
||||
* foreign key is other than `team_id`.
|
||||
*/
|
||||
|
||||
'team_foreign_key' => 'team_id',
|
||||
],
|
||||
|
||||
/*
|
||||
* When set to true, the method for checking permissions will be registered on the gate.
|
||||
* Set this to false if you want to implement custom logic for checking permissions.
|
||||
*/
|
||||
|
||||
'register_permission_check_method' => true,
|
||||
|
||||
/*
|
||||
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
|
||||
* this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated
|
||||
* NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it.
|
||||
*/
|
||||
'register_octane_reset_listener' => false,
|
||||
|
||||
/*
|
||||
* Events will fire when a role or permission is assigned/unassigned:
|
||||
* \Spatie\Permission\Events\RoleAttachedEvent
|
||||
* \Spatie\Permission\Events\RoleDetachedEvent
|
||||
* \Spatie\Permission\Events\PermissionAttachedEvent
|
||||
* \Spatie\Permission\Events\PermissionDetachedEvent
|
||||
*
|
||||
* To enable, set to true, and then create listeners to watch these events.
|
||||
*/
|
||||
'events_enabled' => false,
|
||||
|
||||
/*
|
||||
* Teams Feature.
|
||||
* When set to true the package implements teams using the 'team_foreign_key'.
|
||||
* If you want the migrations to register the 'team_foreign_key', you must
|
||||
* set this to true before doing the migration.
|
||||
* If you already did the migration then you must make a new migration to also
|
||||
* add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions'
|
||||
* (view the latest version of this package's migration file)
|
||||
*/
|
||||
|
||||
'teams' => false,
|
||||
|
||||
/*
|
||||
* The class to use to resolve the permissions team id
|
||||
*/
|
||||
'team_resolver' => DefaultTeamResolver::class,
|
||||
|
||||
/*
|
||||
* Passport Client Credentials Grant
|
||||
* When set to true the package will use Passports Client to check permissions
|
||||
*/
|
||||
|
||||
'use_passport_client_credentials' => false,
|
||||
|
||||
/*
|
||||
* When set to true, the required permission names are added to exception messages.
|
||||
* This could be considered an information leak in some contexts, so the default
|
||||
* setting is false here for optimum safety.
|
||||
*/
|
||||
|
||||
'display_permission_in_exception' => false,
|
||||
|
||||
/*
|
||||
* When set to true, the required role names are added to exception messages.
|
||||
* This could be considered an information leak in some contexts, so the default
|
||||
* setting is false here for optimum safety.
|
||||
*/
|
||||
|
||||
'display_role_in_exception' => false,
|
||||
|
||||
/*
|
||||
* By default wildcard permission lookups are disabled.
|
||||
* See documentation to understand supported syntax.
|
||||
*/
|
||||
|
||||
'enable_wildcard_permission' => false,
|
||||
|
||||
/*
|
||||
* The class to use for interpreting wildcard permissions.
|
||||
* If you need to modify delimiters, override the class and specify its name here.
|
||||
*/
|
||||
// 'wildcard_permission' => Spatie\Permission\WildcardPermission::class,
|
||||
|
||||
/* Cache-specific settings */
|
||||
|
||||
'cache' => [
|
||||
|
||||
/*
|
||||
* By default all permissions are cached for 24 hours to speed up performance.
|
||||
* When permissions or roles are updated the cache is flushed automatically.
|
||||
*/
|
||||
|
||||
'expiration_time' => DateInterval::createFromDateString('24 hours'),
|
||||
|
||||
/*
|
||||
* The cache key used to store all permissions.
|
||||
*/
|
||||
|
||||
'key' => 'spatie.permission.cache',
|
||||
|
||||
/*
|
||||
* You may optionally indicate a specific cache driver to use for permission and
|
||||
* role caching using any of the `store` drivers listed in the cache.php config
|
||||
* file. Using 'default' here means to use the `default` set in cache.php.
|
||||
*/
|
||||
|
||||
'store' => 'default',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Reverb Server
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default server used by Reverb to handle
|
||||
| incoming messages as well as broadcasting message to all your
|
||||
| connected clients. At this time only "reverb" is supported.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('REVERB_SERVER', 'reverb'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Reverb Servers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define details for each of the supported Reverb servers.
|
||||
| Each server has its own configuration options that are defined in
|
||||
| the array below. You should ensure all the options are present.
|
||||
|
|
||||
*/
|
||||
|
||||
'servers' => [
|
||||
|
||||
'reverb' => [
|
||||
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
|
||||
'port' => env('REVERB_SERVER_PORT', 8080),
|
||||
'path' => env('REVERB_SERVER_PATH', ''),
|
||||
'hostname' => env('REVERB_HOST'),
|
||||
'options' => [
|
||||
'tls' => [],
|
||||
],
|
||||
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
|
||||
'scaling' => [
|
||||
'enabled' => env('REVERB_SCALING_ENABLED', false),
|
||||
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
|
||||
'server' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'timeout' => env('REDIS_TIMEOUT', 60),
|
||||
],
|
||||
],
|
||||
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
|
||||
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Reverb Applications
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define how Reverb applications are managed. If you choose
|
||||
| to use the "config" provider, you may define an array of apps which
|
||||
| your server will support, including their connection credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'apps' => [
|
||||
|
||||
'provider' => 'config',
|
||||
|
||||
'apps' => [
|
||||
[
|
||||
'key' => env('REVERB_APP_KEY'),
|
||||
'secret' => env('REVERB_APP_SECRET'),
|
||||
'app_id' => env('REVERB_APP_ID'),
|
||||
'options' => [
|
||||
'host' => env('REVERB_HOST'),
|
||||
'port' => env('REVERB_PORT', 443),
|
||||
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'allowed_origins' => ['*'],
|
||||
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
|
||||
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
|
||||
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
|
||||
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
|
||||
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
|
||||
'rate_limiting' => [
|
||||
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
|
||||
'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60),
|
||||
'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60),
|
||||
'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -34,5 +34,11 @@ return [
|
||||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||
],
|
||||
],
|
||||
'mailgun' => [
|
||||
'domain' => env('MAILGUN_DOMAIN'),
|
||||
'secret' => env('MAILGUN_SECRET'),
|
||||
'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
|
||||
'scheme' => 'https',
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use NotificationChannels\WebPush\PushSubscription;
|
||||
|
||||
return [
|
||||
|
||||
/**
|
||||
* These are the keys for authentication (VAPID).
|
||||
* These keys must be safely stored and should not change.
|
||||
*/
|
||||
'vapid' => [
|
||||
'subject' => env('VAPID_SUBJECT'),
|
||||
'public_key' => env('VAPID_PUBLIC_KEY'),
|
||||
'private_key' => env('VAPID_PRIVATE_KEY'),
|
||||
'pem_file' => env('VAPID_PEM_FILE'),
|
||||
],
|
||||
|
||||
/**
|
||||
* This is model that will be used to for push subscriptions.
|
||||
*/
|
||||
'model' => PushSubscription::class,
|
||||
|
||||
/**
|
||||
* This is the name of the table that will be created by the migration and
|
||||
* used by the PushSubscription model shipped with this package.
|
||||
*/
|
||||
'table_name' => env('WEBPUSH_DB_TABLE', 'push_subscriptions'),
|
||||
|
||||
/**
|
||||
* This is the database connection that will be used by the migration and
|
||||
* the PushSubscription model shipped with this package.
|
||||
*/
|
||||
'database_connection' => env('WEBPUSH_DB_CONNECTION', env('DB_CONNECTION', 'mysql')),
|
||||
|
||||
/**
|
||||
* The Guzzle client options used by Minishlink\WebPush.
|
||||
*/
|
||||
'client_options' => [],
|
||||
|
||||
/**
|
||||
* The automatic padding in bytes used by Minishlink\WebPush.
|
||||
* Set to false to support Firefox Android with v1 endpoint.
|
||||
*/
|
||||
'automatic_padding' => env('WEBPUSH_AUTOMATIC_PADDING', true),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Achievement;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('achievements', function (Blueprint $table) {
|
||||
$table->boolean('has_page')->default(false);
|
||||
});
|
||||
|
||||
$achievements = [
|
||||
'airlines_alliances.all_skyteam',
|
||||
'airlines_alliances.all_oneworld',
|
||||
'airlines_alliances.all_star_alliance',
|
||||
'airlines_alliances.all_vanilla_alliance',
|
||||
'fun_challenges.airline_alphabet',
|
||||
'fun_challenges.airport_alphabet',
|
||||
'fun_challenges.brazilian_states',
|
||||
'fun_challenges.us_states',
|
||||
'fun_challenges.australian_states',
|
||||
'fun_challenges.chinese_provinces',
|
||||
'fun_challenges.canadian_provinces',
|
||||
'countries_continents.all_continent_pairs_one_way',
|
||||
'countries_continents.all_continent_pairs_both_ways',
|
||||
];
|
||||
|
||||
foreach($achievements as $achievement) {
|
||||
Achievement::where('internal_name', $achievement)->update(['has_page' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Achievement;
|
||||
use App\Models\AchievementCategory;
|
||||
use App\Models\AchievementDifficulty;
|
||||
use App\Models\Airline;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
|
||||
Achievement::create([
|
||||
'name' => 'Circumnavigator',
|
||||
'internal_name' => 'general_flying.circumference_of_the_earth',
|
||||
'short_description' => 'Fly the same distance as the circumference of the Earth at the equator!',
|
||||
'icon' => 'standard_achievement.png',
|
||||
'progressive' => true,
|
||||
'long_description' => '',
|
||||
'achievement_category_id' => AchievementCategory::where('internal_name', 'general_flying')->first()->id,
|
||||
'achievement_difficulty_id' => AchievementDifficulty::where('internal_name', 'moderate')->first()->id,
|
||||
'threshold' => 40075,
|
||||
'has_page' => false,
|
||||
]);
|
||||
|
||||
Achievement::create([
|
||||
'name' => 'Fly Me to The Moon',
|
||||
'internal_name' => 'general_flying.to_the_moon',
|
||||
'short_description' => 'Fly the same distance as the Earth to the Moon!',
|
||||
'icon' => 'standard_achievement.png',
|
||||
'long_description' => '',
|
||||
'progressive' => true,
|
||||
'threshold' => 384400,
|
||||
'achievement_category_id' => AchievementCategory::where('internal_name', 'general_flying')->first()->id,
|
||||
'achievement_difficulty_id' => AchievementDifficulty::where('internal_name', 'hard')->first()->id,
|
||||
'has_page' => false,
|
||||
]);
|
||||
|
||||
Achievement::whereInternalName('aircraft.all_boeing_7x7')->update(['has_page' => true]);
|
||||
Achievement::whereInternalName('aircraft.all_airbus_a3xx')->update(['has_page' => true]);
|
||||
Airline::whereInternalName('south-africa-airways')->update(['name' => 'South African Airways']);
|
||||
|
||||
Schema::table('achievements', function (Blueprint $table) {
|
||||
$table->unsignedInteger('sort_order')->nullable()->after('id');
|
||||
});
|
||||
|
||||
// Seed sort_order from current id order, scoped per category
|
||||
$achievements = DB::table('achievements')
|
||||
->orderBy('achievement_category_id')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$position = 1;
|
||||
$currentCategory = null;
|
||||
|
||||
foreach ($achievements as $achievement) {
|
||||
if ($achievement->achievement_category_id !== $currentCategory) {
|
||||
$position = 1;
|
||||
$currentCategory = $achievement->achievement_category_id;
|
||||
}
|
||||
DB::table('achievements')
|
||||
->where('id', $achievement->id)
|
||||
->update(['sort_order' => $position++]);
|
||||
}
|
||||
|
||||
// Move "Four on the Floor" (id 30) after "Triple Threat" (id 34)
|
||||
// within the aircraft category — swap their sort_order values
|
||||
$triEngine = DB::table('achievements')->where('internal_name', 'aircraft.tri_engine')->first();
|
||||
$quadEngine = DB::table('achievements')->where('internal_name', 'aircraft.quad_engine')->first();
|
||||
|
||||
DB::table('achievements')->where('internal_name', 'aircraft.quad_engine')
|
||||
->update(['sort_order' => $triEngine->sort_order + 1]);
|
||||
|
||||
// Shift everything between them up by 1 to make room
|
||||
DB::table('achievements')
|
||||
->where('achievement_category_id', $quadEngine->achievement_category_id)
|
||||
->where('sort_order', '>=', $triEngine->sort_order + 1)
|
||||
->where('internal_name', '!=', 'aircraft.quad_engine')
|
||||
->increment('sort_order');
|
||||
|
||||
$users = User::all();
|
||||
|
||||
foreach ($users as $user) {
|
||||
$user->calculateAchievements();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Achievement;
|
||||
use App\Models\AchievementCategory;
|
||||
use App\Models\AchievementDifficulty;
|
||||
use App\Models\Aircraft;
|
||||
use App\Models\IataEquipmentCode;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
|
||||
function setDesignatorDefault($designator, $model_name, $manufacturer_code=null): self
|
||||
{
|
||||
$manufacturerSearchTerm = $manufacturer_code ? ['manufacturer_code' => $manufacturer_code] : [];
|
||||
|
||||
$count = Aircraft::where([
|
||||
'designator' => $designator,
|
||||
'model_full_name' => $model_name,
|
||||
...$manufacturerSearchTerm,
|
||||
])->update(['preferred' => true]);
|
||||
|
||||
echo $designator . ' ' . $model_name . ' ' . $manufacturer_code . ' ' . $count . PHP_EOL;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
function setDesignatorDefaults(): void
|
||||
{
|
||||
$this
|
||||
->setDesignatorDefault('A19N', 'A-319neo')
|
||||
->setDesignatorDefault('A306', 'A-300B4-600')
|
||||
->setDesignatorDefault('A30B', 'A-300B4-200')
|
||||
->setDesignatorDefault('A310', 'A-310')
|
||||
->setDesignatorDefault('A318', 'A-318')
|
||||
->setDesignatorDefault('A319', 'A-319')
|
||||
->setDesignatorDefault('A320', 'A-320')
|
||||
->setDesignatorDefault('A332', 'A-330-200')
|
||||
->setDesignatorDefault('A338', 'A-330-800')
|
||||
->setDesignatorDefault('A339', 'A-330-900')
|
||||
->setDesignatorDefault('A342', 'A-340-200')
|
||||
->setDesignatorDefault('A343', 'A-340-300')
|
||||
->setDesignatorDefault('A345', 'A-340-500')
|
||||
->setDesignatorDefault('A346', 'A-340-600')
|
||||
->setDesignatorDefault('A359', 'A-350-900 XWB')
|
||||
->setDesignatorDefault('A35K', 'A-350-1000 XWB')
|
||||
->setDesignatorDefault('A388', 'A-380-800')
|
||||
->setDesignatorDefault('AN24', 'An-24')
|
||||
->setDesignatorDefault('AN26', 'An-26')
|
||||
->setDesignatorDefault('AT43', 'ATR-42-300')
|
||||
->setDesignatorDefault('AT44', 'ATR-42-400')
|
||||
->setDesignatorDefault('AT72', 'ATR-72-201')
|
||||
->setDesignatorDefault('AT73', 'ATR-72-211')
|
||||
->setDesignatorDefault('AT75', 'ATR-72-500')
|
||||
->setDesignatorDefault('AT76', 'ATR-72-600')
|
||||
->setDesignatorDefault('B37M', '737 MAX 7')
|
||||
->setDesignatorDefault('B38M', '737 MAX 8')
|
||||
->setDesignatorDefault('B39M', '737 MAX 9')
|
||||
->setDesignatorDefault('B3XM', '737 MAX 10')
|
||||
->setDesignatorDefault('B461', 'BAe-146-100')
|
||||
->setDesignatorDefault('B462', 'BAe-146-200')
|
||||
->setDesignatorDefault('B703', '707-300')
|
||||
->setDesignatorDefault('B712', '717-200')
|
||||
->setDesignatorDefault('B721', '727-100')
|
||||
->setDesignatorDefault('B732', '737-200')
|
||||
->setDesignatorDefault('B737', '737-700')
|
||||
->setDesignatorDefault('B738', '737-800')
|
||||
->setDesignatorDefault('B739', '737-900')
|
||||
->setDesignatorDefault('B742', '747-200')
|
||||
->setDesignatorDefault('B748', '747-8')
|
||||
->setDesignatorDefault('B752', '757-200')
|
||||
->setDesignatorDefault('B772', '777-200')
|
||||
->setDesignatorDefault('B778', '777-8')
|
||||
->setDesignatorDefault('B77L', '777-200LR')
|
||||
->setDesignatorDefault('B77W', '777-300ER')
|
||||
->setDesignatorDefault('B788', '787-8 Dreamliner')
|
||||
->setDesignatorDefault('B789', '787-9 Dreamliner')
|
||||
->setDesignatorDefault('BCS1', 'A-220-100')
|
||||
->setDesignatorDefault('BCS3', 'A-220-300')
|
||||
->setDesignatorDefault('CRJ1', 'CL-600 Regional Jet CRJ-100')
|
||||
->setDesignatorDefault('CRJ2', 'CL-600 Regional Jet CRJ-200')
|
||||
->setDesignatorDefault('CRJ7', 'CL-600 Regional Jet CRJ-700')
|
||||
->setDesignatorDefault('CRJ9', 'CL-600 Regional Jet CRJ-900')
|
||||
->setDesignatorDefault('DC10', 'DC-10')
|
||||
->setDesignatorDefault('DC3', 'DC-3')
|
||||
->setDesignatorDefault('DH8B', 'DHC-8-200 Dash 8' , 'DE HAVILLAND CANADA')
|
||||
->setDesignatorDefault('DH8A', 'DHC-8-100 Dash 8' , 'DE HAVILLAND CANADA')
|
||||
->setDesignatorDefault('DH8C', 'DHC-8-300 Dash 8' , 'DE HAVILLAND CANADA')
|
||||
->setDesignatorDefault('DH8D', 'DHC-8-400 Dash 8' , 'DE HAVILLAND CANADA')
|
||||
->setDesignatorDefault('DHC6', 'DHC-6 Twin Otter','DE HAVILLAND CANADA')
|
||||
->setDesignatorDefault('E120', 'EMB-120 Brasilia', 'EMBRAER')
|
||||
->setDesignatorDefault('E135', 'ERJ-135')
|
||||
->setDesignatorDefault('E145', 'ERJ-145ER')
|
||||
->setDesignatorDefault('E170', '170')
|
||||
->setDesignatorDefault('E190', '190')
|
||||
->setDesignatorDefault('E195', '195')
|
||||
->setDesignatorDefault('E275', 'E175-E2')
|
||||
->setDesignatorDefault('E290', 'E190-E2')
|
||||
->setDesignatorDefault('E295', 'E195-E2')
|
||||
->setDesignatorDefault('F27', 'F-27 Friendship', 'FOKKER')
|
||||
->setDesignatorDefault('JS32', 'BAe-3200 Jetstream Super 31', 'BRITISH AEROSPACE')
|
||||
->setDesignatorDefault('JS41', 'BAe-4100 Jetstream 41', 'BRITISH AEROSPACE')
|
||||
->setDesignatorDefault('MD81', 'MD-81', 'MCDONNELL DOUGLAS')
|
||||
->setDesignatorDefault('MD82', 'MD-82', 'MCDONNELL DOUGLAS')
|
||||
->setDesignatorDefault('MD83', 'MD-83', 'MCDONNELL DOUGLAS')
|
||||
->setDesignatorDefault('MD87', 'MD-87', 'MCDONNELL DOUGLAS')
|
||||
->setDesignatorDefault('MD88', 'MD-88', 'MCDONNELL DOUGLAS')
|
||||
->setDesignatorDefault('MD90', 'MD-90', 'MCDONNELL DOUGLAS')
|
||||
->setDesignatorDefault('AJ27', 'C-909')
|
||||
;
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('aircraft', function (Blueprint $table) {
|
||||
$table->boolean('preferred')->default(false)->after('designator');
|
||||
});
|
||||
|
||||
$this->setDesignatorDefaults();
|
||||
|
||||
IataEquipmentCode::create([
|
||||
'iata_code' => '388',
|
||||
'icao_code' => 'A388',
|
||||
'description' => 'Airbus A380'
|
||||
]);
|
||||
|
||||
Achievement::create([
|
||||
'name' => 'Gone a Gigametre',
|
||||
'internal_name' => 'general_flying.gigametre',
|
||||
'short_description' => 'Fly 1 million kilometres.',
|
||||
'long_description' => '',
|
||||
'achievement_difficulty_id' => AchievementDifficulty::whereInternalName('near_impossible')->first()->id,
|
||||
'achievement_category_id' => AchievementCategory::whereInternalName('general_flying')->first()->id,
|
||||
'icon' => 'standard_achievement.png',
|
||||
'has_page' => false,
|
||||
'sort_order' => 18,
|
||||
'progressive' => true,
|
||||
'threshold' => 1000000,
|
||||
]);
|
||||
|
||||
Achievement::whereInternalName('aircraft.smaller_manufacturer')->update([
|
||||
'difficulty_description' => 'General Aviation flights do not count. Only flights with an airline and flight number can earn this achievement',
|
||||
]);
|
||||
|
||||
foreach(User::all() as $user) {
|
||||
$user->calculateAchievements();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Aircraft;
|
||||
use App\Models\Airline;
|
||||
use App\Models\Country;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Airline::create([
|
||||
'name' => 'Scoot',
|
||||
'IATA_code' => 'TR',
|
||||
'ICAO_code' => 'TGW',
|
||||
'internal_name' => 'scoot-new',
|
||||
'logo' => 'TR.png',
|
||||
'active' => true,
|
||||
'country_id' => Country::whereCode('SG')->first()->id,
|
||||
]);
|
||||
|
||||
Aircraft::where('manufacturer_code', 'ATR')
|
||||
->each(function ($aircraft) {
|
||||
$aircraft->update([
|
||||
'model_full_name' => str_replace('ATR-', '', $aircraft->model_full_name),
|
||||
]);
|
||||
});
|
||||
|
||||
Aircraft::where('manufacturer_code', 'AIRBUS')
|
||||
->each(function ($aircraft) {
|
||||
$aircraft->update([
|
||||
'model_full_name' => str_replace('A-', 'A', $aircraft->model_full_name),
|
||||
]);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use App\Models\FlightReason;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
FlightReason::where('name', 'Other')->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Airline;
|
||||
use App\Models\Country;
|
||||
use App\Models\UserFlight;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Airline::create([
|
||||
'name' => 'Pacific Blue',
|
||||
'internal_name' => 'pacific-blue',
|
||||
'IATA_code' => 'DJ',
|
||||
'ICAO_code' => 'PBN',
|
||||
'active' => false,
|
||||
'logo' => 'pacific-blue.png',
|
||||
'country_id' => Country::where('code', 'NZ')->first()->id,
|
||||
]);
|
||||
|
||||
$flightIds = [326, 327];
|
||||
|
||||
UserFlight::whereIn('id', $flightIds)->update(['airline_id' => Airline::where('internal_name', 'pacific-blue')->first()->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('distance_unit', 3)->default('km')->after('id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('airlines', function (Blueprint $table) {
|
||||
$table->renameColumn('IATA_code', 'iata_code');
|
||||
$table->renameColumn('ICAO_code', 'icao_code');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$teams = config('permission.teams');
|
||||
$tableNames = config('permission.table_names');
|
||||
$columnNames = config('permission.column_names');
|
||||
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
|
||||
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
|
||||
|
||||
throw_if(empty($tableNames), 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
|
||||
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
|
||||
|
||||
/**
|
||||
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
|
||||
*/
|
||||
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
|
||||
$table->id(); // permission id
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['name', 'guard_name']);
|
||||
});
|
||||
|
||||
/**
|
||||
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
|
||||
*/
|
||||
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
|
||||
$table->id(); // role id
|
||||
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
|
||||
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
|
||||
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
|
||||
}
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->timestamps();
|
||||
if ($teams || config('permission.testing')) {
|
||||
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
|
||||
} else {
|
||||
$table->unique(['name', 'guard_name']);
|
||||
}
|
||||
});
|
||||
|
||||
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
|
||||
$table->unsignedBigInteger($pivotPermission);
|
||||
|
||||
$table->string('model_type');
|
||||
$table->unsignedBigInteger($columnNames['model_morph_key']);
|
||||
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
|
||||
|
||||
$table->foreign($pivotPermission)
|
||||
->references('id') // permission id
|
||||
->on($tableNames['permissions'])
|
||||
->cascadeOnDelete();
|
||||
if ($teams) {
|
||||
$table->unsignedBigInteger($columnNames['team_foreign_key']);
|
||||
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
|
||||
|
||||
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_permissions_permission_model_type_primary');
|
||||
} else {
|
||||
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_permissions_permission_model_type_primary');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
|
||||
$table->unsignedBigInteger($pivotRole);
|
||||
|
||||
$table->string('model_type');
|
||||
$table->unsignedBigInteger($columnNames['model_morph_key']);
|
||||
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
|
||||
|
||||
$table->foreign($pivotRole)
|
||||
->references('id') // role id
|
||||
->on($tableNames['roles'])
|
||||
->cascadeOnDelete();
|
||||
if ($teams) {
|
||||
$table->unsignedBigInteger($columnNames['team_foreign_key']);
|
||||
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
|
||||
|
||||
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_roles_role_model_type_primary');
|
||||
} else {
|
||||
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_roles_role_model_type_primary');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
|
||||
$table->unsignedBigInteger($pivotPermission);
|
||||
$table->unsignedBigInteger($pivotRole);
|
||||
|
||||
$table->foreign($pivotPermission)
|
||||
->references('id') // permission id
|
||||
->on($tableNames['permissions'])
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->foreign($pivotRole)
|
||||
->references('id') // role id
|
||||
->on($tableNames['roles'])
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
|
||||
});
|
||||
|
||||
app('cache')
|
||||
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
|
||||
->forget(config('permission.cache.key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$tableNames = config('permission.table_names');
|
||||
|
||||
throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
|
||||
|
||||
Schema::dropIfExists($tableNames['role_has_permissions']);
|
||||
Schema::dropIfExists($tableNames['model_has_roles']);
|
||||
Schema::dropIfExists($tableNames['model_has_permissions']);
|
||||
Schema::dropIfExists($tableNames['roles']);
|
||||
Schema::dropIfExists($tableNames['permissions']);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$role = Role::create(['name' => 'admin']);
|
||||
$permission = Permission::create(['name' => 'reconcile_missing_liveries']);
|
||||
|
||||
$role->givePermissionTo($permission);
|
||||
|
||||
$user = User::whereName('Josh')->first();
|
||||
|
||||
$user->assignRole($role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ignored_missing_liveries', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('filename')->unique()->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ignored_missing_liveries');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
// database/migrations/xxxx_add_settings_to_users_table.php
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->json('settings')->nullable()->after('password');
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('followees', function (Blueprint $table) {
|
||||
$table->boolean('verified')->default(true)->after('followee_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('user_flights', function (Blueprint $table) {
|
||||
$table->timestamp('departure_processed_at')->nullable()->after('departure_date');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::connection(config('webpush.database_connection'))->create(config('webpush.table_name'), function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->morphs('subscribable', 'push_subscriptions_subscribable_morph_idx');
|
||||
$table->string('endpoint', 500)->unique();
|
||||
$table->string('public_key')->nullable();
|
||||
$table->string('auth_token')->nullable();
|
||||
$table->string('content_encoding')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::connection(config('webpush.database_connection'))->dropIfExists(config('webpush.table_name'));
|
||||
}
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('user_flights', function (Blueprint $table) {
|
||||
$table->timestamp('arrival_processed_at')->nullable()->after('departure_processed_at');
|
||||
});
|
||||
|
||||
DB::table('user_flights')
|
||||
->where('arrival_date', '<=', now()->utc())
|
||||
->whereNull('arrival_processed_at')
|
||||
->update(['arrival_processed_at' => now()->utc()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Achievement;
|
||||
use App\Models\AchievementCategory;
|
||||
use App\Models\AchievementDifficulty;
|
||||
use App\Models\Aircraft;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use function React\Promise\all;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Aircraft::create([
|
||||
'designator' => 'B731',
|
||||
'manufacturer_code' => 'BOEING',
|
||||
'model_full_name' => '737-100',
|
||||
'aircraft_description' => 'LandPlane',
|
||||
'engine_type' => 'Jet',
|
||||
'engine_count' => 2,
|
||||
'wtc' => 'M',
|
||||
'preferred' => false,
|
||||
]);
|
||||
|
||||
$modelNames = ['737-8', '737-9', '737-10', '737-7'];
|
||||
Aircraft::whereIn('model_full_name', $modelNames)->delete();
|
||||
|
||||
$impossible = AchievementDifficulty::where('internal_name', 'impossible')->first();
|
||||
$aircraftCategory = AchievementCategory::where('internal_name', 'aircraft')->first();
|
||||
|
||||
Achievement::create([
|
||||
'internal_name' => 'aircraft.all_boeing_737',
|
||||
'name' => 'Rarest of the Commoners',
|
||||
'short_description' => 'Fly Every Variant of the Boeing 737',
|
||||
'icon' => 'standard_achievement.png',
|
||||
'achievement_category_id' => $aircraftCategory->id,
|
||||
'achievement_difficulty_id' => $impossible->id,
|
||||
'progressive' => true,
|
||||
'threshold' => 13,
|
||||
'sort_order' => 7,
|
||||
'has_page' => true,
|
||||
'long_description' => ''
|
||||
]);
|
||||
|
||||
foreach(User::all() as $user){
|
||||
$user->calculateAchievements();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Airline;
|
||||
use App\Models\Alliance;
|
||||
use App\Models\User;
|
||||
use App\Models\UserFlight;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('airlines', function (Blueprint $table) {
|
||||
$table->foreignId('merged_into_id')->nullable()->after('alliance_id')->constrained('airlines')->nullOnDelete();
|
||||
});
|
||||
|
||||
$madagascarAirlines = Airline::where('internal_name', 'air-madagascar')->first();
|
||||
$madagascarAirlines->update(['internal_name' => 'madagascar-airlines']);
|
||||
|
||||
$airMadagascar = Airline::create([
|
||||
'iata_code' => 'MD',
|
||||
'icao_code' => 'MDG',
|
||||
'name' => 'Air Madagascar',
|
||||
'internal_name' => 'air-madagascar',
|
||||
'active' => false,
|
||||
'logo' => 'MD.png',
|
||||
'country_id' => 140,
|
||||
'alliance_id' => null,
|
||||
'merged_into_id' => $madagascarAirlines->id,
|
||||
]);
|
||||
|
||||
UserFlight::where('airline_id', $madagascarAirlines->id)->first()->update(['airline_id' => $airMadagascar->id]);
|
||||
|
||||
foreach(User::all() as $user) {
|
||||
$user->calculateAchievements();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -23,7 +23,7 @@ class AirlinesSeeder extends Seeder
|
||||
$renames = [
|
||||
'airlineId' => 'id',
|
||||
'codeIataAirline' => 'IATA_code',
|
||||
'codeIcaoAirline' => 'ICAO_code',
|
||||
'codeIcaoAirline' => 'icao_code',
|
||||
'slug' => 'internal_name',
|
||||
'nameAirline' => 'name',
|
||||
'codeIso2Country' => 'country_code',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user