Added achievement data

This commit is contained in:
2026-04-28 22:16:21 +10:00
parent 14aed7bf6e
commit b94b1d8ec2
43 changed files with 1559 additions and 130 deletions
@@ -0,0 +1,33 @@
<?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,
]);
}
}
+4 -20
View File
@@ -85,7 +85,6 @@ class FlightController extends Controller
UserAction::create([
'user_id' => $flight->user_id,
'user_flight_id' => $flight->id,
'data' => [
'changes' => $changes,
'original' => $original,
@@ -174,29 +173,14 @@ class FlightController extends Controller
'user_id' => $newFlight->user_id,
'type' => $newFlight->departure_date->isFuture() ? 'flight_booked' : 'flight_logged',
'data' => [
'flight' => $this->flightSnapshot($newFlight->id),
'flight' => $newFlight->snapshot($newFlight->id),
],
]);
return redirect()->route('profile.departure-board', [Auth::user()->name, $newFlight->id]);
}
private function flightSnapshot(int $id): array
{
return UserFlight::with([
'departureAirport',
'departureAirport.region.country',
'arrivalAirport',
'arrivalAirport.region.country',
'aircraft',
'airline',
'airline.country',
'flightClass',
'seatType',
'flightReason',
'crewType',
])->find($id)->toArray();
}
public function update(Request $request, UserFlight $flight)
@@ -212,11 +196,11 @@ class FlightController extends Controller
}
$dirty = $flight->getDirty();
$original = $this->flightSnapshot($flight->id);
$original = $flight->snapshot($flight->id);
$flight->save();
$updated = $this->flightSnapshot($flight->id);
$updated = $flight->snapshot($flight->id);
$this->recordChanges($flight, $dirty, $original, $updated);
return redirect()->route('profile.departure-board', [Auth::user()->name, $flight->id]);
@@ -9,6 +9,7 @@ use App\Models\FlightClass;
use App\Models\FlightReason;
use App\Models\ImportedFlight;
use App\Models\SeatType;
use App\Models\UserAction;
use App\Models\UserFlight;
use Carbon\Carbon;
use Illuminate\Http\Request;
@@ -128,10 +129,11 @@ class FlightImportController extends Controller
})
->orderByDesc('active')
->limit(10)
->get(['id', 'name', 'IATA_code', 'ICAO_code'])
->get(['id', 'name', 'IATA_code', 'ICAO_code', 'internal_name'])
->map(fn($airline) => [
'value' => $airline->id,
'title' => $airline->display_name,
'logo_url' => $airline->logo_url,
])
->values()
->toArray();
@@ -243,7 +245,7 @@ class FlightImportController extends Controller
$arrival = $durationArrival;
}
UserFlight::create([
$newFlight = UserFlight::create([
'user_id' => $user->id,
'departure_date' => $departure,
'arrival_date' => $arrival,
@@ -260,6 +262,14 @@ class FlightImportController extends Controller
'note' => $validated['note'] ?? null,
]);
UserAction::create([
'user_id' => $newFlight->user_id,
'type' => $newFlight->departure_date->isFuture() ? 'flight_booked' : 'flight_imported',
'data' => [
'flight' => $newFlight->snapshot($newFlight->id),
],
]);
ImportedFlight::destroy($validated['imported_flight_id']);
return to_route('reconcile');
}
@@ -11,13 +11,12 @@ use Inertia\Inertia;
class FlightProfileController extends Controller
{
public function profileData(User $user, string $view, ?int $selectedFlightId = null) : array {
$flights = $user->FlightController()->flights();
return [
'user' => $user,
'canEdit' => auth()->check() && auth()->id() === $user->id,
'flights' => UserFlightResource::collection($flights)->resolve(),
'initialView' => $view,
'selectedFlightId' => $selectedFlightId,
'flight_api_url' => '/data/user/'.$user->name.'/flights',
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
];
}
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers;
use App\Models\Notification;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
use AuthorizesRequests;
public function markRead(Request $request, Notification $notification)
{
$this->authorize('update', $notification);
$notification->markAsRead();
}
}
@@ -23,6 +23,7 @@ class UserFlightController extends Controller
'arrivalAirport.region.country',
'arrivalAirport.region.continent',
'airline.country',
'airline.alliance',
'aircraft',
'seatType',
'flightReason',
@@ -34,8 +34,16 @@ class HandleInertiaRequests extends Middleware
'logo_api_url' => config('app.logo_api_url'),
'auth' => [
'user' => $request->user(),
'isLoggedIn' => $request->user() !== null,
],
'achievement_notifications' => fn() => $request->user()
? $request->user()
->notifications()
->with('achievement')
->where('is_achievement', true)
->whereNull('read_at')
->latest()
->get()
: [],
];
}
}
+15 -5
View File
@@ -2,18 +2,16 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Http\Controllers\UserFlightController;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use App\Traits\HasAchievements;
use App\Models\Notification;
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
@@ -21,7 +19,9 @@ class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, HasAchievements;
use HasFactory, HasAchievements, HasApiTokens;
/**
* Get the attributes that should be cast.
@@ -36,6 +36,11 @@ class User extends Authenticatable
];
}
public function achievements(): HasMany
{
return $this->hasMany(UserAchievement::class);
}
public function resolveRouteBinding($value, $field = null): ?User
{
return $this->where('name', 'ilike', $value)->firstOrFail();
@@ -69,4 +74,9 @@ class User extends Authenticatable
{
return $this->following()->where('followee_id', $user->id)->exists();
}
public function notifications(): HasMany
{
return $this->hasMany(Notification::class);
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ class UserAction extends Model
'flight_booked' => 'Flight Booked',
'flight_cancelled' => 'Flight Cancelled',
'flight_updated' => 'Flight Updated',
'flight_imported' => 'Flight Imported',
'flight_imported' => 'Flight Imported from FR24',
'flight_logged' => 'Flight Logged',
'flight_deleted' => 'Flight Deleted',
default => 'Unknown Action'
+17
View File
@@ -156,6 +156,23 @@ class UserFlight extends Model
return !$this->isDomestic();
}
public static function snapshot($userFlightId){
return UserFlight::with([
'departureAirport',
'departureAirport.region.country',
'arrivalAirport',
'arrivalAirport.region.country',
'aircraft',
'airline',
'airline.country',
'airline.alliance',
'flightClass',
'seatType',
'flightReason',
'crewType',
])->find($userFlightId)->toArray();
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Policies;
use App\Models\Notification;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class NotificationPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return false;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Notification $notification): bool
{
return false;
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return false;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Notification $notification): bool
{
return $user->id === $notification->user_id;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Notification $notification): bool
{
return false;
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, Notification $notification): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, Notification $notification): bool
{
return false;
}
}
@@ -55,6 +55,8 @@ class AchievementService
'airline.alliance',
'aircraft',
'flightClass',
'departureAirport.region',
'arrivalAirport.region',
'departureAirport.region.continent',
'arrivalAirport.region.continent',
])->get();
@@ -151,8 +153,8 @@ class AchievementService
{
Notification::create([
'user_id' => $user->id,
'title' => 'Achievement Unlocked!',
'body' => "You've earned the \"{$achievement->name}\" achievement — {$achievement->short_description}",
'title' => $achievement->name,
'body' => $achievement->short_description,
'is_achievement' => true,
'achievement_id' => $achievement->id,
'expires_at' => null,
@@ -5,9 +5,14 @@ namespace App\Services\Achievements\Checkers;
use App\Models\Alliance;
use App\Models\User;
use App\Models\UserFlight;
use Illuminate\Support\Collection;
class AirlinesAndAlliancesChecker extends BaseChecker
{
const array US_3 = ['american-airlines', 'delta', 'united-airlines'];
const array ME_3 = ['emirates', 'etihad-airways', 'qatar-airways'];
const array CN_3 = ['china-southern-airlines', 'china-eastern', 'air-china'];
public function check(): void
{
$flights = $this->flights();
@@ -25,5 +30,16 @@ class AirlinesAndAlliancesChecker extends BaseChecker
$this->awardProgress($check('oneworld'), 'airlines_alliances.all_oneworld');
$this->awardProgress($check('star_alliance'), 'airlines_alliances.all_star_alliance');
$this->awardProgress($check('vanilla_alliance'), 'airlines_alliances.all_vanilla_alliance');
$flownAirlines = $flights
->pluck('airline.internal_name')
->filter()
->unique();
$checkGroup = fn(array $group): int => $flownAirlines->intersect($group)->count();
$this->awardProgress($checkGroup(self::ME_3), 'airlines_alliances.me3');
$this->awardProgress($checkGroup(self::US_3), 'airlines_alliances.us3');
$this->awardProgress($checkGroup(self::CN_3), 'airlines_alliances.cn3');
}
}
@@ -3,27 +3,60 @@
namespace App\Services\Achievements\Checkers;
use App\Models\User;
use App\Models\UserFlight;
class FunChallengesChecker extends BaseChecker
{
const array US_STATES = [
'US-AK', 'US-AL', 'US-AR', 'US-AZ', 'US-CA', 'US-CO', 'US-CT', 'US-DC',
'US-DE', 'US-FL', 'US-GA', 'US-HI', 'US-IA', 'US-ID', 'US-IL', 'US-IN',
'US-KS', 'US-KY', 'US-LA', 'US-MA', 'US-MD', 'US-ME', 'US-MI', 'US-MN',
'US-MO', 'US-MS', 'US-MT', 'US-NC', 'US-ND', 'US-NE', 'US-NH', 'US-NJ',
'US-NM', 'US-NV', 'US-NY', 'US-OH', 'US-OK', 'US-OR', 'US-PA', 'US-RI',
'US-SC', 'US-SD', 'US-TN', 'US-TX', 'US-UT', 'US-VA', 'US-VT', 'US-WA',
'US-WI', 'US-WV', 'US-WY',
];
const array AUSTRALIAN_STATES = [
'AU-ACT', 'AU-NSW', 'AU-NT', 'AU-QLD', 'AU-SA', 'AU-TAS', 'AU-VIC', 'AU-WA',
];
const array CHINESE_PROVINCES = [
'CN-11', 'CN-12', 'CN-13', 'CN-14', 'CN-15', 'CN-21', 'CN-22', 'CN-23',
'CN-31', 'CN-32', 'CN-33', 'CN-34', 'CN-35', 'CN-36', 'CN-37', 'CN-41',
'CN-42', 'CN-43', 'CN-44', 'CN-45', 'CN-46', 'CN-50', 'CN-51', 'CN-52',
'CN-53', 'CN-54', 'CN-61', 'CN-62', 'CN-63', 'CN-64', 'CN-65',
];
const array BRAZILIAN_STATES = [
'BR-AC', 'BR-AL', 'BR-AM', 'BR-AP', 'BR-BA', 'BR-CE', 'BR-DF', 'BR-ES',
'BR-GO', 'BR-MA', 'BR-MG', 'BR-MS', 'BR-MT', 'BR-PA', 'BR-PB', 'BR-PE',
'BR-PI', 'BR-PR', 'BR-RJ', 'BR-RN', 'BR-RO', 'BR-RR', 'BR-RS', 'BR-SC',
'BR-SE', 'BR-SP', 'BR-TO',
];
const array CANADIAN_PROVINCES = [
'CA-AB', 'CA-BC', 'CA-MB', 'CA-NB', 'CA-NL', 'CA-NS', 'CA-NT',
'CA-NU', 'CA-ON', 'CA-PE', 'CA-QC', 'CA-SK', 'CA-YT',
];
public function check(): void
{
$flights = $this->flights();
$airlineLetters = $flights
->filter(fn($f) => $f->airline?->IATA_code !== null)
->map(fn($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();
$this->awardProgress($airlineLetters, 'fun_challenges.airline_alphabet');
// --- Visit the Alphabet ---
// Collect first letters from both departure and arrival airport IATA codes
$airportLetters = $flights
->flatMap(fn($f) => [
->flatMap(fn(UserFlight $f) => [
$f->departureAirport?->iata_code,
$f->arrivalAirport?->iata_code,
])
@@ -33,5 +66,70 @@ class FunChallengesChecker extends BaseChecker
->count();
$this->awardProgress($airportLetters, 'fun_challenges.airport_alphabet');
// --- US States ---
$visitedUsStates = $flights
->flatMap(fn(UserFlight $f) => [
$f->departureAirport?->region?->code,
$f->arrivalAirport?->region?->code,
])
->filter()
->filter(fn($code) => in_array($code, self::US_STATES))
->unique()
->count();
$this->awardProgress($visitedUsStates, 'fun_challenges.us_states');
// --- Australian States ---
$visitedAustralianStates = $flights
->flatMap(fn(UserFlight $f) => [
$f->departureAirport?->region?->code,
$f->arrivalAirport?->region?->code,
])
->filter()
->filter(fn($code) => in_array($code, self::AUSTRALIAN_STATES))
->unique()
->count();
$this->awardProgress($visitedAustralianStates, 'fun_challenges.australian_states');
// --- Chinese Provinces ---
$visitedChineseProvinces = $flights
->flatMap(fn(UserFlight $f) => [
$f->departureAirport?->region?->code,
$f->arrivalAirport?->region?->code,
])
->filter()
->filter(fn($code) => in_array($code, self::CHINESE_PROVINCES))
->unique()
->count();
$this->awardProgress($visitedChineseProvinces, 'fun_challenges.chinese_provinces');
// --- Brazilian States ---
$visitedBrazilianStates = $flights
->flatMap(fn(UserFlight $f) => [
$f->departureAirport?->region?->code,
$f->arrivalAirport?->region?->code,
])
->filter()
->filter(fn($code) => in_array($code, self::BRAZILIAN_STATES))
->unique()
->count();
$this->awardProgress($visitedBrazilianStates, 'fun_challenges.brazilian_states');
// --- Canadian Provinces ---
$visitedCanadianProvinces = $flights
->flatMap(fn(UserFlight $f) => [
$f->departureAirport?->region?->code,
$f->arrivalAirport?->region?->code,
])
->filter()
->filter(fn($code) => in_array($code, self::CANADIAN_PROVINCES))
->unique()
->count();
$this->awardProgress($visitedCanadianProvinces, 'fun_challenges.canadian_provinces');
}
}
@@ -71,6 +71,7 @@ class GeneralFlyingChecker extends BaseChecker
// --- Progressive achievements ---
$this->awardProgress($count,'general_flying.10_flights');
$this->awardProgress($count,'general_flying.50_flights');
$this->awardProgress($count,'general_flying.100_flights');
$this->awardProgress($count,'general_flying.500_flights');
$this->awardProgress($count,'general_flying.1000_flights');