feat: workout sesssions
This commit is contained in:
parent
4ec9aab5b6
commit
02bee84ed4
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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']);
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue