progress
This commit is contained in:
@@ -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;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Events\Verified;
|
use Illuminate\Auth\Events\Verified;
|
||||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
class VerifyEmailController extends Controller
|
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');
|
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->user()->markEmailAsVerified()) {
|
return redirect()->route('login')->with('status', 'Email verified. You can now log in.');
|
||||||
event(new Verified($request->user()));
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -6,11 +6,12 @@ namespace App\Models;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasApiTokens, HasFactory, Notifiable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Http\Middleware\SetLocale;
|
|||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
|
"dedoc/scramble": "^0.13.39",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
|
"laravel/sanctum": "^4.3",
|
||||||
"laravel/tinker": "^2.10.1"
|
"laravel/tinker": "^2.10.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|||||||
Generated
+316
-64
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "bdc6d92507d397adf1c269ed49ec424d",
|
"content-hash": "12cb787cf7a13805bce4ca75436679f4",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -135,6 +135,87 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-02-09T16:56:22+00:00"
|
"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",
|
"name": "dflydev/dot-access-data",
|
||||||
"version": "v3.0.3",
|
"version": "v3.0.3",
|
||||||
@@ -1331,6 +1412,69 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-09-19T13:47:56+00:00"
|
"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",
|
"name": "laravel/serializable-closure",
|
||||||
"version": "v2.0.5",
|
"version": "v2.0.5",
|
||||||
@@ -2112,6 +2256,66 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-03-24T10:02:05+00:00"
|
"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",
|
"name": "nesbot/carbon",
|
||||||
"version": "3.10.3",
|
"version": "3.10.3",
|
||||||
@@ -2588,6 +2792,53 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-08-21T11:53:16+00:00"
|
"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",
|
"name": "psr/clock",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -3276,6 +3527,67 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-09-04T20:59:21+00:00"
|
"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",
|
"name": "symfony/clock",
|
||||||
"version": "v7.3.0",
|
"version": "v7.3.0",
|
||||||
@@ -6563,66 +6875,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-05-16T03:13:13+00:00"
|
"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",
|
"name": "nunomaduro/collision",
|
||||||
"version": "v8.8.2",
|
"version": "v8.8.2",
|
||||||
@@ -8451,12 +8703,12 @@
|
|||||||
],
|
],
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"stability-flags": {},
|
"stability-flags": [],
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.2"
|
"php": "^8.2"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": [],
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1')->group(base_path('routes/api_v1.php'));
|
||||||
@@ -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
@@ -35,14 +35,18 @@ Route::middleware('guest')->group(function () {
|
|||||||
->name('password.store');
|
->name('password.store');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware('auth')->group(function () {
|
// Not session-gated: this link is opened from an email client, which may not
|
||||||
Route::get('verify-email', EmailVerificationPromptController::class)
|
// carry the web session of whichever device/browser registered the account
|
||||||
->name('verification.notice');
|
// (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)
|
Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
|
||||||
->middleware(['signed', 'throttle:6,1'])
|
->middleware(['signed', 'throttle:6,1'])
|
||||||
->name('verification.verify');
|
->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'])
|
Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
|
||||||
->middleware('throttle:6,1')
|
->middleware('throttle:6,1')
|
||||||
->name('verification.send');
|
->name('verification.send');
|
||||||
|
|||||||
Reference in New Issue
Block a user