From 7c501d1e9feba3b4bbc5a848686e4ed03f73e861 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 10 Aug 2026 19:18:25 +0300 Subject: [PATCH] progress --- app/Http/Controllers/Api/AuthController.php | 97 +++++ .../Controllers/Api/DashboardController.php | 72 ++++ app/Http/Controllers/Api/DeviceController.php | 36 ++ .../Api/EmailVerificationController.php | 21 + .../Api/ExerciseAliasController.php | 36 ++ .../Controllers/Api/ExerciseController.php | 121 ++++++ .../Controllers/Api/FavoriteController.php | 52 +++ .../Api/PasswordResetController.php | 58 +++ .../Controllers/Api/ProfileController.php | 44 ++ .../Controllers/Api/ProgramController.php | 103 +++++ .../Controllers/Api/TrainerController.php | 90 +++++ .../Controllers/Api/WeightLogController.php | 48 +++ .../Controllers/Api/WorkoutController.php | 209 ++++++++++ .../Api/WorkoutSessionController.php | 105 +++++ .../Controllers/Api/YogaPoseController.php | 80 ++++ .../Auth/VerifyEmailController.php | 41 +- app/Http/Resources/ExerciseResource.php | 37 ++ app/Http/Resources/ProgramResource.php | 23 ++ app/Http/Resources/UserResource.php | 30 ++ app/Http/Resources/WeightLogResource.php | 18 + .../Resources/WorkoutExerciseResource.php | 24 ++ app/Http/Resources/WorkoutResource.php | 32 ++ .../Resources/WorkoutSessionItemResource.php | 23 ++ app/Http/Resources/WorkoutSessionResource.php | 22 + app/Http/Resources/YogaPoseResource.php | 33 ++ app/Models/User.php | 3 +- bootstrap/app.php | 1 + composer.json | 2 + composer.lock | 380 +++++++++++++++--- config/sanctum.php | 87 ++++ config/scramble.php | 173 ++++++++ ...01_create_personal_access_tokens_table.php | 33 ++ routes/api.php | 5 + routes/api_v1.php | 112 ++++++ routes/auth.php | 12 +- 35 files changed, 2185 insertions(+), 78 deletions(-) create mode 100644 app/Http/Controllers/Api/AuthController.php create mode 100644 app/Http/Controllers/Api/DashboardController.php create mode 100644 app/Http/Controllers/Api/DeviceController.php create mode 100644 app/Http/Controllers/Api/EmailVerificationController.php create mode 100644 app/Http/Controllers/Api/ExerciseAliasController.php create mode 100644 app/Http/Controllers/Api/ExerciseController.php create mode 100644 app/Http/Controllers/Api/FavoriteController.php create mode 100644 app/Http/Controllers/Api/PasswordResetController.php create mode 100644 app/Http/Controllers/Api/ProfileController.php create mode 100644 app/Http/Controllers/Api/ProgramController.php create mode 100644 app/Http/Controllers/Api/TrainerController.php create mode 100644 app/Http/Controllers/Api/WeightLogController.php create mode 100644 app/Http/Controllers/Api/WorkoutController.php create mode 100644 app/Http/Controllers/Api/WorkoutSessionController.php create mode 100644 app/Http/Controllers/Api/YogaPoseController.php create mode 100644 app/Http/Resources/ExerciseResource.php create mode 100644 app/Http/Resources/ProgramResource.php create mode 100644 app/Http/Resources/UserResource.php create mode 100644 app/Http/Resources/WeightLogResource.php create mode 100644 app/Http/Resources/WorkoutExerciseResource.php create mode 100644 app/Http/Resources/WorkoutResource.php create mode 100644 app/Http/Resources/WorkoutSessionItemResource.php create mode 100644 app/Http/Resources/WorkoutSessionResource.php create mode 100644 app/Http/Resources/YogaPoseResource.php create mode 100644 config/sanctum.php create mode 100644 config/scramble.php create mode 100644 database/migrations/2026_08_10_094801_create_personal_access_tokens_table.php create mode 100644 routes/api.php create mode 100644 routes/api_v1.php diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php new file mode 100644 index 0000000..0ec007e --- /dev/null +++ b/app/Http/Controllers/Api/AuthController.php @@ -0,0 +1,97 @@ +validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class], + 'password' => ['required', 'confirmed', Rules\Password::defaults()], + 'is_trainer' => ['nullable', 'boolean'], + 'age' => ['nullable', 'integer', 'min:5', 'max:120'], + 'weight_kg' => ['nullable', 'numeric', 'min:20', 'max:400'], + 'height_cm' => ['nullable', 'numeric', 'min:80', 'max:250'], + 'fitness_level' => ['nullable', 'in:beginner,intermediate,advanced'], + 'goal' => ['nullable', 'string', 'max:50'], + 'health_notes' => ['nullable', 'string', 'max:65535'], + 'available_equipment' => ['nullable', 'string', 'max:65535'], + 'days_per_week' => ['nullable', 'integer', 'min:1', 'max:7'], + 'minutes_per_session' => ['nullable', 'integer', 'min:5', 'max:600'], + 'device_name' => ['required', 'string', 'max:255'], + ]); + + $user = User::create([ + 'name' => $data['name'], + 'email' => $data['email'], + 'password' => Hash::make($data['password']), + 'is_trainer' => (bool) ($data['is_trainer'] ?? false), + 'age' => $data['age'] ?? null, + 'weight_kg' => $data['weight_kg'] ?? null, + 'height_cm' => $data['height_cm'] ?? null, + 'fitness_level' => $data['fitness_level'] ?? null, + 'goal' => $data['goal'] ?? null, + 'health_notes' => $data['health_notes'] ?? null, + 'available_equipment' => $data['available_equipment'] ?? null, + 'days_per_week' => $data['days_per_week'] ?? null, + 'minutes_per_session' => $data['minutes_per_session'] ?? null, + ]); + + event(new Registered($user)); + + $token = $user->createToken($data['device_name'])->plainTextToken; + + return response()->json([ + 'user' => new UserResource($user), + 'token' => $token, + ], 201); + } + + public function login(Request $request): JsonResponse + { + $data = $request->validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + 'device_name' => ['required', 'string', 'max:255'], + ]); + + $user = User::where('email', $data['email'])->first(); + + if (!$user || !Hash::check($data['password'], $user->password)) { + throw ValidationException::withMessages([ + 'email' => ['The provided credentials are incorrect.'], + ]); + } + + $token = $user->createToken($data['device_name'])->plainTextToken; + + return response()->json([ + 'user' => new UserResource($user), + 'token' => $token, + ]); + } + + public function logout(Request $request): JsonResponse + { + $request->user()->currentAccessToken()->delete(); + + return response()->json(['message' => 'Logged out']); + } + + public function me(Request $request): JsonResponse + { + return response()->json(['user' => new UserResource($request->user())]); + } +} diff --git a/app/Http/Controllers/Api/DashboardController.php b/app/Http/Controllers/Api/DashboardController.php new file mode 100644 index 0000000..3aa2c8e --- /dev/null +++ b/app/Http/Controllers/Api/DashboardController.php @@ -0,0 +1,72 @@ +workouts()->count(); + $sessionsQuery = WorkoutSession::where('user_id', $user->id); + $sessionsCount = (clone $sessionsQuery)->count(); + $completedSessions = (clone $sessionsQuery)->whereNotNull('completed_at')->count(); + $completionRate = $sessionsCount > 0 ? round(($completedSessions / $sessionsCount) * 100) : 0; + + $recentSessions = (clone $sessionsQuery) + ->latest('started_at') + ->latest('id') + ->with(['workout:id,title', 'items:id,session_id,done']) + ->limit(5) + ->get() + ->map(function ($s) { + $total = $s->items->count(); + $done = $s->items->where('done', true)->count(); + $pct = $total > 0 ? (int) round(($done / $total) * 100) : 0; + + return [ + 'id' => $s->id, + 'workout_title' => optional($s->workout)->title ?? 'Workout', + 'started_at' => $s->started_at, + 'completed_at' => $s->completed_at, + 'done' => $done, + 'total' => $total, + 'pct' => $pct, + ]; + }); + + $since = now()->subDays(29)->startOfDay(); + $dailyActivity = (clone $sessionsQuery) + ->where('created_at', '>=', $since) + ->get(['id', 'created_at']) + ->groupBy(fn ($s) => $s->created_at->format('Y-m-d')) + ->map->count(); + + $weightLogs = $user->weightLogs()->orderBy('logged_at')->get(['id', 'weight_kg', 'logged_at']); + $weightChange = $weightLogs->count() >= 2 + ? round($weightLogs->last()->weight_kg - $weightLogs->first()->weight_kg, 2) + : null; + + $gamification = Gamification::forUser($user); + + return response()->json([ + 'workouts_count' => $workoutsCount, + 'sessions_count' => $sessionsCount, + 'completed_sessions' => $completedSessions, + 'completion_rate' => $completionRate, + 'recent_sessions' => $recentSessions, + 'daily_activity' => $dailyActivity, + 'weight_logs' => WeightLogResource::collection($weightLogs), + 'weight_change' => $weightChange, + 'gamification' => $gamification, + ]); + } +} diff --git a/app/Http/Controllers/Api/DeviceController.php b/app/Http/Controllers/Api/DeviceController.php new file mode 100644 index 0000000..dd2ed4e --- /dev/null +++ b/app/Http/Controllers/Api/DeviceController.php @@ -0,0 +1,36 @@ +user()->currentAccessToken()?->id; + + $tokens = $request->user()->tokens()->orderByDesc('last_used_at')->get()->map(fn ($token) => [ + 'id' => $token->id, + 'name' => $token->name, + 'is_current_device' => $token->id === $currentTokenId, + 'last_used_at' => $token->last_used_at, + 'created_at' => $token->created_at, + ]); + + return response()->json(['data' => $tokens]); + } + + public function destroy(Request $request, int $tokenId): JsonResponse + { + $deleted = $request->user()->tokens()->where('id', $tokenId)->delete(); + + if (!$deleted) { + abort(404); + } + + return response()->json(['message' => 'Device signed out']); + } +} diff --git a/app/Http/Controllers/Api/EmailVerificationController.php b/app/Http/Controllers/Api/EmailVerificationController.php new file mode 100644 index 0000000..8079ed6 --- /dev/null +++ b/app/Http/Controllers/Api/EmailVerificationController.php @@ -0,0 +1,21 @@ +user()->hasVerifiedEmail()) { + return response()->json(['message' => 'Email already verified.']); + } + + $request->user()->sendEmailVerificationNotification(); + + return response()->json(['message' => 'Verification link sent.']); + } +} diff --git a/app/Http/Controllers/Api/ExerciseAliasController.php b/app/Http/Controllers/Api/ExerciseAliasController.php new file mode 100644 index 0000000..e2830f2 --- /dev/null +++ b/app/Http/Controllers/Api/ExerciseAliasController.php @@ -0,0 +1,36 @@ +validate([ + 'name' => ['nullable', 'string', 'max:255'], + ]); + + $name = trim((string) ($data['name'] ?? '')); + $userId = Auth::id(); + + if ($name === '') { + ExerciseAlias::where('user_id', $userId)->where('exercise_id', $exercise->id)->delete(); + + return response()->json(['message' => __('app.exercises.rename_cleared'), 'name' => null]); + } + + ExerciseAlias::updateOrCreate( + ['user_id' => $userId, 'exercise_id' => $exercise->id], + ['name' => $name] + ); + + return response()->json(['message' => __('app.exercises.renamed_success'), 'name' => $name]); + } +} diff --git a/app/Http/Controllers/Api/ExerciseController.php b/app/Http/Controllers/Api/ExerciseController.php new file mode 100644 index 0000000..db7d45b --- /dev/null +++ b/app/Http/Controllers/Api/ExerciseController.php @@ -0,0 +1,121 @@ +query('q', '')); + $bodypart = (string) $request->query('bodypart', ''); + $muscle = (string) $request->query('muscle', ''); + $equipment = (string) $request->query('equipment', ''); + $perPage = (int) ($request->query('per_page', 12)); + if ($perPage < 6 || $perPage > 60) { $perPage = 12; } + + $query = Exercise::query() + ->with(['bodyParts:id,name,slug', 'targetMuscles:id,name,slug', 'secondaryMuscles:id,name,slug', 'equipments:id,name,slug'] + + (Auth::check() ? ['aliases' => function ($rel) { $rel->where('user_id', Auth::id()); }] : [])) + ->when($q !== '', function ($qBuilder) use ($q) { + $qBuilder->where(function ($sub) use ($q) { + $sub->where('name', 'like', "%{$q}%") + ->orWhere('instructions', 'like', "%{$q}%"); + }); + }) + ->when($bodypart !== '', function ($qBuilder) use ($bodypart) { + $qBuilder->whereHas('bodyParts', function ($rel) use ($bodypart) { + $rel->where('slug', $bodypart); + }); + }) + ->when($muscle !== '', function ($qBuilder) use ($muscle) { + $qBuilder->where(function ($sub) use ($muscle) { + $sub->whereHas('targetMuscles', function ($rel) use ($muscle) { $rel->where('slug', $muscle); }) + ->orWhereHas('secondaryMuscles', function ($rel) use ($muscle) { $rel->where('slug', $muscle); }); + }); + }) + ->when($equipment !== '', function ($qBuilder) use ($equipment) { + $qBuilder->whereHas('equipments', function ($rel) use ($equipment) { + $rel->where('slug', $equipment); + }); + }) + ->orderBy('name'); + + $exercises = $query->paginate($perPage)->withQueryString(); + + return response()->json([ + 'data' => ExerciseResource::collection($exercises->items()), + 'meta' => [ + 'current_page' => $exercises->currentPage(), + 'last_page' => $exercises->lastPage(), + 'per_page' => $exercises->perPage(), + 'total' => $exercises->total(), + ], + 'filters' => [ + 'body_parts' => BodyPart::orderBy('name')->get(['id', 'name', 'slug']), + 'muscles' => Muscle::orderBy('name')->get(['id', 'name', 'slug']), + 'equipments' => Equipment::orderBy('name')->get(['id', 'name', 'slug']), + ], + ]); + } + + public function show(Exercise $exercise): JsonResponse + { + $exercise->load(['bodyParts:id,name,slug', 'targetMuscles:id,name,slug', 'secondaryMuscles:id,name,slug', 'equipments:id,name,slug'] + + (Auth::check() ? ['aliases' => function ($rel) { $rel->where('user_id', Auth::id()); }] : [])); + + $targetIds = $exercise->targetMuscles->pluck('id')->all(); + $bodyPartIds = $exercise->bodyParts->pluck('id')->all(); + $equipmentIds = $exercise->equipments->pluck('id')->all(); + + $similarExercises = Exercise::query() + ->with(['bodyParts:id,name,slug', 'targetMuscles:id,name,slug', 'equipments:id,name,slug'] + + (Auth::check() ? ['aliases' => function ($rel) { $rel->where('user_id', Auth::id()); }] : [])) + ->where('id', '!=', $exercise->id) + ->where(function ($q) use ($targetIds, $bodyPartIds, $equipmentIds) { + if (!empty($targetIds)) { + $q->orWhereHas('targetMuscles', fn ($rel) => $rel->whereIn('id', $targetIds)); + } + if (!empty($bodyPartIds)) { + $q->orWhereHas('bodyParts', fn ($rel) => $rel->whereIn('id', $bodyPartIds)); + } + if (!empty($equipmentIds)) { + $q->orWhereHas('equipments', fn ($rel) => $rel->whereIn('id', $equipmentIds)); + } + }) + ->orderBy('name') + ->limit(9) + ->get(); + + $personalBest = null; + if (Auth::check()) { + $personalBest = WorkoutSessionItem::query() + ->whereHas('workoutExercise', fn ($q) => $q->where('exercise_id', $exercise->id)) + ->whereHas('session', fn ($q) => $q->where('user_id', Auth::id())) + ->where('done', true) + ->whereNotNull('actual_weight') + ->orderByDesc('actual_weight') + ->orderByDesc('actual_reps') + ->first(); + } + + return response()->json([ + 'exercise' => new ExerciseResource($exercise), + 'similar_exercises' => ExerciseResource::collection($similarExercises), + 'personal_best' => $personalBest ? [ + 'actual_weight' => $personalBest->actual_weight !== null ? (float) $personalBest->actual_weight : null, + 'actual_reps' => $personalBest->actual_reps, + ] : null, + ]); + } +} diff --git a/app/Http/Controllers/Api/FavoriteController.php b/app/Http/Controllers/Api/FavoriteController.php new file mode 100644 index 0000000..79f1062 --- /dev/null +++ b/app/Http/Controllers/Api/FavoriteController.php @@ -0,0 +1,52 @@ +favoriteExercises()->where('exercise_id', $exercise->id)->exists(); + if ($exists) { + $user->favoriteExercises()->detach($exercise->id); + } else { + $user->favoriteExercises()->attach($exercise->id); + } + + return response()->json(['favorited' => !$exists]); + } + + public function toggleWorkout(Workout $workout): JsonResponse + { + $user = Auth::user(); + $exists = $user->favoriteWorkouts()->where('workout_id', $workout->id)->exists(); + if ($exists) { + $user->favoriteWorkouts()->detach($workout->id); + } else { + $user->favoriteWorkouts()->attach($workout->id); + } + + return response()->json(['favorited' => !$exists]); + } + + public function toggleYogaPose(YogaPose $pose): JsonResponse + { + $user = Auth::user(); + $exists = $user->favoriteYogaPoses()->where('yoga_pose_id', $pose->id)->exists(); + if ($exists) { + $user->favoriteYogaPoses()->detach($pose->id); + } else { + $user->favoriteYogaPoses()->attach($pose->id); + } + + return response()->json(['favorited' => !$exists]); + } +} diff --git a/app/Http/Controllers/Api/PasswordResetController.php b/app/Http/Controllers/Api/PasswordResetController.php new file mode 100644 index 0000000..3f0b000 --- /dev/null +++ b/app/Http/Controllers/Api/PasswordResetController.php @@ -0,0 +1,58 @@ +validate([ + 'email' => ['required', 'email'], + ]); + + $status = Password::sendResetLink($request->only('email')); + + return $status === Password::RESET_LINK_SENT + ? response()->json(['message' => __($status)]) + : response()->json(['message' => __($status)], 422); + } + + public function resetPassword(Request $request): JsonResponse + { + $request->validate([ + 'token' => ['required'], + 'email' => ['required', 'email'], + 'password' => ['required', 'confirmed', Rules\Password::defaults()], + ]); + + $status = Password::reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function (User $user) use ($request) { + $user->forceFill([ + 'password' => Hash::make($request->password), + 'remember_token' => Str::random(60), + ])->save(); + + // A password reset often means "I lost my phone" - revoke every + // existing API token rather than leaving old devices signed in. + $user->tokens()->delete(); + + event(new PasswordReset($user)); + } + ); + + return $status === Password::PASSWORD_RESET + ? response()->json(['message' => __($status)]) + : response()->json(['message' => __($status)], 422); + } +} diff --git a/app/Http/Controllers/Api/ProfileController.php b/app/Http/Controllers/Api/ProfileController.php new file mode 100644 index 0000000..7b5776e --- /dev/null +++ b/app/Http/Controllers/Api/ProfileController.php @@ -0,0 +1,44 @@ +json(['user' => new UserResource($request->user())]); + } + + public function update(ProfileUpdateRequest $request): JsonResponse + { + $request->user()->fill($request->validated()); + + if ($request->user()->isDirty('email')) { + $request->user()->email_verified_at = null; + } + + $request->user()->save(); + + return response()->json(['user' => new UserResource($request->user())]); + } + + public function destroy(Request $request): JsonResponse + { + $request->validate([ + 'password' => ['required', 'current_password'], + ]); + + $user = $request->user(); + $user->tokens()->delete(); + $user->delete(); + + return response()->json(['message' => 'Account deleted']); + } +} diff --git a/app/Http/Controllers/Api/ProgramController.php b/app/Http/Controllers/Api/ProgramController.php new file mode 100644 index 0000000..827db51 --- /dev/null +++ b/app/Http/Controllers/Api/ProgramController.php @@ -0,0 +1,103 @@ +programs()->withCount('workouts')->latest()->paginate(10); + + return response()->json([ + 'data' => ProgramResource::collection($programs->items()), + 'meta' => [ + 'current_page' => $programs->currentPage(), + 'last_page' => $programs->lastPage(), + 'per_page' => $programs->perPage(), + 'total' => $programs->total(), + ], + ]); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'weeks' => ['required', 'integer', 'min:1', 'max:52'], + ]); + + $program = Auth::user()->programs()->create($data); + + return response()->json(['program' => new ProgramResource($program)], 201); + } + + public function show(Program $program): JsonResponse + { + $this->authorizeProgram($program); + $program->load('workouts'); + + $completedWorkoutIds = WorkoutSession::query() + ->where('user_id', Auth::id()) + ->whereIn('workout_id', $program->workouts->pluck('id')) + ->whereNotNull('completed_at') + ->pluck('workout_id') + ->unique(); + + $totalWorkouts = $program->workouts->count(); + $completedCount = $completedWorkoutIds->count(); + $progressPct = $totalWorkouts > 0 ? (int) round(($completedCount / $totalWorkouts) * 100) : 0; + + return response()->json([ + 'program' => new ProgramResource($program), + 'completed_workout_ids' => $completedWorkoutIds->values(), + 'progress_pct' => $progressPct, + 'completed_count' => $completedCount, + 'total_workouts' => $totalWorkouts, + ]); + } + + public function destroy(Program $program): JsonResponse + { + $this->authorizeProgram($program); + $program->delete(); + + return response()->json(['message' => 'Program deleted']); + } + + public function addWorkout(Request $request, Program $program): JsonResponse + { + $this->authorizeProgram($program); + $data = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'week_number' => ['required', 'integer', 'min:1', 'max:'.$program->weeks], + 'day_number' => ['required', 'integer', 'min:1', 'max:14'], + 'estimated_minutes' => ['nullable', 'integer', 'min:1', 'max:600'], + ]); + + $workout = $program->workouts()->create([ + 'user_id' => Auth::id(), + 'title' => $data['title'], + 'estimated_minutes' => $data['estimated_minutes'] ?? null, + 'week_number' => $data['week_number'], + 'day_number' => $data['day_number'], + ]); + + return response()->json(['workout_id' => $workout->id, 'message' => 'Workout added to program'], 201); + } + + protected function authorizeProgram(Program $program): void + { + if ($program->user_id !== Auth::id()) { + abort(403); + } + } +} diff --git a/app/Http/Controllers/Api/TrainerController.php b/app/Http/Controllers/Api/TrainerController.php new file mode 100644 index 0000000..3b8c218 --- /dev/null +++ b/app/Http/Controllers/Api/TrainerController.php @@ -0,0 +1,90 @@ +clients() + ->with(['workouts' => function ($q) use ($trainer) { + $q->where('assigned_by_trainer_id', $trainer->id)->latest(); + }]) + ->orderBy('name') + ->get(); + + return response()->json([ + 'data' => $clients->map(fn ($client) => [ + 'client' => new UserResource($client), + 'assigned_workouts' => WorkoutResource::collection($client->workouts), + ]), + ]); + } + + public function addClient(Request $request): JsonResponse + { + $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 response()->json(['message' => 'You cannot add yourself as a client.'], 422); + } + + if ($trainer->clients()->where('client_id', $client->id)->exists()) { + return response()->json(['message' => 'This person is already one of your clients.'], 422); + } + + $trainer->clients()->attach($client->id); + + return response()->json(['message' => 'Client added', 'client' => new UserResource($client)], 201); + } + + public function removeClient(User $client): JsonResponse + { + Auth::user()->clients()->detach($client->id); + + return response()->json(['message' => 'Client removed']); + } + + public function storeWorkout(Request $request, User $client): JsonResponse + { + $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 response()->json(['workout' => new WorkoutResource($workout)], 201); + } + + protected function authorizeClient(User $client): void + { + if (!Auth::user()->clients()->where('client_id', $client->id)->exists()) { + abort(403); + } + } +} diff --git a/app/Http/Controllers/Api/WeightLogController.php b/app/Http/Controllers/Api/WeightLogController.php new file mode 100644 index 0000000..382acd3 --- /dev/null +++ b/app/Http/Controllers/Api/WeightLogController.php @@ -0,0 +1,48 @@ +weightLogs()->orderBy('logged_at')->get(); + + return response()->json(['data' => WeightLogResource::collection($logs)]); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'weight_kg' => ['required', 'numeric', 'min:20', 'max:400'], + 'logged_at' => ['nullable', 'date', 'before_or_equal:today'], + ]); + + $loggedAt = $data['logged_at'] ?? now()->toDateString(); + + $entry = WeightLog::where('user_id', Auth::id())->whereDate('logged_at', $loggedAt)->first() + ?? new WeightLog(['user_id' => Auth::id(), 'logged_at' => $loggedAt]); + $entry->weight_kg = $data['weight_kg']; + $entry->save(); + + return response()->json(['weight_log' => new WeightLogResource($entry)], 201); + } + + public function destroy(WeightLog $weightLog): JsonResponse + { + if ($weightLog->user_id !== Auth::id()) { + abort(403); + } + + $weightLog->delete(); + + return response()->json(['message' => __('app.weight_log.deleted')]); + } +} diff --git a/app/Http/Controllers/Api/WorkoutController.php b/app/Http/Controllers/Api/WorkoutController.php new file mode 100644 index 0000000..e9e2297 --- /dev/null +++ b/app/Http/Controllers/Api/WorkoutController.php @@ -0,0 +1,209 @@ +workouts()->with('assignedByTrainer')->latest()->paginate(10); + + return $this->paginated($workouts); + } + + public function community(Request $request): JsonResponse + { + $q = trim((string) $request->query('q', '')); + + $workouts = Workout::query() + ->where('is_public', true) + ->with(['user:id,name', 'items']) + ->when($q !== '', fn ($qb) => $qb->where('title', 'like', "%{$q}%")) + ->latest() + ->paginate(12) + ->withQueryString(); + + return $this->paginated($workouts); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'title' => 'required|string|max:255', + 'notes' => 'nullable|string', + 'estimated_minutes' => 'nullable|integer|min:1|max:600', + ]); + $workout = Auth::user()->workouts()->create($data); + + return response()->json(['workout' => new WorkoutResource($workout)], 201); + } + + public function show(Workout $workout): JsonResponse + { + $this->authorizeView($workout); + $workout->load(['items.exercise', 'assignedByTrainer', 'user:id,name']); + + return response()->json(['workout' => new WorkoutResource($workout)]); + } + + public function searchExercises(Request $request, Workout $workout): JsonResponse + { + $this->authorizeWorkout($workout); + $q = trim((string) $request->query('q', '')); + + $exercises = Exercise::query() + ->with(['aliases' => fn ($rel) => $rel->where('user_id', Auth::id())]) + ->when($q !== '', fn ($qb) => $qb->where('name', 'like', "%{$q}%")) + ->orderBy('name') + ->limit(20) + ->get(['id', 'name']); + + return response()->json(ExerciseResource::collection($exercises)); + } + + public function togglePublic(Workout $workout): JsonResponse + { + if ($workout->user_id !== Auth::id()) { + abort(403); + } + $workout->is_public = !$workout->is_public; + $workout->save(); + + return response()->json(['workout' => new WorkoutResource($workout)]); + } + + public function copy(Workout $workout): JsonResponse + { + if (!$workout->is_public && $workout->user_id !== Auth::id()) { + abort(403); + } + + $workout->loadMissing('items'); + + $copy = Auth::user()->workouts()->create([ + 'title' => $workout->title.' (copy)', + 'notes' => $workout->notes, + 'estimated_minutes' => $workout->estimated_minutes, + ]); + + foreach ($workout->items as $item) { + $copy->items()->create([ + 'exercise_id' => $item->exercise_id, + 'position' => $item->position, + 'sets' => $item->sets, + 'reps' => $item->reps, + 'weight' => $item->weight, + 'rest_seconds' => $item->rest_seconds, + 'notes' => $item->notes, + ]); + } + + $copy->load(['items.exercise']); + + return response()->json(['workout' => new WorkoutResource($copy)], 201); + } + + public function addExercise(Request $request, Workout $workout): JsonResponse + { + $this->authorizeWorkout($workout); + $data = $request->validate([ + 'exercise_id' => 'required|exists:exercises,id', + 'position' => 'nullable|integer|min:1', + 'sets' => 'nullable|integer|min:1|max:20', + 'reps' => 'nullable|integer|min:1|max:200', + 'weight' => 'nullable|numeric|min:0|max:999.99', + 'rest_seconds' => 'nullable|integer|min:0|max:600', + 'notes' => 'nullable|string', + ]); + $nextPos = ($workout->items()->max('position') ?? 0) + 1; + $data['position'] = $data['position'] ?? $nextPos; + $item = $workout->items()->create($data); + $item->load('exercise'); + + return response()->json(['item' => new WorkoutExerciseResource($item)], 201); + } + + public function updateExercise(Request $request, Workout $workout, WorkoutExercise $item): JsonResponse + { + $this->authorizeWorkout($workout); + if ($item->workout_id !== $workout->id) { + abort(404); + } + $data = $request->validate([ + 'position' => 'nullable|integer|min:1', + 'sets' => 'nullable|integer|min:1|max:20', + 'reps' => 'nullable|integer|min:1|max:200', + 'weight' => 'nullable|numeric|min:0|max:999.99', + 'rest_seconds' => 'nullable|integer|min:0|max:600', + 'notes' => 'nullable|string', + ]); + $item->update($data); + $item->load('exercise'); + + return response()->json(['item' => new WorkoutExerciseResource($item)]); + } + + public function removeExercise(Workout $workout, WorkoutExercise $item): JsonResponse + { + $this->authorizeWorkout($workout); + if ($item->workout_id !== $workout->id) { + abort(404); + } + $item->delete(); + + return response()->json(['message' => 'Exercise removed']); + } + + public function destroy(Workout $workout): JsonResponse + { + if ($workout->user_id !== Auth::id()) { + abort(403); + } + $workout->delete(); + + return response()->json(['message' => 'Workout deleted']); + } + + protected function authorizeWorkout(Workout $workout): void + { + if ($workout->user_id === Auth::id()) { + return; + } + if ($workout->assigned_by_trainer_id && $workout->assigned_by_trainer_id === Auth::id()) { + return; + } + abort(403); + } + + protected function authorizeView(Workout $workout): void + { + if ($workout->is_public) { + return; + } + $this->authorizeWorkout($workout); + } + + protected function paginated($paginator): JsonResponse + { + return response()->json([ + 'data' => WorkoutResource::collection($paginator->items()), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/WorkoutSessionController.php b/app/Http/Controllers/Api/WorkoutSessionController.php new file mode 100644 index 0000000..5244b09 --- /dev/null +++ b/app/Http/Controllers/Api/WorkoutSessionController.php @@ -0,0 +1,105 @@ +authorizeWorkout($workout); + + $session = WorkoutSession::create([ + 'user_id' => Auth::id(), + 'workout_id' => $workout->id, + 'started_at' => now(), + ]); + + $workout->loadMissing('items'); + foreach ($workout->items as $item) { + WorkoutSessionItem::create([ + 'session_id' => $session->id, + 'workout_exercise_id' => $item->id, + 'done' => false, + ]); + } + + $session->load(['workout.items.exercise', 'items']); + + return response()->json(['session' => new WorkoutSessionResource($session)], 201); + } + + public function show(WorkoutSession $session): JsonResponse + { + $this->authorizeSession($session); + $session->load(['workout.items.exercise', 'items']); + + return response()->json(['session' => new WorkoutSessionResource($session)]); + } + + public function toggleItem(Request $request, WorkoutSession $session, WorkoutExercise $item): JsonResponse + { + $this->authorizeSession($session); + if ($item->workout_id !== $session->workout_id) { + abort(404); + } + + $data = $request->validate([ + 'actual_weight' => ['nullable', 'numeric', 'min:0', 'max:9999.99'], + 'actual_reps' => ['nullable', 'integer', 'min:0', 'max:1000'], + ]); + + $si = WorkoutSessionItem::firstOrCreate([ + 'session_id' => $session->id, + 'workout_exercise_id' => $item->id, + ], [ + 'done' => false, + ]); + + $si->done = !$si->done; + if ($si->done) { + if (array_key_exists('actual_weight', $data) && $data['actual_weight'] !== null) { + $si->actual_weight = $data['actual_weight']; + } + if (array_key_exists('actual_reps', $data) && $data['actual_reps'] !== null) { + $si->actual_reps = $data['actual_reps']; + } + } + $si->save(); + + return response()->json(['item' => $si]); + } + + public function complete(WorkoutSession $session): JsonResponse + { + $this->authorizeSession($session); + $session->completed_at = now(); + $session->save(); + $session->load(['workout.items.exercise', 'items']); + + return response()->json(['session' => new WorkoutSessionResource($session)]); + } + + protected function authorizeWorkout(Workout $workout): void + { + if ($workout->user_id !== Auth::id()) { + abort(403); + } + } + + protected function authorizeSession(WorkoutSession $session): void + { + if ($session->user_id !== Auth::id()) { + abort(403); + } + } +} diff --git a/app/Http/Controllers/Api/YogaPoseController.php b/app/Http/Controllers/Api/YogaPoseController.php new file mode 100644 index 0000000..7c7d88c --- /dev/null +++ b/app/Http/Controllers/Api/YogaPoseController.php @@ -0,0 +1,80 @@ +query('q', '')); + $category = (int) $request->query('category', 0); + $benefit = trim((string) $request->query('benefit', '')); + $perPage = (int) $request->query('per_page', 12); + if ($perPage < 6 || $perPage > 60) { $perPage = 12; } + + $query = YogaPose::query()->with('category') + ->when($q !== '', function ($qb) use ($q) { + $qb->where(function ($sub) use ($q) { + $sub->where('english_name', 'like', "%{$q}%") + ->orWhere('sanskrit_name', 'like', "%{$q}%") + ->orWhere('sanskrit_name_adapted', 'like', "%{$q}%"); + }); + }) + ->when($category > 0, fn ($qb) => $qb->where('category_id', $category)) + ->when($benefit !== '', fn ($qb) => $qb->where('benefits', 'like', "%{$benefit}%")) + ->orderBy('english_name'); + + $poses = $query->paginate($perPage)->withQueryString(); + + return response()->json([ + 'data' => YogaPoseResource::collection($poses->items()), + 'meta' => [ + 'current_page' => $poses->currentPage(), + 'last_page' => $poses->lastPage(), + 'per_page' => $poses->perPage(), + 'total' => $poses->total(), + ], + 'categories' => YogaCategory::orderBy('name')->get(['id', 'name']), + ]); + } + + public function show(YogaPose $pose): JsonResponse + { + $pose->load('category'); + + $benefits = (string) $pose->benefits; + $tokens = collect(preg_split('/[^\p{L}]+/u', mb_strtolower($benefits))) + ->filter(fn ($t) => $t !== null && $t !== '' && mb_strlen($t) >= 4) + ->unique() + ->take(8) + ->values() + ->all(); + + $similarPoses = YogaPose::query() + ->with('category') + ->where('id', '!=', $pose->id) + ->where(function ($q) use ($pose, $tokens) { + if ($pose->category_id) { + $q->orWhere('category_id', $pose->category_id); + } + foreach ($tokens as $tk) { + $q->orWhere('benefits', 'like', "%{$tk}%"); + } + }) + ->orderBy('english_name') + ->limit(9) + ->get(); + + return response()->json([ + 'pose' => new YogaPoseResource($pose), + 'similar_poses' => YogaPoseResource::collection($similarPoses), + ]); + } +} diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php index 784765e..dd257e1 100644 --- a/app/Http/Controllers/Auth/VerifyEmailController.php +++ b/app/Http/Controllers/Auth/VerifyEmailController.php @@ -3,25 +3,48 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; +use App\Models\User; use Illuminate\Auth\Events\Verified; -use Illuminate\Foundation\Auth\EmailVerificationRequest; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; class VerifyEmailController extends Controller { /** - * Mark the authenticated user's email address as verified. + * Mark the user's email address as verified. + * + * Deliberately not session-gated: the link is opened from an email + * client, which may belong to a device/browser with no web session at + * all (e.g. an account registered through the Android app). The signed + * URL + hash-of-email check below is what proves identity here, not + * Auth::user(). */ - public function __invoke(EmailVerificationRequest $request): RedirectResponse + public function __invoke(Request $request, int $id, string $hash): RedirectResponse|\Illuminate\Http\JsonResponse { - if ($request->user()->hasVerifiedEmail()) { + $user = User::findOrFail($id); + + if (!hash_equals(sha1($user->getEmailForVerification()), $hash)) { + abort(403); + } + + $alreadyVerified = $user->hasVerifiedEmail(); + + if (!$alreadyVerified && $user->markEmailAsVerified()) { + event(new Verified($user)); + } + + if ($request->wantsJson()) { + return response()->json([ + 'message' => $alreadyVerified ? 'Email already verified.' : 'Email verified.', + 'verified' => true, + ]); + } + + if (Auth::check() && Auth::id() === $user->id) { return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); } - if ($request->user()->markEmailAsVerified()) { - event(new Verified($request->user())); - } - - return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); + return redirect()->route('login')->with('status', 'Email verified. You can now log in.'); } } diff --git a/app/Http/Resources/ExerciseResource.php b/app/Http/Resources/ExerciseResource.php new file mode 100644 index 0000000..6876dbc --- /dev/null +++ b/app/Http/Resources/ExerciseResource.php @@ -0,0 +1,37 @@ + $this->id, + 'name' => $this->display_name, + 'original_name' => $this->name, + 'slug' => $this->slug, + 'gif_url' => $this->gif_url, + 'poster_url' => $this->poster_url, + 'instructions' => $this->instructions, + 'body_parts' => $this->whenLoaded('bodyParts', fn () => $this->bodyParts->map(fn ($m) => [ + 'id' => $m->id, 'name' => $m->name, 'slug' => $m->slug, + ])), + 'target_muscles' => $this->whenLoaded('targetMuscles', fn () => $this->targetMuscles->map(fn ($m) => [ + 'id' => $m->id, 'name' => $m->name, 'slug' => $m->slug, + ])), + 'secondary_muscles' => $this->whenLoaded('secondaryMuscles', fn () => $this->secondaryMuscles->map(fn ($m) => [ + 'id' => $m->id, 'name' => $m->name, 'slug' => $m->slug, + ])), + 'equipments' => $this->whenLoaded('equipments', fn () => $this->equipments->map(fn ($m) => [ + 'id' => $m->id, 'name' => $m->name, 'slug' => $m->slug, + ])), + 'is_favorited' => $request->user() + ? $request->user()->favoriteExercises()->where('exercise_id', $this->id)->exists() + : false, + ]; + } +} diff --git a/app/Http/Resources/ProgramResource.php b/app/Http/Resources/ProgramResource.php new file mode 100644 index 0000000..2ce1bb9 --- /dev/null +++ b/app/Http/Resources/ProgramResource.php @@ -0,0 +1,23 @@ + $this->id, + 'user_id' => $this->user_id, + 'title' => $this->title, + 'description' => $this->description, + 'weeks' => $this->weeks, + 'workouts_count' => $this->whenCounted('workouts'), + 'workouts' => WorkoutResource::collection($this->whenLoaded('workouts')), + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php new file mode 100644 index 0000000..f5b7da8 --- /dev/null +++ b/app/Http/Resources/UserResource.php @@ -0,0 +1,30 @@ + $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'is_trainer' => (bool) $this->is_trainer, + 'is_admin' => (bool) $this->is_admin, + 'age' => $this->age, + 'weight_kg' => $this->weight_kg !== null ? (float) $this->weight_kg : null, + 'height_cm' => $this->height_cm !== null ? (float) $this->height_cm : null, + 'fitness_level' => $this->fitness_level, + 'goal' => $this->goal, + 'health_notes' => $this->health_notes, + 'available_equipment' => $this->available_equipment, + 'days_per_week' => $this->days_per_week, + 'minutes_per_session' => $this->minutes_per_session, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/WeightLogResource.php b/app/Http/Resources/WeightLogResource.php new file mode 100644 index 0000000..d4b12bb --- /dev/null +++ b/app/Http/Resources/WeightLogResource.php @@ -0,0 +1,18 @@ + $this->id, + 'weight_kg' => (float) $this->weight_kg, + 'logged_at' => $this->logged_at, + ]; + } +} diff --git a/app/Http/Resources/WorkoutExerciseResource.php b/app/Http/Resources/WorkoutExerciseResource.php new file mode 100644 index 0000000..dab7661 --- /dev/null +++ b/app/Http/Resources/WorkoutExerciseResource.php @@ -0,0 +1,24 @@ + $this->id, + 'workout_id' => $this->workout_id, + 'position' => $this->position, + 'sets' => $this->sets, + 'reps' => $this->reps, + 'weight' => $this->weight !== null ? (float) $this->weight : null, + 'rest_seconds' => $this->rest_seconds, + 'notes' => $this->notes, + 'exercise' => new ExerciseResource($this->whenLoaded('exercise')), + ]; + } +} diff --git a/app/Http/Resources/WorkoutResource.php b/app/Http/Resources/WorkoutResource.php new file mode 100644 index 0000000..0492c08 --- /dev/null +++ b/app/Http/Resources/WorkoutResource.php @@ -0,0 +1,32 @@ + $this->id, + 'user_id' => $this->user_id, + 'owner' => new UserResource($this->whenLoaded('user')), + 'title' => $this->title, + 'notes' => $this->notes, + 'estimated_minutes' => $this->estimated_minutes, + 'is_public' => (bool) $this->is_public, + 'program_id' => $this->program_id, + 'week_number' => $this->week_number, + 'day_number' => $this->day_number, + 'assigned_by_trainer_id' => $this->assigned_by_trainer_id, + 'assigned_by_trainer' => new UserResource($this->whenLoaded('assignedByTrainer')), + 'items' => WorkoutExerciseResource::collection($this->whenLoaded('items')), + 'is_favorited' => $request->user() + ? $request->user()->favoriteWorkouts()->where('workout_id', $this->id)->exists() + : false, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/WorkoutSessionItemResource.php b/app/Http/Resources/WorkoutSessionItemResource.php new file mode 100644 index 0000000..7ba4d79 --- /dev/null +++ b/app/Http/Resources/WorkoutSessionItemResource.php @@ -0,0 +1,23 @@ + $this->id, + 'session_id' => $this->session_id, + 'workout_exercise_id' => $this->workout_exercise_id, + 'done' => (bool) $this->done, + 'actual_weight' => $this->actual_weight !== null ? (float) $this->actual_weight : null, + 'actual_reps' => $this->actual_reps, + 'exercise_ms' => $this->exercise_ms, + 'rest_ms' => $this->rest_ms, + ]; + } +} diff --git a/app/Http/Resources/WorkoutSessionResource.php b/app/Http/Resources/WorkoutSessionResource.php new file mode 100644 index 0000000..bd35279 --- /dev/null +++ b/app/Http/Resources/WorkoutSessionResource.php @@ -0,0 +1,22 @@ + $this->id, + 'user_id' => $this->user_id, + 'workout_id' => $this->workout_id, + 'workout' => new WorkoutResource($this->whenLoaded('workout')), + 'started_at' => $this->started_at, + 'completed_at' => $this->completed_at, + 'items' => WorkoutSessionItemResource::collection($this->whenLoaded('items')), + ]; + } +} diff --git a/app/Http/Resources/YogaPoseResource.php b/app/Http/Resources/YogaPoseResource.php new file mode 100644 index 0000000..45cb293 --- /dev/null +++ b/app/Http/Resources/YogaPoseResource.php @@ -0,0 +1,33 @@ + $this->id, + 'slug' => $this->slug, + 'english_name' => $this->english_name, + 'sanskrit_name' => $this->sanskrit_name, + 'sanskrit_name_adapted' => $this->sanskrit_name_adapted, + 'translation_name' => $this->translation_name, + 'description' => $this->description, + 'benefits' => $this->benefits, + 'url_svg' => $this->url_svg, + 'url_png' => $this->url_png, + 'url_svg_alt' => $this->url_svg_alt, + 'category' => $this->whenLoaded('category', fn () => [ + 'id' => $this->category->id, + 'name' => $this->category->name, + ]), + 'is_favorited' => $request->user() + ? $request->user()->favoriteYogaPoses()->where('yoga_pose_id', $this->id)->exists() + : false, + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index be2c4a9..08d08a1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,11 +6,12 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable; /** * The attributes that are mass assignable. diff --git a/bootstrap/app.php b/bootstrap/app.php index 226602a..491331f 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -8,6 +8,7 @@ use App\Http\Middleware\SetLocale; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) diff --git a/composer.json b/composer.json index 496d0f3..09ae6e7 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,9 @@ "license": "MIT", "require": { "php": "^8.2", + "dedoc/scramble": "^0.13.39", "laravel/framework": "^12.0", + "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1" }, "require-dev": { diff --git a/composer.lock b/composer.lock index a734bb5..ceb67fd 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bdc6d92507d397adf1c269ed49ec424d", + "content-hash": "12cb787cf7a13805bce4ca75436679f4", "packages": [ { "name": "brick/math", @@ -135,6 +135,87 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "dedoc/scramble", + "version": "v0.13.39", + "source": { + "type": "git", + "url": "https://github.com/dedoc/scramble.git", + "reference": "7fcc4758d62f22d326637a578168bd5eb67a8625" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dedoc/scramble/zipball/7fcc4758d62f22d326637a578168bd5eb67a8625", + "reference": "7fcc4758d62f22d326637a578168bd5eb67a8625", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "myclabs/deep-copy": "^1.12", + "nikic/php-parser": "^5.0", + "php": "^8.1", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "spatie/laravel-package-tools": "^1.9.2" + }, + "require-dev": { + "larastan/larastan": "^3.3", + "laravel/pint": "^v1.1.0", + "laravel/scout": "^10.0|^11.0", + "nunomaduro/collision": "^7.0|^8.0", + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "pestphp/pest": "^2.34|^3.7|^4.4", + "pestphp/pest-plugin-laravel": "^2.3|^3.1|^4.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5|^11.5.3|^12.5.12", + "spatie/laravel-permission": "^6.10|^7.2", + "spatie/pest-plugin-snapshots": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Dedoc\\Scramble\\ScrambleServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Dedoc\\Scramble\\": "src", + "Dedoc\\Scramble\\Database\\Factories\\": "database/factories" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Roman Lytvynenko", + "email": "litvinenko95@gmail.com", + "role": "Developer" + } + ], + "description": "Automatic generation of API documentation for Laravel applications.", + "homepage": "https://github.com/dedoc/scramble", + "keywords": [ + "documentation", + "laravel", + "openapi" + ], + "support": { + "issues": "https://github.com/dedoc/scramble/issues", + "source": "https://github.com/dedoc/scramble/tree/v0.13.39" + }, + "funding": [ + { + "url": "https://github.com/romalytvynenko", + "type": "github" + } + ], + "time": "2026-08-06T06:42:48+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -1331,6 +1412,69 @@ }, "time": "2025-09-19T13:47:56+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-06-23T18:26:55+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.5", @@ -2112,6 +2256,66 @@ ], "time": "2025-03-24T10:02:05+00:00" }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, { "name": "nesbot/carbon", "version": "3.10.3", @@ -2588,6 +2792,53 @@ ], "time": "2025-08-21T11:53:16+00:00" }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + }, + "time": "2026-07-08T07:01:06+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -3276,6 +3527,67 @@ }, "time": "2025-09-04T20:59:21+00:00" }, + { + "name": "spatie/laravel-package-tools", + "version": "1.93.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-package-tools.git", + "reference": "d5552849801f2642aea710557463234b59ef65eb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb", + "reference": "d5552849801f2642aea710557463234b59ef65eb", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0", + "pestphp/pest": "^2.1|^3.1|^4.0", + "phpunit/php-code-coverage": "^10.0|^11.0|^12.0", + "phpunit/phpunit": "^10.5|^11.5|^12.5", + "spatie/pest-plugin-test-time": "^2.2|^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\LaravelPackageTools\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Tools for creating Laravel packages", + "homepage": "https://github.com/spatie/laravel-package-tools", + "keywords": [ + "laravel-package-tools", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-package-tools/issues", + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-05-19T14:06:37+00:00" + }, { "name": "symfony/clock", "version": "v7.3.0", @@ -6563,66 +6875,6 @@ }, "time": "2024-05-16T03:13:13+00:00" }, - { - "name": "myclabs/deep-copy", - "version": "1.13.4", - "source": { - "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3 <3.2.2" - }, - "require-dev": { - "doctrine/collections": "^1.6.8", - "doctrine/common": "^2.13.3 || ^3.2.2", - "phpspec/prophecy": "^1.10", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" - }, - "type": "library", - "autoload": { - "files": [ - "src/DeepCopy/deep_copy.php" - ], - "psr-4": { - "DeepCopy\\": "src/DeepCopy/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Create deep copies (clones) of your objects", - "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" - ], - "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" - }, - "funding": [ - { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" - } - ], - "time": "2025-08-01T08:46:24+00:00" - }, { "name": "nunomaduro/collision", "version": "v8.8.2", @@ -8451,12 +8703,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.2" }, - "platform-dev": {}, - "plugin-api-version": "2.6.0" + "platform-dev": [], + "plugin-api-version": "2.2.0" } diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..cde73cf --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,87 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/config/scramble.php b/config/scramble.php new file mode 100644 index 0000000..3b422b4 --- /dev/null +++ b/config/scramble.php @@ -0,0 +1,173 @@ + [ + * 'include' => 'api', + * 'exclude' => ['api/internal'], + * ], + * + * Without *, patterns match path segments (api matches api and api/users, not apiary). + * With *, Str::is is used (e.g. api/v*). + * + * One static include → default server is /{include} and paths are stripped (/users). + * Multiple includes or wildcards → server defaults to / and paths stay full (/api/users). + * Override with `servers`, or use Scramble::registerApi() for separate bases. + */ + 'api_path' => 'api', + + /* + * Your API domain. By default, app domain is used. This is also a part of the default API routes + * matcher, so when implementing your own, make sure you use this config if needed. + */ + 'api_domain' => null, + + /* + * The path where your OpenAPI specification will be exported. + */ + 'export_path' => 'api.json', + + /* + * Cache configuration for the generated OpenAPI document. + * + * Use `scramble:cache` to warm the cache and `scramble:clear` to invalidate it. + */ + 'cache' => [ + 'key' => 'scramble.openapi', + 'store' => 'file', + ], + + 'info' => [ + /* + * API version. + */ + 'version' => env('API_VERSION', 'v1'), + + /* + * Description rendered on the home page of the API documentation (`/docs/api`). + */ + 'description' => 'REST API for the fitness app, consumed by the Android client. All endpoints are namespaced under /api/v1. Auth is Sanctum bearer tokens obtained from POST /api/v1/login or /api/v1/register.', + ], + + 'ui' => [ + 'title' => 'Fitness API', + ], + + 'renderer' => 'elements', + + 'renderers' => [ + /* + * Stoplight Elements config options: https://docs.stoplight.io/docs/elements/b074dc47b2826-elements-configuration-options + */ + 'elements' => [ + 'view' => 'scramble::docs', + 'theme' => 'light', + 'hideTryIt' => false, + 'hideSchemas' => false, + 'logo' => '', + 'tryItCredentialsPolicy' => 'include', + 'layout' => 'responsive', + 'router' => 'hash', + ], + /* + * Scalar API reference config options: https://scalar.com/products/api-references/configuration + */ + 'scalar' => [ + 'view' => 'scramble::scalar', + 'cdn' => 'https://cdn.jsdelivr.net/npm/@scalar/api-reference', + 'theme' => 'laravel', + 'proxyUrl' => 'https://proxy.scalar.com', + 'darkMode' => false, + 'showDeveloperTools' => 'never', + 'agent' => ['disabled' => true], + 'credentials' => 'include', + ], + ], + + /* + * The list of servers of the API. By default, when `null`, server URL will be created from + * `scramble.api_path` and `scramble.api_domain` config variables. When providing an array, you + * will need to specify the local server URL manually (if needed). + * + * Example of non-default config (final URLs are generated using Laravel `url` helper): + * + * ```php + * 'servers' => [ + * 'Live' => 'api', + * 'Prod' => 'https://scramble.dedoc.co/api', + * ], + * ``` + */ + 'servers' => null, + + /** + * Determines how Scramble stores the descriptions of enum cases. + * Available options: + * - 'description' – Case descriptions are stored as the enum schema's description using table formatting. + * - 'extension' – Case descriptions are stored in the `x-enumDescriptions` enum schema extension. + * + * @see https://redocly.com/docs-legacy/api-reference-docs/specification-extensions/x-enum-descriptions + * - false - Case descriptions are ignored. + */ + 'enum_cases_description_strategy' => 'description', + + /** + * Determines how Scramble stores the names of enum cases. + * Available options: + * - 'names' – Case names are stored in the `x-enumNames` enum schema extension. + * - 'varnames' - Case names are stored in the `x-enum-varnames` enum schema extension. + * - false - Case names are not stored. + */ + 'enum_cases_names_strategy' => false, + + /** + * When Scramble encounters deep objects in query parameters, it flattens the parameters so the generated + * OpenAPI document correctly describes the API. Flattening deep query parameters is relevant until + * OpenAPI 3.2 is released and query string structure can be described properly. + * + * For example, this nested validation rule describes the object with `bar` property: + * `['foo.bar' => ['required', 'int']]`. + * + * When `flatten_deep_query_parameters` is `true`, Scramble will document the parameter like so: + * `{"name":"foo[bar]", "schema":{"type":"int"}, "required":true}`. + * + * When `flatten_deep_query_parameters` is `false`, Scramble will document the parameter like so: + * `{"name":"foo", "schema": {"type":"object", "properties":{"bar":{"type": "int"}}, "required": ["bar"]}, "required":true}`. + */ + 'flatten_deep_query_parameters' => true, + + 'middleware' => [ + 'web', + RestrictedDocsAccess::class, + ], + + 'extensions' => [], + + /* + * Automatically document API security (OpenAPI `security` / `securitySchemes`) based on route + * middleware. + * + * Disabled by default. Uncomment the line below to enable `MiddlewareAuthSecurityStrategy`. + * When at least one documented route uses middleware matching the configured patterns (by default + * `auth` and `auth:*`), bearer auth is applied globally. Routes without matching middleware are + * marked as public (`security: []`). + * + * Set to `null` explicitly to disable. If you already configure security manually via + * `afterOpenApiGenerated` / `extendOpenApi`, keep this disabled to avoid duplicate schemes. + * + * Customize with a class-string or [class, options]: + * + * 'security_strategy' => [ + * \Dedoc\Scramble\SecurityDocumentation\MiddlewareAuthSecurityStrategy::class, + * [ + * 'middleware' => ['auth', 'auth:*'], + * 'scheme' => \Dedoc\Scramble\Support\Generator\SecurityScheme::http('bearer'), + * ], + * ], + */ + 'security_strategy' => \Dedoc\Scramble\SecurityDocumentation\MiddlewareAuthSecurityStrategy::class, +]; diff --git a/database/migrations/2026_08_10_094801_create_personal_access_tokens_table.php b/database/migrations/2026_08_10_094801_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/2026_08_10_094801_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..c6b48bd --- /dev/null +++ b/routes/api.php @@ -0,0 +1,5 @@ +group(base_path('routes/api_v1.php')); diff --git a/routes/api_v1.php b/routes/api_v1.php new file mode 100644 index 0000000..6a00013 --- /dev/null +++ b/routes/api_v1.php @@ -0,0 +1,112 @@ +name('api.v1.register'); +Route::post('/login', [AuthController::class, 'login'])->name('api.v1.login'); + +Route::post('/forgot-password', [PasswordResetController::class, 'forgotPassword']) + ->middleware('throttle:6,1') + ->name('api.v1.password.email'); +Route::post('/reset-password', [PasswordResetController::class, 'resetPassword']) + ->middleware('throttle:6,1') + ->name('api.v1.password.reset'); + +// Public catalog browsing (no auth required, matches web's public exercise/yoga pages) +Route::get('/exercises', [ExerciseController::class, 'index'])->name('api.v1.exercises.index'); +Route::get('/exercises/{exercise:slug}', [ExerciseController::class, 'show'])->name('api.v1.exercises.show'); + +Route::get('/yoga', [YogaPoseController::class, 'index'])->name('api.v1.yoga.index'); +Route::get('/yoga/{pose:slug}', [YogaPoseController::class, 'show'])->name('api.v1.yoga.show'); + +Route::middleware('auth:sanctum')->group(function () { + Route::post('/logout', [AuthController::class, 'logout'])->name('api.v1.logout'); + Route::get('/me', [AuthController::class, 'me'])->name('api.v1.me'); + + // Profile + Route::get('/profile', [ProfileController::class, 'show'])->name('api.v1.profile.show'); + Route::patch('/profile', [ProfileController::class, 'update'])->name('api.v1.profile.update'); + Route::delete('/profile', [ProfileController::class, 'destroy'])->name('api.v1.profile.destroy'); + + // Dashboard + Route::get('/dashboard', [DashboardController::class, 'index'])->name('api.v1.dashboard'); + + // Email verification (resend). The verify link itself is a public, + // signed route registered in routes/auth.php - it works without a + // Sanctum token since it's opened from an email client. + Route::post('/email/verification-notification', [EmailVerificationController::class, 'store']) + ->middleware('throttle:6,1') + ->name('api.v1.verification.send'); + + // Devices (Sanctum tokens) + Route::get('/devices', [DeviceController::class, 'index'])->name('api.v1.devices.index'); + Route::delete('/devices/{tokenId}', [DeviceController::class, 'destroy'])->name('api.v1.devices.destroy'); + + // Favorites + Route::post('/exercises/{exercise}/favorite', [FavoriteController::class, 'toggleExercise'])->name('api.v1.exercises.favorite'); + Route::post('/workouts/{workout}/favorite', [FavoriteController::class, 'toggleWorkout'])->name('api.v1.workouts.favorite'); + Route::post('/yoga/{pose}/favorite', [FavoriteController::class, 'toggleYogaPose'])->name('api.v1.yoga.favorite'); + + // Exercise alias (per-user rename) + Route::post('/exercises/{exercise}/alias', [ExerciseAliasController::class, 'store'])->name('api.v1.exercises.alias'); + + // Body weight log + Route::get('/weight-logs', [WeightLogController::class, 'index'])->name('api.v1.weight-logs.index'); + Route::post('/weight-logs', [WeightLogController::class, 'store'])->name('api.v1.weight-logs.store'); + Route::delete('/weight-logs/{weightLog}', [WeightLogController::class, 'destroy'])->name('api.v1.weight-logs.destroy'); + + // Workouts + Route::prefix('/workouts')->group(function () { + Route::get('/', [WorkoutController::class, 'index'])->name('api.v1.workouts.index'); + Route::get('/community', [WorkoutController::class, 'community'])->name('api.v1.workouts.community'); + Route::post('/', [WorkoutController::class, 'store'])->name('api.v1.workouts.store'); + Route::get('/{workout}', [WorkoutController::class, 'show'])->name('api.v1.workouts.show'); + Route::delete('/{workout}', [WorkoutController::class, 'destroy'])->name('api.v1.workouts.destroy'); + Route::post('/{workout}/toggle-public', [WorkoutController::class, 'togglePublic'])->name('api.v1.workouts.toggle-public'); + Route::post('/{workout}/copy', [WorkoutController::class, 'copy'])->name('api.v1.workouts.copy'); + + Route::get('/{workout}/exercises/search', [WorkoutController::class, 'searchExercises'])->name('api.v1.workouts.exercises.search'); + Route::post('/{workout}/exercises', [WorkoutController::class, 'addExercise'])->name('api.v1.workouts.exercises.add'); + Route::patch('/{workout}/exercises/{item}', [WorkoutController::class, 'updateExercise'])->name('api.v1.workouts.exercises.update'); + Route::delete('/{workout}/exercises/{item}', [WorkoutController::class, 'removeExercise'])->name('api.v1.workouts.exercises.remove'); + + Route::post('/{workout}/sessions', [WorkoutSessionController::class, 'store'])->name('api.v1.sessions.store'); + }); + + // Sessions + Route::get('/sessions/{session}', [WorkoutSessionController::class, 'show'])->name('api.v1.sessions.show'); + Route::patch('/sessions/{session}/items/{item}', [WorkoutSessionController::class, 'toggleItem'])->name('api.v1.sessions.items.toggle'); + Route::post('/sessions/{session}/complete', [WorkoutSessionController::class, 'complete'])->name('api.v1.sessions.complete'); + + // Programs + Route::prefix('/programs')->group(function () { + Route::get('/', [ProgramController::class, 'index'])->name('api.v1.programs.index'); + Route::post('/', [ProgramController::class, 'store'])->name('api.v1.programs.store'); + Route::get('/{program}', [ProgramController::class, 'show'])->name('api.v1.programs.show'); + Route::delete('/{program}', [ProgramController::class, 'destroy'])->name('api.v1.programs.destroy'); + Route::post('/{program}/workouts', [ProgramController::class, 'addWorkout'])->name('api.v1.programs.workouts.add'); + }); + + // Trainer: manage clients and assign workouts to them + Route::middleware('trainer')->prefix('/trainer')->group(function () { + Route::get('/clients', [TrainerController::class, 'clients'])->name('api.v1.trainer.clients'); + Route::post('/clients', [TrainerController::class, 'addClient'])->name('api.v1.trainer.clients.add'); + Route::delete('/clients/{client}', [TrainerController::class, 'removeClient'])->name('api.v1.trainer.clients.remove'); + Route::post('/clients/{client}/workouts', [TrainerController::class, 'storeWorkout'])->name('api.v1.trainer.workouts.store'); + }); +}); diff --git a/routes/auth.php b/routes/auth.php index 3926ecf..e8767a7 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -35,14 +35,18 @@ Route::middleware('guest')->group(function () { ->name('password.store'); }); +// Not session-gated: this link is opened from an email client, which may not +// carry the web session of whichever device/browser registered the account +// (e.g. a user who signed up through the Android app). Identity is proven by +// the signed URL + hash, same as the auth-gated version this replaced. +Route::get('verify-email/{id}/{hash}', VerifyEmailController::class) + ->middleware(['signed', 'throttle:6,1']) + ->name('verification.verify'); + Route::middleware('auth')->group(function () { Route::get('verify-email', EmailVerificationPromptController::class) ->name('verification.notice'); - Route::get('verify-email/{id}/{hash}', VerifyEmailController::class) - ->middleware(['signed', 'throttle:6,1']) - ->name('verification.verify'); - Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store']) ->middleware('throttle:6,1') ->name('verification.send');