This commit is contained in:
2026-08-10 19:18:25 +03:00
parent a9923f72ce
commit 7c501d1e9f
35 changed files with 2185 additions and 78 deletions
@@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function register(Request $request): JsonResponse
{
$data = $request->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())]);
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\WeightLogResource;
use App\Models\WorkoutSession;
use App\Support\Gamification;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
class DashboardController extends Controller
{
public function index(): JsonResponse
{
$user = Auth::user();
$workoutsCount = $user->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,
]);
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DeviceController extends Controller
{
public function index(Request $request): JsonResponse
{
$currentTokenId = $request->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']);
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EmailVerificationController extends Controller
{
public function store(Request $request): JsonResponse
{
if ($request->user()->hasVerifiedEmail()) {
return response()->json(['message' => 'Email already verified.']);
}
$request->user()->sendEmailVerificationNotification();
return response()->json(['message' => 'Verification link sent.']);
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Exercise;
use App\Models\ExerciseAlias;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ExerciseAliasController extends Controller
{
public function store(Request $request, Exercise $exercise): JsonResponse
{
$data = $request->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]);
}
}
@@ -0,0 +1,121 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ExerciseResource;
use App\Models\BodyPart;
use App\Models\Equipment;
use App\Models\Exercise;
use App\Models\Muscle;
use App\Models\WorkoutSessionItem;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ExerciseController extends Controller
{
public function index(Request $request): JsonResponse
{
$q = trim((string) $request->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,
]);
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Exercise;
use App\Models\Workout;
use App\Models\YogaPose;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
class FavoriteController extends Controller
{
public function toggleExercise(Exercise $exercise): JsonResponse
{
$user = Auth::user();
$exists = $user->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]);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
class PasswordResetController extends Controller
{
public function forgotPassword(Request $request): JsonResponse
{
$request->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);
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\ProfileUpdateRequest;
use App\Http\Resources\UserResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ProfileController extends Controller
{
public function show(Request $request): JsonResponse
{
return response()->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']);
}
}
@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ProgramResource;
use App\Models\Program;
use App\Models\WorkoutSession;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ProgramController extends Controller
{
public function index(): JsonResponse
{
$programs = Auth::user()->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);
}
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use App\Http\Resources\WorkoutResource;
use App\Models\User;
use App\Models\Workout;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class TrainerController extends Controller
{
public function clients(): JsonResponse
{
$trainer = Auth::user();
$clients = $trainer->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);
}
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\WeightLogResource;
use App\Models\WeightLog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class WeightLogController extends Controller
{
public function index(): JsonResponse
{
$logs = Auth::user()->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')]);
}
}
@@ -0,0 +1,209 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ExerciseResource;
use App\Http\Resources\WorkoutExerciseResource;
use App\Http\Resources\WorkoutResource;
use App\Models\Exercise;
use App\Models\Workout;
use App\Models\WorkoutExercise;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class WorkoutController extends Controller
{
public function index(): JsonResponse
{
$workouts = Auth::user()->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(),
],
]);
}
}
@@ -0,0 +1,105 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\WorkoutSessionResource;
use App\Models\Workout;
use App\Models\WorkoutExercise;
use App\Models\WorkoutSession;
use App\Models\WorkoutSessionItem;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class WorkoutSessionController extends Controller
{
public function store(Request $request, Workout $workout): JsonResponse
{
$this->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);
}
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\YogaPoseResource;
use App\Models\YogaCategory;
use App\Models\YogaPose;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class YogaPoseController extends Controller
{
public function index(Request $request): JsonResponse
{
$q = trim((string) $request->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),
]);
}
}