From 02bee84ed4593f43f8e4f31e5621914c8ff0218a Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Wed, 20 May 2026 15:33:32 +0200 Subject: [PATCH] feat: workout sesssions --- app/Enums/WorkoutSource.php | 12 ++ .../Controllers/WorkoutSessionController.php | 33 ++++ app/Http/Requests/WorkoutSessionsRequest.php | 45 +++++ .../Resources/WorkoutSessionsResource.php | 31 ++++ app/Models/User.php | 5 + app/Models/WorkoutSessions.php | 50 ++++++ database/factories/WorkoutSessionsFactory.php | 35 ++++ ...0_130807_create_workout_sessions_table.php | 39 +++++ routes/api.php | 7 + tests/Feature/WorkoutSessionsTest.php | 156 ++++++++++++++++++ 10 files changed, 413 insertions(+) create mode 100644 app/Enums/WorkoutSource.php create mode 100644 app/Http/Controllers/WorkoutSessionController.php create mode 100644 app/Http/Requests/WorkoutSessionsRequest.php create mode 100644 app/Http/Resources/WorkoutSessionsResource.php create mode 100644 app/Models/WorkoutSessions.php create mode 100644 database/factories/WorkoutSessionsFactory.php create mode 100644 database/migrations/2026_05_20_130807_create_workout_sessions_table.php create mode 100644 tests/Feature/WorkoutSessionsTest.php diff --git a/app/Enums/WorkoutSource.php b/app/Enums/WorkoutSource.php new file mode 100644 index 0000000..8962340 --- /dev/null +++ b/app/Enums/WorkoutSource.php @@ -0,0 +1,12 @@ +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); + } +} diff --git a/app/Http/Requests/WorkoutSessionsRequest.php b/app/Http/Requests/WorkoutSessionsRequest.php new file mode 100644 index 0000000..8e6abfb --- /dev/null +++ b/app/Http/Requests/WorkoutSessionsRequest.php @@ -0,0 +1,45 @@ +|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'], + ]; + } +} diff --git a/app/Http/Resources/WorkoutSessionsResource.php b/app/Http/Resources/WorkoutSessionsResource.php new file mode 100644 index 0000000..93a244f --- /dev/null +++ b/app/Http/Resources/WorkoutSessionsResource.php @@ -0,0 +1,31 @@ + + */ + 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, + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 55fd2ef..70a59db 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -73,6 +73,11 @@ class User extends Authenticatable implements FilamentUser return $this->hasMany(MealPosts::class); } + public function workouts(): HasMany + { + return $this->hasMany(WorkoutSessions::class); + } + public function likedMealPosts(): BelongsToMany { return $this->belongsToMany(MealPosts::class, 'meal_post_likes', 'user_id', 'meal_posts_id') diff --git a/app/Models/WorkoutSessions.php b/app/Models/WorkoutSessions.php new file mode 100644 index 0000000..8a10ad7 --- /dev/null +++ b/app/Models/WorkoutSessions.php @@ -0,0 +1,50 @@ + 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); + } +} diff --git a/database/factories/WorkoutSessionsFactory.php b/database/factories/WorkoutSessionsFactory.php new file mode 100644 index 0000000..401ead0 --- /dev/null +++ b/database/factories/WorkoutSessionsFactory.php @@ -0,0 +1,35 @@ + + */ +class WorkoutSessionsFactory extends Factory +{ + protected $model = WorkoutSessions::class; + + /** + * @return array + */ + 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(), + ]; + } +} diff --git a/database/migrations/2026_05_20_130807_create_workout_sessions_table.php b/database/migrations/2026_05_20_130807_create_workout_sessions_table.php new file mode 100644 index 0000000..3a73617 --- /dev/null +++ b/database/migrations/2026_05_20_130807_create_workout_sessions_table.php @@ -0,0 +1,39 @@ +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'); + } +}; diff --git a/routes/api.php b/routes/api.php index 589ec04..c7afce8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,6 +5,7 @@ use App\Http\Controllers\DeviceTokenController; use App\Http\Controllers\MealImageAnalysisController; use App\Http\Controllers\MealPostController; use App\Http\Controllers\PostReviewsController; +use App\Http\Controllers\WorkoutSessionController; use Illuminate\Support\Facades\Route; // Auth @@ -30,6 +31,12 @@ Route::middleware('auth:sanctum')->group(function (): void { 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 Route::middleware('auth:sanctum')->group(function (): void { Route::get('meal-posts/stats', [MealPostController::class, 'stats']); diff --git a/tests/Feature/WorkoutSessionsTest.php b/tests/Feature/WorkoutSessionsTest.php new file mode 100644 index 0000000..c50fec1 --- /dev/null +++ b/tests/Feature/WorkoutSessionsTest.php @@ -0,0 +1,156 @@ +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']); +});