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
@@ -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');
}
}