StreakFit — API Laravel 13 OSDD + Octane/FrankenPHP
Build & Deploy / build (push) Successful in 18s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 03:00:17 +02:00
co-authored by Claude Opus 4.8
commit af32669931
300 changed files with 21235 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"name": "functional/routines",
"description": "",
"type": "layer",
"version": "1.0.0",
"require": {
"xefi/laravel-osdd": "*"
},
"autoload": {
"psr-4": {
"Functional\\Routines\\": "src/",
"Functional\\Routines\\Database\\Seeders\\": "database/seeders/",
"Functional\\Routines\\Database\\Factories\\": "database/factories/"
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"extra": {
"laravel": {
"providers": [
"Functional\\Routines\\Providers\\RoutinesServiceProvider"
]
}
}
}
@@ -0,0 +1,36 @@
<?php
namespace Functional\Routines\Database\Factories;
use Functional\Exercises\Models\Exercise;
use Functional\Routines\Models\Routine;
use Functional\Routines\Models\RoutineExercise;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<RoutineExercise>
*/
class RoutineExerciseFactory extends Factory
{
protected $model = RoutineExercise::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'routine_id' => Routine::factory(),
'exercise_id' => Exercise::factory(),
'position' => fake()->numberBetween(1, 10),
'sets' => fake()->numberBetween(3, 5),
'target_reps' => fake()->randomElement(['6-8', '8-12', '10-15', '12-20', 'AMRAP']),
'rest_seconds' => fake()->optional(0.8)->randomElement([45, 60, 90, 120, 180]),
'tempo' => fake()->optional(0.4)->randomElement(['2-0-2', '3-1-1', '4-0-1']),
'target_weight' => fake()->optional(0.6)->randomFloat(2, 10, 120),
'notes' => fake()->optional(0.3)->sentence(8),
];
}
}
@@ -0,0 +1,50 @@
<?php
namespace Functional\Routines\Database\Factories;
use Functional\Routines\Models\Routine;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Routine>
*/
class RoutineFactory extends Factory
{
protected $model = Routine::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = fake()->randomElement([
'Full body débutant', 'Push day', 'Pull day', 'Leg day', 'Upper body',
'Circuit HIIT', 'Force 5x5', 'Hypertrophie bras', 'Core & mobilité',
]) . ' ' . fake()->numberBetween(1, 9);
return [
'user_id' => User::factory(),
'name' => $name,
'slug' => Str::slug($name) . '-' . fake()->unique()->numberBetween(1000, 9999),
'description' => fake()->boolean(70) ? fake()->sentence(12) : null,
'is_public' => fake()->boolean(30),
'estimated_minutes' => fake()->optional(0.8)->numberBetween(20, 90),
];
}
/** Routine partagée publiquement (visible du catalogue communautaire). */
public function public(): static
{
return $this->state(fn (array $attributes) => ['is_public' => true]);
}
/** Routine privée, réservée à son propriétaire. */
public function private(): static
{
return $this->state(fn (array $attributes) => ['is_public' => false]);
}
}
@@ -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('routines', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('name');
$table->string('slug')->index();
$table->text('description')->nullable();
$table->boolean('is_public')->default(false);
$table->unsignedInteger('estimated_minutes')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('routines');
}
};
@@ -0,0 +1,37 @@
<?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
{
// Pivot enrichi : un exercice positionné dans une routine, avec ses paramètres d'entraînement.
Schema::create('routine_exercises', function (Blueprint $table) {
$table->id();
$table->foreignId('routine_id')->constrained();
$table->foreignId('exercise_id')->constrained();
$table->unsignedInteger('position');
$table->unsignedInteger('sets');
$table->string('target_reps'); // ex "8-12"
$table->unsignedInteger('rest_seconds')->nullable();
$table->string('tempo')->nullable();
$table->decimal('target_weight', 8, 2)->nullable();
$table->text('notes')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('routine_exercises');
}
};
@@ -0,0 +1,17 @@
<?php
namespace Functional\Routines\Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class RoutinesSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
use Functional\Routines\Http\Controllers\RoutinesController;
use Illuminate\Support\Facades\Route;
// Routes here are wrapped in the 'api' middleware group with the 'api'
// prefix by the layer service provider. To version your endpoints
// (/api/v1/...), nest Route::prefix('v1')->group(...) inside.
// Scopées à l'utilisateur authentifié (auth()->id()).
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('routines', RoutinesController::class);
});
+6
View File
@@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Broadcast;
// Define broadcast channels here, e.g.:
// Broadcast::channel('App.Models.User.{id}', fn($user, $id) => (int) $user->id === (int) $id);
+6
View File
@@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Artisan;
// Define artisan commands here, e.g.:
// Artisan::command('layer:hello', fn() => info('Hello from this layer.'));
+7
View File
@@ -0,0 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
// Routes here are wrapped in the 'web' middleware group by the layer
// service provider. For custom groups (prefix, domain, named groups, ...)
// use Route::middleware(...)->prefix(...)->group(function () { ... });
@@ -0,0 +1,77 @@
<?php
namespace Functional\Routines\Http\Controllers;
use Functional\Routines\Http\Requests\StoreRoutineRequest;
use Functional\Routines\Http\Requests\UpdateRoutineRequest;
use Functional\Routines\Http\Resources\RoutineResource;
use Functional\Routines\Models\Routine;
use Functional\Routines\Services\RoutineService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response as HttpResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* CRUD des routines de l'utilisateur authentifié, strictement scopées par auth()->id().
*/
class RoutinesController
{
public function __construct(
private readonly RoutineService $routineService,
) {
}
/** GET /api/routines */
public function index(): AnonymousResourceCollection
{
$routines = Routine::query()
->where('user_id', auth()->id())
->with('routineExercises.exercise')
->orderBy('name')
->paginate();
return RoutineResource::collection($routines);
}
/** POST /api/routines */
public function store(StoreRoutineRequest $request): JsonResponse
{
$routine = $this->routineService->create($request->user(), $request->validated());
return (new RoutineResource($routine))
->response()
->setStatusCode(Response::HTTP_CREATED);
}
/** GET /api/routines/{routine} */
public function show(int $routine): RoutineResource
{
return new RoutineResource($this->findForUser($routine));
}
/** PUT /api/routines/{routine} */
public function update(UpdateRoutineRequest $request, int $routine): RoutineResource
{
$updated = $this->routineService->update($this->findForUser($routine), $request->validated());
return new RoutineResource($updated);
}
/** DELETE /api/routines/{routine} */
public function destroy(int $routine): HttpResponse
{
$this->findForUser($routine)->delete();
return response()->noContent();
}
/** Charge la routine (avec ses exercices) en la scopant strictement à l'utilisateur courant. */
private function findForUser(int $routineId): Routine
{
return Routine::query()
->where('user_id', auth()->id())
->with('routineExercises.exercise')
->findOrFail($routineId);
}
}
@@ -0,0 +1,36 @@
<?php
namespace Functional\Routines\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Validation de la création d'une routine, avec la liste (optionnelle) de ses exercices.
*/
class StoreRoutineRequest extends FormRequest
{
public function authorize(): bool
{
// Le scoping par utilisateur est géré par le contrôleur (auth:sanctum + user_id courant).
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'is_public' => ['sometimes', 'boolean'],
'estimated_minutes' => ['nullable', 'integer', 'min:1'],
'exercises' => ['sometimes', 'array'],
'exercises.*.exercise_id' => ['required', 'integer', 'exists:exercises,id'],
'exercises.*.position' => ['nullable', 'integer', 'min:0'],
'exercises.*.sets' => ['required', 'integer', 'min:1'],
'exercises.*.target_reps' => ['required', 'string', 'max:50'],
'exercises.*.rest_seconds' => ['nullable', 'integer', 'min:0'],
'exercises.*.tempo' => ['nullable', 'string', 'max:50'],
'exercises.*.target_weight' => ['nullable', 'numeric', 'min:0'],
'exercises.*.notes' => ['nullable', 'string'],
];
}
}
@@ -0,0 +1,36 @@
<?php
namespace Functional\Routines\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Validation de la mise à jour d'une routine. Les exercices, si fournis, remplacent
* intégralement le pivot routine_exercises existant.
*/
class UpdateRoutineRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'is_public' => ['sometimes', 'boolean'],
'estimated_minutes' => ['nullable', 'integer', 'min:1'],
'exercises' => ['sometimes', 'array'],
'exercises.*.exercise_id' => ['required', 'integer', 'exists:exercises,id'],
'exercises.*.position' => ['nullable', 'integer', 'min:0'],
'exercises.*.sets' => ['required', 'integer', 'min:1'],
'exercises.*.target_reps' => ['required', 'string', 'max:50'],
'exercises.*.rest_seconds' => ['nullable', 'integer', 'min:0'],
'exercises.*.tempo' => ['nullable', 'string', 'max:50'],
'exercises.*.target_weight' => ['nullable', 'numeric', 'min:0'],
'exercises.*.notes' => ['nullable', 'string'],
];
}
}
@@ -0,0 +1,32 @@
<?php
namespace Functional\Routines\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Une ligne du pivot routine_exercises, avec un sous-objet exercice minimal prêt-à-afficher.
*/
class RoutineExerciseResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'exercise_id' => $this->exercise_id,
'position' => $this->position,
'sets' => $this->sets,
'target_reps' => $this->target_reps,
'rest_seconds' => $this->rest_seconds,
'tempo' => $this->tempo,
'target_weight' => $this->target_weight,
'notes' => $this->notes,
'exercise' => [
'id' => $this->exercise->id,
'name' => $this->exercise->name,
'image_url' => $this->exercise->image_url,
'target' => $this->exercise->target,
],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace Functional\Routines\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Une routine, avec ses exercices ordonnés (chargés via routineExercises.exercise).
*/
class RoutineResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'is_public' => $this->is_public,
'estimated_minutes' => $this->estimated_minutes,
'exercises' => RoutineExerciseResource::collection($this->whenLoaded('routineExercises')),
];
}
}
@@ -0,0 +1,45 @@
<?php
namespace Functional\Routines\Models;
use Functional\Routines\Database\Factories\RoutineFactory;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Attributes\UseFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Une routine = liste ordonnée d'exercices avec séries/reps/repos, possédée par un utilisateur.
*
* @property string $name
* @property string $slug
* @property string|null $description
* @property bool $is_public
* @property int|null $estimated_minutes
*/
#[UseFactory(RoutineFactory::class)]
class Routine extends Model
{
use HasFactory;
protected $fillable = [
'user_id', 'name', 'slug', 'description', 'is_public', 'estimated_minutes',
];
protected $casts = [
'is_public' => 'boolean',
'estimated_minutes' => 'integer',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function routineExercises(): HasMany
{
return $this->hasMany(RoutineExercise::class)->orderBy('position');
}
}
@@ -0,0 +1,49 @@
<?php
namespace Functional\Routines\Models;
use Functional\Exercises\Models\Exercise;
use Functional\Routines\Database\Factories\RoutineExerciseFactory;
use Illuminate\Database\Eloquent\Attributes\UseFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Pivot enrichi : un exercice positionné dans une routine, avec ses paramètres d'entraînement.
*
* @property int $position
* @property int $sets
* @property string $target_reps
* @property int|null $rest_seconds
* @property string|null $tempo
* @property float|null $target_weight
* @property string|null $notes
*/
#[UseFactory(RoutineExerciseFactory::class)]
class RoutineExercise extends Model
{
use HasFactory;
protected $fillable = [
'routine_id', 'exercise_id', 'position', 'sets', 'target_reps',
'rest_seconds', 'tempo', 'target_weight', 'notes',
];
protected $casts = [
'position' => 'integer',
'sets' => 'integer',
'rest_seconds' => 'integer',
'target_weight' => 'decimal:2',
];
public function routine(): BelongsTo
{
return $this->belongsTo(Routine::class);
}
public function exercise(): BelongsTo
{
return $this->belongsTo(Exercise::class);
}
}
@@ -0,0 +1,30 @@
<?php
namespace Functional\Routines\Providers;
use Functional\Routines\Database\Seeders\RoutinesSeeder;
use Xefi\LaravelOSDD\LayerServiceProvider;
class RoutinesServiceProvider extends LayerServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
$this->loadSeeders([RoutinesSeeder::class]);
}
$this->withRouting(
web: __DIR__ . '/../../routes/web.php',
api: __DIR__ . '/../../routes/api.php',
commands: __DIR__ . '/../../routes/console.php',
channels: __DIR__ . '/../../routes/channels.php',
);
$this->loadSeeders([\Functional\Routines\Database\Seeders\RoutinesSeeder::class]);
}
public function register(): void
{
//
}
}
@@ -0,0 +1,69 @@
<?php
namespace Functional\Routines\Services;
use Functional\Routines\Models\Routine;
use Functional\Users\Models\User;
use Illuminate\Support\Str;
/**
* Création/mise à jour d'une routine et synchronisation de son pivot routine_exercises.
*/
class RoutineService
{
/** Champs propres à la routine (les exercices sont synchronisés à part). */
private const ROUTINE_FIELDS = ['name', 'description', 'is_public', 'estimated_minutes'];
public function create(User $user, array $data): Routine
{
$routine = new Routine($this->routineAttributes($data));
$routine->user_id = $user->id;
$routine->slug = Str::slug($data['name']);
$routine->save();
$this->syncExercises($routine, $data['exercises'] ?? []);
return $routine->load('routineExercises.exercise');
}
public function update(Routine $routine, array $data): Routine
{
$routine->fill($this->routineAttributes($data));
if (array_key_exists('name', $data)) {
$routine->slug = Str::slug($data['name']);
}
$routine->save();
if (array_key_exists('exercises', $data)) {
$this->syncExercises($routine, $data['exercises']);
}
return $routine->load('routineExercises.exercise');
}
private function routineAttributes(array $data): array
{
return array_intersect_key($data, array_flip(self::ROUTINE_FIELDS));
}
/** Remplace intégralement le pivot par la liste fournie (stratégie simple : purge puis recrée, dans l'ordre donné). */
private function syncExercises(Routine $routine, array $exercises): void
{
$routine->routineExercises()->delete();
foreach ($exercises as $index => $exercise) {
$routine->routineExercises()->create([
'exercise_id' => $exercise['exercise_id'],
'position' => $exercise['position'] ?? $index,
'sets' => $exercise['sets'],
'target_reps' => $exercise['target_reps'],
'rest_seconds' => $exercise['rest_seconds'] ?? null,
'tempo' => $exercise['tempo'] ?? null,
'target_weight' => $exercise['target_weight'] ?? null,
'notes' => $exercise['notes'] ?? null,
]);
}
}
}