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),
]);
}
}
@@ -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.');
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ExerciseResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
];
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProgramResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
];
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
];
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WeightLogResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'weight_kg' => (float) $this->weight_kg,
'logged_at' => $this->logged_at,
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WorkoutExerciseResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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')),
];
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WorkoutResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WorkoutSessionItemResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WorkoutSessionResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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')),
];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class YogaPoseResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
];
}
}
+2 -1
View File
@@ -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.
+1
View File
@@ -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',
)
+2
View File
@@ -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": {
Generated
+316 -64
View File
@@ -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"
}
+87
View File
@@ -0,0 +1,87 @@
<?php
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => 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,
],
];
+173
View File
@@ -0,0 +1,173 @@
<?php
use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess;
return [
/*
* Which routes to document. String or array form; use Scramble::routes() for custom selection.
*
* 'api_path' => [
* '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,
];
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->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');
}
};
+5
View File
@@ -0,0 +1,5 @@
<?php
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(base_path('routes/api_v1.php'));
+112
View File
@@ -0,0 +1,112 @@
<?php
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\DashboardController;
use App\Http\Controllers\Api\DeviceController;
use App\Http\Controllers\Api\EmailVerificationController;
use App\Http\Controllers\Api\ExerciseAliasController;
use App\Http\Controllers\Api\ExerciseController;
use App\Http\Controllers\Api\FavoriteController;
use App\Http\Controllers\Api\PasswordResetController;
use App\Http\Controllers\Api\ProfileController;
use App\Http\Controllers\Api\ProgramController;
use App\Http\Controllers\Api\TrainerController;
use App\Http\Controllers\Api\WeightLogController;
use App\Http\Controllers\Api\WorkoutController;
use App\Http\Controllers\Api\WorkoutSessionController;
use App\Http\Controllers\Api\YogaPoseController;
use Illuminate\Support\Facades\Route;
Route::post('/register', [AuthController::class, 'register'])->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');
});
});
+8 -4
View File
@@ -35,14 +35,18 @@ Route::middleware('guest')->group(function () {
->name('password.store');
});
Route::middleware('auth')->group(function () {
Route::get('verify-email', EmailVerificationPromptController::class)
->name('verification.notice');
// 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::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
->middleware('throttle:6,1')
->name('verification.send');