feat: workout sesssions
Laravel CI-CD / Tests Unitaires (push) Successful in 22s Details
Laravel CI-CD / Deploy with Kamal (push) Successful in 1m54s Details

This commit is contained in:
Leon Morival 2026-05-20 15:33:32 +02:00
parent 4ec9aab5b6
commit 02bee84ed4
10 changed files with 413 additions and 0 deletions

View File

@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum WorkoutSource: string
{
case MANUAL = 'manual';
case STRAVA = 'strava';
case APPLE_HEALTH = 'apple_health';
case GARMIN = 'garmin';
case FITBIT = 'fitbit';
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\WorkoutSessionsRequest;
use App\Http\Resources\WorkoutSessionsResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class WorkoutSessionController extends Controller
{
public function index(WorkoutSessionsRequest $request): AnonymousResourceCollection
{
$workoutSessions = $request->user()
->workouts()
->latest('started_at')
->paginate($request->integer('per_page', 15))
->withQueryString();
return WorkoutSessionsResource::collection($workoutSessions);
}
public function store(WorkoutSessionsRequest $request): JsonResponse
{
$workoutSession = $request->user()
->workouts()
->create($request->validated());
return (new WorkoutSessionsResource($workoutSession))
->response()
->setStatusCode(201);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Http\Requests;
use App\Enums\WorkoutSource;
use App\Models\WorkoutSessions;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class WorkoutSessionsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
if ($this->isMethod('get')) {
return [
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
];
}
return [
'external_id' => [
'nullable',
'string',
'max:255',
Rule::unique((new WorkoutSessions)->getTable(), 'external_id')
->where('source', $this->string('source', WorkoutSource::MANUAL->value)->toString()),
],
'source' => ['sometimes', Rule::enum(WorkoutSource::class)],
'type' => ['required', 'string', 'max:255'],
'title' => ['required', 'string', 'max:255'],
'duration_seconds' => ['required', 'integer', 'min:1'],
'calories_burned' => ['required', 'integer', 'min:0'],
'distance_meters' => ['required', 'integer', 'min:0'],
'started_at' => ['nullable', 'date'],
];
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Resources;
use App\Models\WorkoutSessions;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin WorkoutSessions */
class WorkoutSessionsResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'externalId' => $this->external_id,
'source' => $this->source,
'type' => $this->type,
'title' => $this->title,
'durationSeconds' => $this->duration_seconds,
'caloriesBurned' => $this->calories_burned,
'distanceMeters' => (int) $this->distance_meters,
'startedAt' => $this->started_at,
'createdAt' => $this->created_at,
'updatedAt' => $this->updated_at,
];
}
}

View File

@ -73,6 +73,11 @@ class User extends Authenticatable implements FilamentUser
return $this->hasMany(MealPosts::class); return $this->hasMany(MealPosts::class);
} }
public function workouts(): HasMany
{
return $this->hasMany(WorkoutSessions::class);
}
public function likedMealPosts(): BelongsToMany public function likedMealPosts(): BelongsToMany
{ {
return $this->belongsToMany(MealPosts::class, 'meal_post_likes', 'user_id', 'meal_posts_id') return $this->belongsToMany(MealPosts::class, 'meal_post_likes', 'user_id', 'meal_posts_id')

View File

@ -0,0 +1,50 @@
<?php
namespace App\Models;
use App\Enums\WorkoutSource;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WorkoutSessions extends Model
{
use HasFactory, HasUlids;
protected $fillable = [
'user_id',
'external_id',
'source',
'type',
'title',
'duration_seconds',
'calories_burned',
'distance_meters',
'started_at',
];
protected $attributes = [
'source' => WorkoutSource::MANUAL->value,
];
protected function casts(): array
{
return [
'source' => WorkoutSource::class,
'distance_meters' => 'integer',
'started_at' => 'datetime',
];
}
/*
|--------------------------------------------------------------------------
| Relations
|--------------------------------------------------------------------------
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace Database\Factories;
use App\Enums\WorkoutSource;
use App\Models\User;
use App\Models\WorkoutSessions;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
/**
* @extends Factory<WorkoutSessions>
*/
class WorkoutSessionsFactory extends Factory
{
protected $model = WorkoutSessions::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'external_id' => null,
'source' => WorkoutSource::MANUAL,
'type' => $this->faker->randomElement(['running', 'cycling', 'strength']),
'title' => $this->faker->words(3, true),
'duration_seconds' => $this->faker->numberBetween(600, 7200),
'calories_burned' => $this->faker->numberBetween(50, 1200),
'distance_meters' => $this->faker->numberBetween(0, 50000),
'started_at' => Carbon::now(),
];
}
}

View File

@ -0,0 +1,39 @@
<?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('workout_sessions', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->foreignUlid('user_id')->constrained('users')->cascadeOnDelete();
$table->string('external_id')->nullable();
$table->string('source')->default('manual');
$table->string('type');
$table->string('title');
$table->integer('duration_seconds');
$table->integer('calories_burned');
$table->bigInteger('distance_meters');
$table->timestampTz('started_at')->nullable();
$table->unique(['source', 'external_id']);
$table->timestampsTz();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('workout_sessions');
}
};

View File

