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/exercises",
"description": "",
"type": "layer",
"version": "1.0.0",
"require": {
"xefi/laravel-osdd": "*"
},
"autoload": {
"psr-4": {
"Functional\\Exercises\\": "src/",
"Functional\\Exercises\\Database\\Seeders\\": "database/seeders/",
"Functional\\Exercises\\Database\\Factories\\": "database/factories/"
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"extra": {
"laravel": {
"providers": [
"Functional\\Exercises\\Providers\\ExercisesServiceProvider"
]
}
}
}
@@ -0,0 +1,9 @@
<?php
return [
// Base URL publique des médias (RustFS/S3). Ex: https://s3.nfteam.ovh/streakfit
'media_base_url' => env('MEDIA_BASE_URL', 'https://s3.nfteam.ovh/streakfit'),
// Préfixe des objets média dans le bucket.
'media_prefix' => env('EXERCISES_MEDIA_PREFIX', 'exercises'),
];
@@ -0,0 +1,39 @@
<?php
namespace Functional\Exercises\Database\Factories;
use Functional\Exercises\Models\Exercise;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Exercise>
*/
class ExerciseFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = fake()->unique()->words(3, true);
$ext = fake()->unique()->numerify('####');
return [
'external_id' => $ext,
'name' => $name,
'slug' => \Illuminate\Support\Str::slug($name . '-' . $ext),
'category' => fake()->randomElement(['waist', 'chest', 'back', 'upper legs', 'shoulders']),
'body_part' => fake()->randomElement(['waist', 'chest', 'back', 'upper legs', 'shoulders']),
'equipment' => fake()->randomElement(['body weight', 'dumbbell', 'barbell', 'cable', 'kettlebell']),
'target' => fake()->randomElement(['abs', 'pectorals', 'lats', 'quads', 'delts']),
'muscle_group' => fake()->word(),
'secondary_muscles' => fake()->randomElements(['hip flexors', 'lower back', 'triceps', 'glutes'], 2),
'instructions' => ['en' => [fake()->sentence()], 'fr' => [fake()->sentence()]],
'image_path' => 'exercises/' . $ext . '.jpg',
'gif_path' => 'exercises/' . $ext . '.gif',
'attribution' => '© Gym visual — https://gymvisual.com/',
];
}
}
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('exercises', function (Blueprint $table) {
$table->id();
$table->string('external_id')->unique(); // "0001" du dataset
$table->string('name');
$table->string('slug')->index();
$table->string('category')->nullable()->index(); // waist, chest...
$table->string('body_part')->nullable()->index(); // upper arms, back...
$table->string('equipment')->nullable()->index(); // dumbbell, body weight...
$table->string('target')->nullable()->index(); // muscle cible principal
$table->string('muscle_group')->nullable(); // synergistes primaires
$table->json('secondary_muscles')->nullable(); // ["deltoid", ...]
$table->json('instructions')->nullable(); // { en:[...], fr:[...] }
$table->string('image_path')->nullable(); // clé S3 / chemin média
$table->string('gif_path')->nullable();
$table->string('attribution')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('exercises');
}
};
@@ -0,0 +1,17 @@
<?php
namespace Functional\Exercises\Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class ExercisesSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
use Functional\Exercises\Http\Controllers\ExercisesController;
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.
// Catalogue public, lecture seule — aucun middleware d'authentification.
// /facets doit être déclaré avant /{exercise} pour ne pas être capturé par le binding.
Route::get('/exercises/facets', [ExercisesController::class, 'facets']);
Route::get('/exercises', [ExercisesController::class, 'index']);
Route::get('/exercises/{exercise}', [ExercisesController::class, 'show']);
+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,115 @@
<?php
namespace Functional\Exercises\Console\Commands;
use Functional\Exercises\Models\Exercise;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Importe le dataset d'exercices (fork hasaneyldrm/exercises-dataset).
* JSON : upsert par external_id. --media : pousse aussi images+gifs vers RustFS/S3.
*/
class ImportExercisesCommand extends Command
{
protected $signature = 'exercises:import
{--path= : racine du dataset (contient data/exercises.json, images/, videos/)}
{--media : uploade aussi les images/gifs vers le disque S3 "media"}
{--langs=en,fr : langues d instructions à conserver (pt-BR retombe sur en)}
{--fresh : vide la table avant import}';
protected $description = "Importe le dataset d'exercices (JSON) et, avec --media, les médias vers RustFS/S3.";
public function handle(): int
{
$root = rtrim($this->option('path') ?: storage_path('app/dataset'), '/');
$json = is_file("$root/data/exercises.json") ? "$root/data/exercises.json" : "$root/exercises.json";
if (! is_file($json)) {
$this->error("exercises.json introuvable sous {$root}");
return self::FAILURE;
}
$langs = array_values(array_filter(array_map('trim', explode(',', (string) $this->option('langs')))));
$records = json_decode((string) file_get_contents($json), true);
if (! is_array($records)) {
$this->error('JSON invalide');
return self::FAILURE;
}
if ($this->option('fresh')) {
Exercise::query()->delete();
$this->warn('Table exercises vidée.');
}
$media = (bool) $this->option('media');
$prefix = trim((string) config('exercises.media_prefix', 'exercises'), '/');
$disk = $media ? Storage::disk('media') : null;
$bar = $this->output->createProgressBar(count($records));
$bar->start();
$imported = 0;
$uploaded = 0;
foreach ($records as $r) {
$ext = (string) ($r['id'] ?? '');
if ($ext === '') {
$bar->advance();
continue;
}
// Instructions : on garde les étapes (arrays) des langues demandées.
$steps = $r['instruction_steps'] ?? [];
$instructions = [];
foreach ($langs as $l) {
if (! empty($steps[$l])) {
$instructions[$l] = array_values($steps[$l]);
}
}
$imgKey = ! empty($r['image']) ? "{$prefix}/{$ext}.jpg" : null;
$gifKey = ! empty($r['gif_url']) ? "{$prefix}/{$ext}.gif" : null;
if ($media && $disk) {
foreach ([[$r['image'] ?? null, $imgKey], [$r['gif_url'] ?? null, $gifKey]] as [$rel, $key]) {
if ($rel && $key && is_file("{$root}/{$rel}") && ! $disk->exists($key)) {
$disk->put($key, (string) file_get_contents("{$root}/{$rel}"), 'public');
$uploaded++;
}
}
}
Exercise::updateOrCreate(
['external_id' => $ext],
[
'name' => $r['name'] ?? $ext,
'slug' => Str::slug(($r['name'] ?? $ext) . '-' . $ext),
'category' => $r['category'] ?? null,
'body_part' => $r['body_part'] ?? null,
'equipment' => $r['equipment'] ?? null,
'target' => $r['target'] ?? null,
'muscle_group' => $r['muscle_group'] ?? null,
'secondary_muscles' => $r['secondary_muscles'] ?? [],
'instructions' => $instructions,
'image_path' => $imgKey,
'gif_path' => $gifKey,
'attribution' => $r['attribution'] ?? null,
]
);
$imported++;
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info("Importé/à jour : {$imported} exercices" . ($media ? " (+ {$uploaded} médias S3)" : ''));
return self::SUCCESS;
}
}
@@ -0,0 +1,74 @@
<?php
namespace Functional\Exercises\Http\Controllers;
use Functional\Exercises\Http\Resources\ExerciseListResource;
use Functional\Exercises\Http\Resources\ExerciseResource;
use Functional\Exercises\Models\Exercise;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* Catalogue d'exercices : lecture seule et public, aucun scoping par utilisateur.
*/
class ExercisesController
{
private const DEFAULT_LOCALE = 'fr';
private const DEFAULT_PER_PAGE = 20;
/** GET /api/exercises — liste filtrable (search/body_part/equipment/target/category) et paginée. */
public function index(Request $request): AnonymousResourceCollection
{
$exercises = Exercise::query()
->search($request->query('search'))
->when($request->query('body_part'), fn ($query, $bodyPart) => $query->where('body_part', $bodyPart))
->when($request->query('equipment'), fn ($query, $equipment) => $query->where('equipment', $equipment))
->when($request->query('target'), fn ($query, $target) => $query->where('target', $target))
->when($request->query('category'), fn ($query, $category) => $query->where('category', $category))
->orderBy('name')
->paginate((int) $request->query('per_page', self::DEFAULT_PER_PAGE));
return ExerciseListResource::collection($exercises);
}
/** GET /api/exercises/{exercise} — détail avec instructions dans la locale demandée. */
public function show(Request $request, Exercise $exercise): ExerciseResource
{
return new ExerciseResource($exercise, $this->resolveLocale($request));
}
/** GET /api/exercises/facets — valeurs distinctes pour alimenter les filtres du front. */
public function facets(): JsonResponse
{
return response()->json([
'body_parts' => Exercise::query()->whereNotNull('body_part')->distinct()->orderBy('body_part')->pluck('body_part')->values(),
'equipments' => Exercise::query()->whereNotNull('equipment')->distinct()->orderBy('equipment')->pluck('equipment')->values(),
'targets' => Exercise::query()->whereNotNull('target')->distinct()->orderBy('target')->pluck('target')->values(),
'categories' => Exercise::query()->whereNotNull('category')->distinct()->orderBy('category')->pluck('category')->values(),
]);
}
/** Résout la locale demandée : paramètre `locale`, sinon header Accept-Language, sinon 'fr'. */
private function resolveLocale(Request $request): string
{
$locale = $request->query('locale');
if ($locale) {
return (string) $locale;
}
$acceptLanguage = $request->header('Accept-Language');
if (! $acceptLanguage) {
return self::DEFAULT_LOCALE;
}
// Le header peut lister plusieurs langues pondérées ("fr-FR,fr;q=0.9,en;q=0.8") : on ne garde que la première.
$firstLanguage = strtok($acceptLanguage, ',');
$languageTag = strtok($firstLanguage, ';');
return $languageTag !== false ? $languageTag : self::DEFAULT_LOCALE;
}
}
@@ -0,0 +1,26 @@
<?php
namespace Functional\Exercises\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Forme allégée d'un exercice pour les listes (GET /api/exercises).
*/
class ExerciseListResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'body_part' => $this->body_part,
'equipment' => $this->equipment,
'target' => $this->target,
'image_url' => $this->image_url,
'gif_url' => $this->gif_url,
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace Functional\Exercises\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Détail complet d'un exercice (GET /api/exercises/{id}), instructions localisées.
*/
class ExerciseResource extends JsonResource
{
public function __construct($resource, private readonly string $locale)
{
parent::__construct($resource);
}
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'category' => $this->category,
'equipment' => $this->equipment,
'target' => $this->target,
'muscle_group' => $this->muscle_group,
'secondary_muscles' => $this->secondary_muscles ?? [],
'instructions' => $this->resource->instructionsFor($this->locale),
'image_url' => $this->image_url,
'gif_url' => $this->gif_url,
'attribution' => $this->attribution,
];
}
}
@@ -0,0 +1,101 @@
<?php
namespace Functional\Exercises\Models;
use Functional\Exercises\Database\Factories\ExerciseFactory;
use Illuminate\Database\Eloquent\Attributes\UseFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* Catalogue d'exercices (lecture seule, alimenté par l'import du dataset).
*
* @property string $external_id
* @property string $name
* @property array|null $secondary_muscles
* @property array|null $instructions
*/
#[UseFactory(ExerciseFactory::class)]
class Exercise extends Model
{
use HasFactory;
protected $fillable = [
'external_id', 'name', 'slug', 'category', 'body_part', 'equipment',
'target', 'muscle_group', 'secondary_muscles', 'instructions',
'image_path', 'gif_path', 'attribution',
];
protected $casts = [
'secondary_muscles' => 'array',
'instructions' => 'array',
];
protected $appends = ['image_url', 'gif_url'];
/** URL publique du thumbnail (média servi depuis RustFS/S3). */
public function imageUrl(): ?string
{
return $this->mediaUrl($this->image_path);
}
public function gifUrl(): ?string
{
return $this->mediaUrl($this->gif_path);
}
public function getImageUrlAttribute(): ?string
{
return $this->imageUrl();
}
public function getGifUrlAttribute(): ?string
{
return $this->gifUrl();
}
private function mediaUrl(?string $path): ?string
{
if (! $path) {
return null;
}
return rtrim((string) config('exercises.media_base_url'), '/') . '/' . ltrim($path, '/');
}
/**
* Instructions dans la locale demandée, avec repli.
* pt-BR n'existe pas dans le dataset repli en/fr.
*/
public function instructionsFor(string $locale): array
{
$data = $this->instructions ?? [];
$chain = [$locale, substr($locale, 0, 2), 'en', 'fr'];
foreach ($chain as $lang) {
if (! empty($data[$lang])) {
return (array) $data[$lang];
}
}
return [];
}
/** Recherche plein-texte simple (nom / muscle cible / équipement). */
public function scopeSearch(Builder $query, ?string $term): Builder
{
if (! $term) {
return $query;
}
$like = '%' . str_replace('%', '\%', $term) . '%';
return $query->where(function (Builder $q) use ($like) {
$q->where('name', 'like', $like)
->orWhere('target', 'like', $like)
->orWhere('equipment', 'like', $like)
->orWhere('body_part', 'like', $like);
});
}
}
@@ -0,0 +1,32 @@
<?php
namespace Functional\Exercises\Providers;
use Functional\Exercises\Console\Commands\ImportExercisesCommand;
use Functional\Exercises\Database\Seeders\ExercisesSeeder;
use Xefi\LaravelOSDD\LayerServiceProvider;
class ExercisesServiceProvider extends LayerServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
$this->loadSeeders([ExercisesSeeder::class]);
$this->commands([ImportExercisesCommand::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\Exercises\Database\Seeders\ExercisesSeeder::class]);
}
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/exercises.php', 'exercises');
}
}