diff --git a/app/Console/Commands/UpdateDepartedFlights.php b/app/Console/Commands/UpdateDepartedFlights.php index 2aeb59a..7658dd8 100644 --- a/app/Console/Commands/UpdateDepartedFlights.php +++ b/app/Console/Commands/UpdateDepartedFlights.php @@ -4,6 +4,7 @@ namespace App\Console\Commands; use App\Models\Aircraft; use App\Models\Notification; +use App\Models\User; use App\Models\UserFlight; use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; @@ -33,4 +34,28 @@ class UpdateDepartedFlights extends Command $flight->update(['departure_processed_at' => $now]); } } + + protected function notifyFollowersOnDeparture(UserFlight $flight): void + { + $user = $flight->user; + $title = "{$user->name} is taking off!"; + $flightNumber = $flight->flight_number ? "On {$flight->flight_number} from" : 'From'; + $body = "{$flightNumber} {$flight->departureAirport->municipality} → {$flight->arrivalAirport->municipality}"; + $url = route('profile.flight', [$flight->id]); + + $user->followers()->get()->each(function (User $follower) use ($title, $body, $url) { + $notify = $follower->getSetting('notify_on_flight_departure'); + + if ($notify) { + Notification::create([ + 'user_id' => $follower->id, + 'title' => $title, + 'body' => $body, + 'url' => $url, + ]); + } + + } + ); + } } diff --git a/app/Http/Controllers/FlightController.php b/app/Http/Controllers/FlightController.php index 1d49de9..d0d4a77 100644 --- a/app/Http/Controllers/FlightController.php +++ b/app/Http/Controllers/FlightController.php @@ -9,7 +9,9 @@ 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; @@ -246,10 +248,41 @@ class FlightController extends Controller ], ]); + $this->notifyFollowersOnCreation($newFlight); + return redirect()->route('profile.departure-board', [Auth::user()->name, $newFlight->id]); } + protected function notifyFollowersOnCreation(UserFlight $flight): void + { + $user = $flight->user; + $isFuture = $flight->departure_date->isFuture(); + $title = $isFuture + ? "{$user->name} booked a new flight" + : "{$user->name} logged a new flight"; + + + $flightNumber = $flight->flight_number ? "{$flight->flight_number} from" : 'From'; + $body = "{$flightNumber} {$flight->departureAirport->municipality} ({$flight->departureAirport->display_code}) → {$flight->arrivalAirport->municipality} ({$flight->arrivalAirport->display_code}) on {$flight->departure_date_display}"; + $url = route('profile.departure-board', [$user->name, $flight->id]); + + $user->followers()->get()->each(function (User $follower) use ($title, $body, $url, $isFuture) { + $notifyForFuture = $follower->getSetting('notify_on_upcoming_flight_added'); + $notifyForHistoric = $follower->getSetting('notify_on_historic_flight_added'); + + if (($isFuture && $notifyForFuture) || (!$isFuture && $notifyForHistoric)) { + Notification::create([ + 'user_id' => $follower->id, + 'title' => $title, + 'body' => $body, + 'url' => $url, + ]); + } + + } + ); + } public function update(Request $request, UserFlight $flight) @@ -280,25 +313,46 @@ class FlightController extends Controller $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, - 'data' => [ + 'user_id' => $flight->user_id, + 'type' => $isFuture ? 'flight_cancelled' : 'flight_deleted', + 'data' => [ 'flight' => $snapshot, - ] + ], ]); + if ($isFuture) { + $this->notifyFollowersOnCancellation($flight); + } + $flight->delete(); + return redirect()->route('profile.'.$referrer, [Auth::user()->name]); } + protected function notifyFollowersOnCancellation(UserFlight $flight): void + { + $user = $flight->user; + + $title = "{$user->name} cancelled a flight"; + $flightNumber = $flight->flight_number ? "{$flight->flight_number} from" : 'From'; + $body = "{$flightNumber} {$flight->departureAirport->municipality} ({$flight->departureAirport->display_code}) → {$flight->arrivalAirport->municipality} ({$flight->arrivalAirport->display_code}) on {$flight->departure_date_display}"; + $url = route('profile.view', [$user->name]); + + $user->followers()->get()->each(function (User $follower) use ($title, $body, $url) { + if ($follower->getSetting('notify_on_flight_cancellation')) { + Notification::create([ + 'user_id' => $follower->id, + 'title' => $title, + 'body' => $body, + 'url' => $url, + ]); + } + }); + } + public function staticData() : array { return [ 'seat_types' => SeatType::orderBy('id')->get()->toArray(), diff --git a/app/Http/Controllers/FlightImportController.php b/app/Http/Controllers/FlightImportController.php index 6b816e2..dc91857 100644 --- a/app/Http/Controllers/FlightImportController.php +++ b/app/Http/Controllers/FlightImportController.php @@ -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; @@ -268,10 +270,43 @@ class FlightImportController extends Controller ], ]); + $this->notifyFollowersOnCreation($newFlight); + ImportedFlight::destroy($validated['imported_flight_id']); return to_route('reconcile'); } + protected function notifyFollowersOnCreation(UserFlight $flight): void + { + $user = $flight->user; + $isFuture = $flight->departure_date->isFuture(); + + $title = $isFuture + ? "{$user->name} booked a new flight" + : "{$user->name} logged a new flight"; + + + $flightNumber = $flight->flight_number ? "{$flight->flight_number} from" : 'From'; + $body = "{$flightNumber} {$flight->departureAirport->municipality} ({$flight->departureAirport->display_code}) → {$flight->arrivalAirport->municipality} ({$flight->arrivalAirport->display_code}) on {$flight->departure_date_display}"; + $url = route('profile.departure-board', [$user->name, $flight->id]); + + $user->followers()->get()->each(function (User $follower) use ($title, $body, $url, $isFuture) { + $notifyForFuture = $follower->getSetting('notify_on_upcoming_flight_added'); + $notifyForHistoric = $follower->getSetting('notify_on_historic_flight_added'); + + if (($isFuture && $notifyForFuture) || (!$isFuture && $notifyForHistoric)) { + Notification::create([ + 'user_id' => $follower->id, + 'title' => $title, + 'body' => $body, + 'url' => $url, + ]); + } + + } + ); + } + private function validateCsvFormat(string $path): ?string { diff --git a/app/Http/Controllers/FollowerController.php b/app/Http/Controllers/FollowerController.php index 0d92710..9d063cf 100644 --- a/app/Http/Controllers/FollowerController.php +++ b/app/Http/Controllers/FollowerController.php @@ -38,6 +38,14 @@ class FollowerController extends Controller 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']); } @@ -51,15 +59,17 @@ class FollowerController extends Controller $this->clearFollowingCache(auth()->user()); - 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 : '/follow-requests', - ]); + 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 : '/follow-requests', + ]); + } return response()->json(['status' => $canView ? 'following' : 'requested']); } diff --git a/app/Models/User.php b/app/Models/User.php index a6927cd..beb4950 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; 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; @@ -144,11 +145,20 @@ class User extends Authenticatable return $this->hasMany(Followee::class, 'user_id')->verified(); } - public function followers(): HasMany + public function pendingFollowers(): BelongsToMany { - return $this->hasMany(Followee::class, 'followee_id')->verified(); + 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 { diff --git a/app/Observers/FlightObserver.php b/app/Observers/FlightObserver.php index 3737757..ddfed4b 100644 --- a/app/Observers/FlightObserver.php +++ b/app/Observers/FlightObserver.php @@ -2,6 +2,7 @@ namespace App\Observers; +use App\Models\User; use App\Models\UserFlight; use Illuminate\Support\Facades\Cache; @@ -11,10 +12,11 @@ class FlightObserver { Cache::forget("user_flights_{$flight->user->id}"); - //Make queued task if the site gets big - $flight->user->followers() + $flight + ->user + ->followers() ->get() - ->each(fn ($follower) => Cache::forget("user_following_flights_{$follower->user_id}")); + ->each(fn ($follower) => Cache::forget("user_following_flights_{$follower->id}")); } diff --git a/app/Settings/SettingsRegistry.php b/app/Settings/SettingsRegistry.php index ad23a45..7367795 100644 --- a/app/Settings/SettingsRegistry.php +++ b/app/Settings/SettingsRegistry.php @@ -12,6 +12,7 @@ class SettingsRegistry '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!)', ]; } @@ -22,7 +23,7 @@ class SettingsRegistry 'key' => 'distance_unit', 'type' => 'select', 'label' => 'Distance Units', - 'category' => 'Units of Measurement', + 'category' => 'FlightsGoneBy Settings', 'default' => 'km', 'options' => [ ['value' => 'km', 'label' => 'Kilometres (km)'], @@ -107,7 +108,7 @@ class SettingsRegistry 'category' => 'FlightsGoneBy Settings', 'type' => 'multiselect', 'label' => 'Which columns to show on the Departure Board', - 'default' => ['airline', 'flight_number', 'from', 'to', 'departure_date', 'departure_time', 'arrival_time', 'duration', 'distance', 'aircraft', 'registration', 'class_seat_combined'], + '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'], ['value' => 'flight_number', 'label' => 'Flight Number'], @@ -123,6 +124,48 @@ class SettingsRegistry ['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_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, + ],*/ + [ + 'category' => 'Notifications', + 'key' => 'notify_on_flight_cancellation', + 'type' => 'checkbox', + 'label' => 'Notify Me When Someone I Follow Cancels an Upcoming Flight', + 'default' => false, + ], ]; } @@ -139,13 +182,16 @@ class SettingsRegistry foreach (static::schema() as $field) { $key = "settings.{$field['key']}"; $rules[$key] = match ($field['type']) { - 'select' => ['required', 'string', 'in:' . implode(',', array_column($field['options'], 'value'))], - 'checkbox' => ['boolean'], - 'text' => ['nullable', 'string', 'max:255'], - 'multiselect' => ['nullable', 'array'], - "settings.{$field['key']}.*" => ['string'], - default => ['nullable'], + '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; } diff --git a/dockerfile b/dockerfile index f54e96b..392a3cb 100644 --- a/dockerfile +++ b/dockerfile @@ -3,6 +3,7 @@ FROM php:8.4-fpm-alpine RUN apk add --no-cache nginx curl zip unzip git postgresql-dev nodejs npm dcron \ && docker-php-ext-install pdo pdo_pgsql pgsql opcache pcntl +RUN docker-php-ext-install bcmath COPY --from=composer:latest /usr/bin/composer /usr/bin/composer WORKDIR /var/www diff --git a/resources/js/Components/CategorySettingsForm.vue b/resources/js/Components/CategorySettingsForm.vue new file mode 100644 index 0000000..aea9a6d --- /dev/null +++ b/resources/js/Components/CategorySettingsForm.vue @@ -0,0 +1,118 @@ + + + diff --git a/resources/js/Components/FlightsGoneBy/GlassBox.vue b/resources/js/Components/FlightsGoneBy/GlassBox.vue index b760671..50eb37c 100644 --- a/resources/js/Components/FlightsGoneBy/GlassBox.vue +++ b/resources/js/Components/FlightsGoneBy/GlassBox.vue @@ -1,12 +1,13 @@