diff --git a/app/Http/Controllers/TrainerController.php b/app/Http/Controllers/TrainerController.php new file mode 100644 index 0000000..6322bce --- /dev/null +++ b/app/Http/Controllers/TrainerController.php @@ -0,0 +1,92 @@ +clients() + ->with(['workouts' => function ($q) use ($trainer) { + $q->where('assigned_by_trainer_id', $trainer->id)->latest(); + }]) + ->orderBy('name') + ->get(); + + return view('trainer.clients', compact('clients')); + } + + public function addClient(Request $request): RedirectResponse + { + $trainer = Auth::user(); + $data = $request->validate([ + 'email' => ['required', 'email', 'exists:users,email'], + ]); + + $client = User::where('email', $data['email'])->firstOrFail(); + + if ($client->id === $trainer->id) { + return back()->withErrors(['email' => 'You cannot add yourself as a client.']); + } + + if ($trainer->clients()->where('client_id', $client->id)->exists()) { + return back()->withErrors(['email' => 'This person is already one of your clients.']); + } + + $trainer->clients()->attach($client->id); + + return back()->with('status', 'Client added'); + } + + public function removeClient(User $client): RedirectResponse + { + Auth::user()->clients()->detach($client->id); + + return back()->with('status', 'Client removed'); + } + + public function createWorkout(User $client): View + { + $this->authorizeClient($client); + + return view('trainer.assign-workout', compact('client')); + } + + public function storeWorkout(Request $request, User $client): RedirectResponse + { + $this->authorizeClient($client); + + $data = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'notes' => ['nullable', 'string'], + 'estimated_minutes' => ['nullable', 'integer', 'min:1', 'max:600'], + ]); + + $workout = Workout::create([ + 'user_id' => $client->id, + 'assigned_by_trainer_id' => Auth::id(), + 'title' => $data['title'], + 'notes' => $data['notes'] ?? null, + 'estimated_minutes' => $data['estimated_minutes'] ?? null, + ]); + + return redirect()->route('trainer.clients')->with('status', "Workout assigned to {$client->name}"); + } + + protected function authorizeClient(User $client): void + { + if (!Auth::user()->clients()->where('client_id', $client->id)->exists()) { + abort(403); + } + } +} diff --git a/app/Http/Controllers/WorkoutController.php b/app/Http/Controllers/WorkoutController.php index 9718847..cb6a8c5 100644 --- a/app/Http/Controllers/WorkoutController.php +++ b/app/Http/Controllers/WorkoutController.php @@ -17,7 +17,7 @@ class WorkoutController extends Controller public function index() { - $workouts = Auth::user()->workouts()->latest()->paginate(10); + $workouts = Auth::user()->workouts()->with('assignedByTrainer')->latest()->paginate(10); return view('workouts.index', compact('workouts')); } @@ -41,7 +41,7 @@ class WorkoutController extends Controller public function show(Workout $workout) { $this->authorizeWorkout($workout); - $workout->load(['items.exercise']); + $workout->load(['items.exercise', 'assignedByTrainer']); $exercises = Exercise::orderBy('name')->limit(20)->get(['id','name','slug']); return view('workouts.show', compact('workout', 'exercises')); } @@ -112,15 +112,25 @@ class WorkoutController extends Controller public function destroy(Workout $workout) { - $this->authorizeWorkout($workout); + // Deletion stays strictly owner-only, even for a trainer-assigned workout. + if ($workout->user_id !== Auth::id()) { + abort(403); + } $workout->delete(); return redirect()->route('workouts.index')->with('status', 'Workout deleted'); } + // Allows the owner, or the trainer who assigned this workout, to view and edit it. + // Session/completion data is intentionally not exposed to the trainer here - only + // the workout structure they authored. protected function authorizeWorkout(Workout $workout): void { - if ($workout->user_id !== Auth::id()) { - abort(403); + if ($workout->user_id === Auth::id()) { + return; } + if ($workout->assigned_by_trainer_id && $workout->assigned_by_trainer_id === Auth::id()) { + return; + } + abort(403); } } diff --git a/app/Http/Middleware/Trainer.php b/app/Http/Middleware/Trainer.php new file mode 100644 index 0000000..25f48ab --- /dev/null +++ b/app/Http/Middleware/Trainer.php @@ -0,0 +1,25 @@ +is_trainer) { + abort(403); + } + return $next($request); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 9b3f514..f317748 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -74,6 +74,22 @@ class User extends Authenticatable return $this->hasMany(Program::class); } + // Trainer/client relationships + public function clients() + { + return $this->belongsToMany(User::class, 'trainer_clients', 'trainer_id', 'client_id')->withTimestamps(); + } + + public function trainers() + { + return $this->belongsToMany(User::class, 'trainer_clients', 'client_id', 'trainer_id')->withTimestamps(); + } + + public function assignedWorkouts() + { + return $this->hasMany(Workout::class, 'assigned_by_trainer_id'); + } + public function favoriteExercises() { return $this->belongsToMany(Exercise::class, 'exercise_favorites')->withTimestamps(); diff --git a/app/Models/Workout.php b/app/Models/Workout.php index ec79d2a..ba82263 100644 --- a/app/Models/Workout.php +++ b/app/Models/Workout.php @@ -20,6 +20,7 @@ class Workout extends Model 'program_id', 'week_number', 'day_number', + 'assigned_by_trainer_id', ]; protected $casts = [ @@ -38,6 +39,11 @@ class Workout extends Model return $this->belongsTo(Program::class); } + public function assignedByTrainer(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_by_trainer_id'); + } + public function items(): HasMany { return $this->hasMany(WorkoutExercise::class)->orderBy('position'); diff --git a/bootstrap/app.php b/bootstrap/app.php index 8e56a9d..226602a 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -17,6 +17,7 @@ return Application::configure(basePath: dirname(__DIR__)) ]); $middleware->alias([ 'admin' => \App\Http\Middleware\Admin::class, + 'trainer' => \App\Http\Middleware\Trainer::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/database/migrations/2026_08_10_092220_add_assigned_by_trainer_to_workouts_table.php b/database/migrations/2026_08_10_092220_add_assigned_by_trainer_to_workouts_table.php new file mode 100644 index 0000000..8375d1b --- /dev/null +++ b/database/migrations/2026_08_10_092220_add_assigned_by_trainer_to_workouts_table.php @@ -0,0 +1,28 @@ +foreignId('assigned_by_trainer_id')->nullable()->after('day_number')->constrained('users')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('workouts', function (Blueprint $table) { + $table->dropConstrainedForeignId('assigned_by_trainer_id'); + }); + } +}; diff --git a/database/migrations/2026_08_10_092220_create_trainer_clients_table.php b/database/migrations/2026_08_10_092220_create_trainer_clients_table.php new file mode 100644 index 0000000..0ce5d35 --- /dev/null +++ b/database/migrations/2026_08_10_092220_create_trainer_clients_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('trainer_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('client_id')->constrained('users')->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['trainer_id', 'client_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('trainer_clients'); + } +}; diff --git a/lang/en/app.php b/lang/en/app.php index 1c69435..dcdb373 100644 --- a/lang/en/app.php +++ b/lang/en/app.php @@ -9,6 +9,7 @@ return [ 'build_workout' => 'Build a Workout', 'dashboard' => 'Dashboard', 'programs' => 'Programs', + 'clients' => 'Clients', 'admin' => 'Admin', 'profile' => 'Profile', 'logout' => 'Log Out', diff --git a/lang/ro/app.php b/lang/ro/app.php index 4e8de8a..5084f64 100644 --- a/lang/ro/app.php +++ b/lang/ro/app.php @@ -9,6 +9,7 @@ return [ 'build_workout' => 'Creează un antrenament', 'dashboard' => 'Tabloul de bord', 'programs' => 'Programe', + 'clients' => 'Clienți', 'admin' => 'Admin', 'profile' => 'Profil', 'logout' => 'Deconectare', diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index 2b8b039..56021d5 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -20,6 +20,9 @@ @auth {{ __('app.nav.dashboard') }} {{ __('app.nav.programs') }} + @if(Auth::user()->is_trainer) + {{ __('app.nav.clients') }} + @endif @if(Auth::user()->is_admin) {{ __('app.nav.admin') }} @endif @@ -100,6 +103,9 @@ @auth {{ __('app.nav.dashboard') }} {{ __('app.nav.programs') }} + @if(Auth::user()->is_trainer) + {{ __('app.nav.clients') }} + @endif @if(Auth::user()->is_admin) {{ __('app.nav.admin') }} @endif diff --git a/resources/views/trainer/assign-workout.blade.php b/resources/views/trainer/assign-workout.blade.php new file mode 100644 index 0000000..27a8f01 --- /dev/null +++ b/resources/views/trainer/assign-workout.blade.php @@ -0,0 +1,37 @@ + +
+
+ ← My Clients +

Assign a Workout to {{ $client->name }}

+

You'll be able to add exercises to it right after creating it.

+
+
+ +
+ @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif +
+ @csrf +
+ + +
+
+ + +
+
+ + +
+ +
+
+
diff --git a/resources/views/trainer/clients.blade.php b/resources/views/trainer/clients.blade.php new file mode 100644 index 0000000..453c632 --- /dev/null +++ b/resources/views/trainer/clients.blade.php @@ -0,0 +1,70 @@ + +
+
+

My Clients

+

Add clients by the email they registered with, and assign them workouts.

+
+
+ +
+ @if(session('status')) +
{{ session('status') }}
+ @endif + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+ @csrf +
+ + +
+ +
+ + @if($clients->isEmpty()) +
No clients yet. Add one by email above.
+ @else +
+ @foreach($clients as $client) +
+
+
+

{{ $client->name }}

+

{{ $client->email }}

+
+
+ Assign workout +
+ @csrf + @method('DELETE') + +
+
+
+ + @if($client->workouts->isEmpty()) +

No workouts assigned yet.

+ @else +
    + @foreach($client->workouts as $workout) +
  • + {{ $workout->title }} + {{ $workout->created_at->format('M j, Y') }} +
  • + @endforeach +
+ @endif +
+ @endforeach +
+ @endif +
+
diff --git a/resources/views/workouts/index.blade.php b/resources/views/workouts/index.blade.php index 7cd2a38..704780f 100644 --- a/resources/views/workouts/index.blade.php +++ b/resources/views/workouts/index.blade.php @@ -23,6 +23,9 @@

{{ $workout->title }}

+ @if($workout->assignedByTrainer) + Assigned by {{ $workout->assignedByTrainer->name }} + @endif @if($workout->estimated_minutes)

~ {{ $workout->estimated_minutes }} min

@endif diff --git a/resources/views/workouts/show.blade.php b/resources/views/workouts/show.blade.php index 325221a..f6b92cd 100644 --- a/resources/views/workouts/show.blade.php +++ b/resources/views/workouts/show.blade.php @@ -23,6 +23,9 @@ @endauth
+ @if($workout->assignedByTrainer) + Assigned by {{ $workout->assignedByTrainer->name }} + @endif

@if($workout->estimated_minutes) ~ {{ $workout->estimated_minutes }} min · @endif {{ $workout->items->count() }} exercises

diff --git a/routes/web.php b/routes/web.php index ffbb21d..01e9161 100644 --- a/routes/web.php +++ b/routes/web.php @@ -60,6 +60,15 @@ Route::middleware('auth')->prefix('/workouts')->group(function () { Route::post('/{workout}/sessions', [\App\Http\Controllers\WorkoutSessionController::class, 'store'])->name('sessions.store'); }); +// Trainer: manage clients and assign workouts to them +Route::middleware(['auth', 'trainer'])->prefix('/trainer')->group(function () { + Route::get('/clients', [\App\Http\Controllers\TrainerController::class, 'clients'])->name('trainer.clients'); + Route::post('/clients', [\App\Http\Controllers\TrainerController::class, 'addClient'])->name('trainer.clients.add'); + Route::delete('/clients/{client}', [\App\Http\Controllers\TrainerController::class, 'removeClient'])->name('trainer.clients.remove'); + Route::get('/clients/{client}/workouts/create', [\App\Http\Controllers\TrainerController::class, 'createWorkout'])->name('trainer.workouts.create'); + Route::post('/clients/{client}/workouts', [\App\Http\Controllers\TrainerController::class, 'storeWorkout'])->name('trainer.workouts.store'); +}); + Route::middleware('auth')->prefix('/programs')->group(function () { Route::get('/', [\App\Http\Controllers\ProgramController::class, 'index'])->name('programs.index'); Route::get('/create', [\App\Http\Controllers\ProgramController::class, 'create'])->name('programs.create'); diff --git a/tests/Feature/TrainerTest.php b/tests/Feature/TrainerTest.php new file mode 100644 index 0000000..c5665da --- /dev/null +++ b/tests/Feature/TrainerTest.php @@ -0,0 +1,199 @@ +create(); + $trainer->is_trainer = true; + $trainer->save(); + + return $trainer; + } + + public function test_guest_is_redirected_to_login(): void + { + $response = $this->get(route('trainer.clients')); + + $response->assertRedirect(route('login')); + } + + public function test_non_trainer_is_forbidden(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get(route('trainer.clients')); + + $response->assertForbidden(); + } + + public function test_trainer_can_add_a_client_by_email(): void + { + $trainer = $this->trainer(); + $client = User::factory()->create(['email' => 'client@example.com']); + + $response = $this->actingAs($trainer)->post(route('trainer.clients.add'), [ + 'email' => 'client@example.com', + ]); + + $response->assertRedirect(); + $this->assertTrue($trainer->clients()->where('client_id', $client->id)->exists()); + } + + public function test_trainer_cannot_add_themselves_as_a_client(): void + { + $trainer = $this->trainer(); + + $response = $this->actingAs($trainer)->post(route('trainer.clients.add'), [ + 'email' => $trainer->email, + ]); + + $response->assertSessionHasErrors('email'); + $this->assertFalse($trainer->clients()->where('client_id', $trainer->id)->exists()); + } + + public function test_trainer_cannot_add_the_same_client_twice(): void + { + $trainer = $this->trainer(); + $client = User::factory()->create(); + $trainer->clients()->attach($client->id); + + $response = $this->actingAs($trainer)->post(route('trainer.clients.add'), [ + 'email' => $client->email, + ]); + + $response->assertSessionHasErrors('email'); + $this->assertEquals(1, $trainer->clients()->where('client_id', $client->id)->count()); + } + + public function test_trainer_cannot_add_a_client_with_an_unknown_email(): void + { + $trainer = $this->trainer(); + + $response = $this->actingAs($trainer)->post(route('trainer.clients.add'), [ + 'email' => 'nobody@example.com', + ]); + + $response->assertSessionHasErrors('email'); + } + + public function test_trainer_can_remove_a_client(): void + { + $trainer = $this->trainer(); + $client = User::factory()->create(); + $trainer->clients()->attach($client->id); + + $response = $this->actingAs($trainer)->delete(route('trainer.clients.remove', $client)); + + $response->assertRedirect(); + $this->assertFalse($trainer->clients()->where('client_id', $client->id)->exists()); + } + + public function test_trainer_can_assign_a_workout_to_their_client(): void + { + $trainer = $this->trainer(); + $client = User::factory()->create(); + $trainer->clients()->attach($client->id); + + $response = $this->actingAs($trainer)->post(route('trainer.workouts.store', $client), [ + 'title' => 'Week 1 Push Day', + ]); + + $response->assertRedirect(route('trainer.clients')); + $this->assertDatabaseHas('workouts', [ + 'user_id' => $client->id, + 'assigned_by_trainer_id' => $trainer->id, + 'title' => 'Week 1 Push Day', + ]); + } + + public function test_client_roster_lists_workouts_assigned_to_each_client(): void + { + $trainer = $this->trainer(); + $client = User::factory()->create(['name' => 'Jamie Client']); + $trainer->clients()->attach($client->id); + Workout::factory()->create([ + 'user_id' => $client->id, + 'assigned_by_trainer_id' => $trainer->id, + 'title' => 'Week 1 Push Day', + ]); + // A workout the client made for themselves should not show up as "assigned". + Workout::factory()->create(['user_id' => $client->id, 'title' => 'My Own Workout']); + + $response = $this->actingAs($trainer)->get(route('trainer.clients')); + + $response->assertOk(); + $response->assertSee('Week 1 Push Day'); + $response->assertDontSee('My Own Workout'); + } + + public function test_trainer_cannot_assign_a_workout_to_a_non_client(): void + { + $trainer = $this->trainer(); + $stranger = User::factory()->create(); + + $response = $this->actingAs($trainer)->post(route('trainer.workouts.store', $stranger), [ + 'title' => 'Week 1 Push Day', + ]); + + $response->assertForbidden(); + } + + public function test_client_can_manage_their_assigned_workout(): void + { + $trainer = $this->trainer(); + $client = User::factory()->create(); + $trainer->clients()->attach($client->id); + $workout = Workout::factory()->create(['user_id' => $client->id, 'assigned_by_trainer_id' => $trainer->id]); + + $response = $this->actingAs($client)->get(route('workouts.show', $workout)); + + $response->assertOk(); + } + + public function test_assigning_trainer_can_view_and_edit_the_assigned_workout(): void + { + $trainer = $this->trainer(); + $client = User::factory()->create(); + $trainer->clients()->attach($client->id); + $workout = Workout::factory()->create(['user_id' => $client->id, 'assigned_by_trainer_id' => $trainer->id]); + + $response = $this->actingAs($trainer)->get(route('workouts.show', $workout)); + $response->assertOk(); + } + + public function test_assigning_trainer_cannot_delete_the_assigned_workout(): void + { + $trainer = $this->trainer(); + $client = User::factory()->create(); + $trainer->clients()->attach($client->id); + $workout = Workout::factory()->create(['user_id' => $client->id, 'assigned_by_trainer_id' => $trainer->id]); + + $response = $this->actingAs($trainer)->delete(route('workouts.destroy', $workout)); + + $response->assertForbidden(); + $this->assertDatabaseHas('workouts', ['id' => $workout->id]); + } + + public function test_unrelated_trainer_cannot_view_someone_elses_assigned_workout(): void + { + $trainer = $this->trainer(); + $otherTrainer = $this->trainer(); + $client = User::factory()->create(); + $trainer->clients()->attach($client->id); + $workout = Workout::factory()->create(['user_id' => $client->id, 'assigned_by_trainer_id' => $trainer->id]); + + $response = $this->actingAs($otherTrainer)->get(route('workouts.show', $workout)); + + $response->assertForbidden(); + } +}