diff --git a/app/Http/Controllers/AirlineController.php b/app/Http/Controllers/AirlineController.php index a9fe113..451d83c 100644 --- a/app/Http/Controllers/AirlineController.php +++ b/app/Http/Controllers/AirlineController.php @@ -57,6 +57,7 @@ public function foundStore(Request $request) 'unit_is_lbs' => $request->boolean('unit_is_lbs'), 'active' => true, 'require_pirep_review' => true, + 'owner_user_id' => auth()->id(), ]); $airline->users()->attach(auth()->id(), ['role' => 'Manager']); diff --git a/app/Http/Controllers/MemberController.php b/app/Http/Controllers/MemberController.php new file mode 100644 index 0000000..b303713 --- /dev/null +++ b/app/Http/Controllers/MemberController.php @@ -0,0 +1,141 @@ +middleware('auth'); + } + + public function index() + { + $airline = session('activeairline'); + + abort_unless($airline && auth()->user()->isManagerOf($airline), 403); + + // Reload from DB — the session model is a snapshot + $airline = Airline::findOrFail($airline->id); + + // Owner first (represented by -1), then Managers (0), Dispatchers (1), Pilots (2); alphabetical within each. + // Sorted in PHP so it stays portable across MySQL/SQLite (tests). + $roleOrder = ['Manager' => 0, 'Dispatcher' => 1, 'Pilot' => 2]; + $members = $airline->users() + ->orderBy('name') + ->get() + ->sortBy(fn ($member) => [ + $member->id === $airline->owner_user_id ? -1 : ($roleOrder[$member->pivot->role] ?? 99), + $member->name + ]) + ->values(); + + return view('manager.members', compact('airline', 'members')); + } + + public function update(Request $request, User $member) + { + $airline = session('activeairline'); + + abort_unless($airline && auth()->user()->isManagerOf($airline), 403); + abort_unless($airline->isMember($member), 403); + + // Reload fresh to have accurate owner_user_id + $airline = Airline::findOrFail($airline->id); + + $viewerIsOwner = auth()->user()->isOwnerOf($airline); + $targetIsOwner = $airline->owner_user_id === $member->id; + + abort_if($targetIsOwner, 403); + + $request->validate(['role' => 'required|in:Pilot,Dispatcher,Manager']); + + if (! $viewerIsOwner) { + $isTargetManager = $member->hasAirlineRole($airline, 'Manager'); + abort_if($isTargetManager || $request->role === 'Manager', 403); + } + + $airline->users()->updateExistingPivot($member->id, ['role' => $request->role]); + + activity() + ->causedBy(auth()->user()) + ->performedOn($airline) + ->withProperties(['level' => ActivityLevel::INFO]) + ->event('airline_member_role_changed') + ->log($member->name . ' is now ' . $request->role . ' at ' . $airline->name); + + return back()->with('success', $member->name . "'s role updated to " . $request->role . '.'); + } + + public function destroy(User $member) + { + $airline = session('activeairline'); + + abort_unless($airline && auth()->user()->isManagerOf($airline), 403); + abort_unless($airline->isMember($member), 403); + + // Reload fresh to have accurate owner_user_id + $airline = Airline::findOrFail($airline->id); + + $viewerIsOwner = auth()->user()->isOwnerOf($airline); + $targetIsOwner = $airline->owner_user_id === $member->id; + + abort_if($targetIsOwner, 403); + + if (! $viewerIsOwner) { + $isTargetManager = $member->hasAirlineRole($airline, 'Manager'); + abort_if($isTargetManager, 403); + } + + $airline->users()->detach($member->id); + + activity() + ->causedBy(auth()->user()) + ->performedOn($airline) + ->withProperties(['level' => ActivityLevel::INFO]) + ->event('airline_member_removed') + ->log($member->name . ' was removed from ' . $airline->name); + + return back()->with('success', $member->name . ' has been removed from the airline.'); + } + + public function transferOwnership(User $member) + { + $airline = session('activeairline'); + + abort_unless($airline, 403); + $airline = Airline::findOrFail($airline->id); + + abort_unless(auth()->user()->isOwnerOf($airline), 403); + abort_unless($airline->isMember($member), 403); + abort_if($member->id === auth()->id(), 422); + + DB::transaction(function () use ($airline, $member) { + // Promote new owner to Manager role in memberships + $airline->users()->updateExistingPivot($member->id, ['role' => 'Manager']); + // Keep old owner as Manager in memberships + $airline->users()->updateExistingPivot(auth()->id(), ['role' => 'Manager']); + + $airline->owner_user_id = $member->id; + $airline->save(); + }); + + // re-put the fresh airline into the session + session(['activeairline' => $airline]); + + activity() + ->causedBy(auth()->user()) + ->performedOn($airline) + ->withProperties(['level' => ActivityLevel::INFO]) + ->event('airline_ownership_transferred') + ->log('Ownership of ' . $airline->name . ' transferred to ' . $member->name); + + return back()->with('success', 'Ownership transferred to ' . $member->name . '. You are now a Manager.'); + } +} diff --git a/app/Http/Controllers/SetupController.php b/app/Http/Controllers/SetupController.php index ef25e4c..df81ad7 100644 --- a/app/Http/Controllers/SetupController.php +++ b/app/Http/Controllers/SetupController.php @@ -121,6 +121,8 @@ public function store(Request $request) $user->assignRole($superAdminRole); $airline->users()->attach($user, ['role' => 'Manager']); + $airline->owner_user_id = $user->id; + $airline->save(); auth()->login($user); session(['activeairline' => $airline]); diff --git a/app/Models/Airline.php b/app/Models/Airline.php index 2ac37c6..47caf01 100644 --- a/app/Models/Airline.php +++ b/app/Models/Airline.php @@ -27,6 +27,7 @@ class Airline extends Model 'active', 'require_pirep_review', 'location_continuity', + 'owner_user_id', ]; protected $casts = [ @@ -83,4 +84,14 @@ public function notams(): HasMany return $this->hasMany(Notam::class); } + public function owner(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(User::class, 'owner_user_id'); + } + + public function isOwnedBy(User $u): bool + { + return $this->owner_user_id !== null && $this->owner_user_id === $u->id; + } + } diff --git a/app/Models/User.php b/app/Models/User.php index 222462c..273943b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -103,14 +103,19 @@ public function hasAirlineRole(Airline $airline, string|array $roles): bool ->exists(); } + public function isOwnerOf(Airline $airline): bool + { + return $airline->owner_user_id === $this->id; + } + public function isManagerOf(Airline $airline): bool { - return $this->hasAirlineRole($airline, 'Manager'); + return $this->isOwnerOf($airline) || $this->hasAirlineRole($airline, 'Manager'); } public function canReviewFlightsFor(Airline $airline): bool { - return $this->hasAirlineRole($airline, ['Dispatcher', 'Manager']); + return $this->isOwnerOf($airline) || $this->hasAirlineRole($airline, ['Dispatcher', 'Manager']); } public function countNewNotifications() { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d6b3856..3ae1a62 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -148,7 +148,21 @@ public function boot(): void }); Gate::before(function ($user, $ability) { - return $user->hasRole('Super-Admin') ? true : null; + $activeAirline = session('activeairline'); + if ($activeAirline) { + if ($ability === 'add aircraft' || $ability === 'edit aircraft') { + return $user->isManagerOf($activeAirline); + } + if ($ability === 'review flight') { + return $user->canReviewFlightsFor($activeAirline); + } + } + + if ($user->hasRole('Super-Admin')) { + return true; + } + + return null; }); } } \ No newline at end of file diff --git a/database/migrations/2026_07_23_114200_add_owner_to_airlines_table.php b/database/migrations/2026_07_23_114200_add_owner_to_airlines_table.php new file mode 100644 index 0000000..6991341 --- /dev/null +++ b/database/migrations/2026_07_23_114200_add_owner_to_airlines_table.php @@ -0,0 +1,48 @@ +foreignId('owner_user_id')->nullable()->after('id')->constrained('users')->nullOnDelete(); + }); + + // Backfill + $airlines = DB::table('airlines')->get(); + foreach ($airlines as $airline) { + $ownerUserId = DB::table('airline_memberships') + ->where('airline_id', $airline->id) + ->where('role', 'Manager') + ->orderBy('created_at') + ->orderBy('id') + ->value('user_id'); + + if (!$ownerUserId) { + $ownerUserId = DB::table('airline_memberships') + ->where('airline_id', $airline->id) + ->orderBy('created_at') + ->orderBy('id') + ->value('user_id'); + } + + if ($ownerUserId) { + DB::table('airlines') + ->where('id', $airline->id) + ->update(['owner_user_id' => $ownerUserId]); + } + } + } + + public function down(): void + { + Schema::table('airlines', function (Blueprint $table) { + $table->dropConstrainedForeignId('owner_user_id'); + }); + } +}; diff --git a/database/seeders/AirlineMembershipSeeder.php b/database/seeders/AirlineMembershipSeeder.php index b7ad9dd..ba0448e 100644 --- a/database/seeders/AirlineMembershipSeeder.php +++ b/database/seeders/AirlineMembershipSeeder.php @@ -44,5 +44,30 @@ public function run(): void } } } + + // Set owners for all airlines + $airlines = DB::table('airlines')->get(); + foreach ($airlines as $airline) { + $ownerUserId = DB::table('airline_memberships') + ->where('airline_id', $airline->id) + ->where('role', 'Manager') + ->orderBy('created_at') + ->orderBy('id') + ->value('user_id'); + + if (!$ownerUserId) { + $ownerUserId = DB::table('airline_memberships') + ->where('airline_id', $airline->id) + ->orderBy('created_at') + ->orderBy('id') + ->value('user_id'); + } + + if ($ownerUserId) { + DB::table('airlines') + ->where('id', $airline->id) + ->update(['owner_user_id' => $ownerUserId]); + } + } } } diff --git a/resources/views/layouts/_navbar.blade.php b/resources/views/layouts/_navbar.blade.php index d4e7cfd..d3e20cb 100644 --- a/resources/views/layouts/_navbar.blade.php +++ b/resources/views/layouts/_navbar.blade.php @@ -58,7 +58,7 @@ @endphp @if($canReviewFlights || $isAirlineManager)