@ -5,6 +5,7 @@ use App\Http\Controllers\DeviceTokenController;
use App\Http\Controllers\MealImageAnalysisController; use App\Http\Controllers\MealImageAnalysisController;
use App\Http\Controllers\MealPostController; use App\Http\Controllers\MealPostController;
use App\Http\Controllers\PostReviewsController; use App\Http\Controllers\PostReviewsController;
use App\Http\Controllers\WorkoutSessionController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
// Auth // Auth
@ -30,6 +31,12 @@ Route::middleware('auth:sanctum')->group(function (): void {
Route::get('test-notifs', [DeviceTokenController::class, 'notifs']); Route::get('test-notifs', [DeviceTokenController::class, 'notifs']);
}); });
// Workouts
Route::middleware('auth:sanctum')->group(function (): void {
Route::get('workouts', [WorkoutSessionController::class, 'index'])->name('workouts.index');
Route::post('workouts', [WorkoutSessionController::class, 'store'])->name('workouts.store');
});
// Meals // Meals
Route::middleware('auth:sanctum')->group(function (): void { Route::middleware('auth:sanctum')->group(function (): void {
Route::get('meal-posts/stats', [MealPostController::class, 'stats']); Route::get('meal-posts/stats', [MealPostController::class, 'stats']);

View File

@ -0,0 +1,156 @@
<?php
use App\Enums\WorkoutSource;
use App\Models\User;
use App\Models\WorkoutSessions;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
it('creates a workout for the authenticated user', function () {
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->postJson('/api/workouts', [
'external_id' => 'apple-workout-1',
'source' => WorkoutSource::APPLE_HEALTH->value,
'type' => 'running',
'title' => 'Morning run',
'duration_seconds' => 1800,
'calories_burned' => 450,
'distance_meters' => 5200,
'started_at' => '2026-05-19T07:30:00.000Z',
]);
$response
->assertCreated()
->assertJsonPath('data.externalId', 'apple-workout-1')
->assertJsonPath('data.source', WorkoutSource::APPLE_HEALTH->value)
->assertJsonPath('data.type', 'running')
->assertJsonPath('data.title', 'Morning run')
->assertJsonPath('data.durationSeconds', 1800)
->assertJsonPath('data.caloriesBurned', 450)
->assertJsonPath('data.distanceMeters', 5200);
$this->assertDatabaseHas('workout_sessions', [
'id' => $response->json('data.id'),
'user_id' => $user->id,
'external_id' => 'apple-workout-1',
'source' => WorkoutSource::APPLE_HEALTH->value,
'type' => 'running',
'title' => 'Morning run',
'duration_seconds' => 1800,
'calories_burned' => 450,
'distance_meters' => 5200,
]);
});
it('returns workouts for the authenticated user', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$oldWorkoutSession = WorkoutSessions::factory()->for($user, 'user')->create([
'external_id' => 'manual-1',
'source' => WorkoutSource::MANUAL,
'type' => 'strength',
'title' => 'Upper body',
'duration_seconds' => 2400,
'calories_burned' => 320,
'distance_meters' => 0,
'started_at' => '2026-05-18 10:00:00',
]);
$latestWorkoutSession = WorkoutSessions::factory()->for($user, 'user')->create([
'external_id' => 'apple-1',
'source' => WorkoutSource::APPLE_HEALTH,
'type' => 'running',
'title' => 'Morning run',
'duration_seconds' => 1800,
'calories_burned' => 450,
'distance_meters' => 5200,
'started_at' => '2026-05-19 07:30:00',
]);
$otherWorkoutSession = WorkoutSessions::factory()->for($otherUser, 'user')->create([
'title' => 'Hidden workout',
'started_at' => '2026-05-20 07:30:00',
]);
Sanctum::actingAs($user);
$response = $this->getJson('/api/workouts');
$response
->assertOk()
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.id', $latestWorkoutSession->id)
->assertJsonPath('data.0.externalId', 'apple-1')
->assertJsonPath('data.0.source', WorkoutSource::APPLE_HEALTH->value)
->assertJsonPath('data.0.type', 'running')
->assertJsonPath('data.0.title', 'Morning run')
->assertJsonPath('data.0.durationSeconds', 1800)
->assertJsonPath('data.0.caloriesBurned', 450)
->assertJsonPath('data.0.distanceMeters', 5200)
->assertJsonPath('data.1.id', $oldWorkoutSession->id)
->assertJsonMissing([
'id' => $otherWorkoutSession->id,
]);
});
it('validates the workouts pagination size', function () {
Sanctum::actingAs(User::factory()->create());
$this
->getJson('/api/workouts?per_page=101')
->assertUnprocessable()
->assertJsonValidationErrors(['per_page']);
});
it('validates workout creation data', function () {
Sanctum::actingAs(User::factory()->create());
$this
->postJson('/api/workouts', [
'source' => 'invalid',
'type' => '',
'title' => '',
'duration_seconds' => 0,
'calories_burned' => -1,
'distance_meters' => -1,
'started_at' => 'not-a-date',
])
->assertUnprocessable()
->assertJsonValidationErrors([
'source',
'type',
'title',
'duration_seconds',
'calories_burned',
'distance_meters',
'started_at',
]);
});
it('validates workout external ids per source', function () {
$user = User::factory()->create();
WorkoutSessions::factory()->create([
'external_id' => 'manual-1',
'source' => WorkoutSource::MANUAL,
]);
Sanctum::actingAs($user);
$this
->postJson('/api/workouts', [
'external_id' => 'manual-1',
'source' => WorkoutSource::MANUAL->value,
'type' => 'running',
'title' => 'Morning run',
'duration_seconds' => 1800,
'calories_burned' => 450,
'distance_meters' => 5200,
])
->assertUnprocessable()
->assertJsonValidationErrors(['external_id']);
});