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,77 @@
<?php
namespace Functional\Programs\Http\Controllers;
use Functional\Programs\Http\Requests\StoreProgramRequest;
use Functional\Programs\Http\Requests\UpdateProgramRequest;
use Functional\Programs\Http\Resources\ProgramResource;
use Functional\Programs\Models\Program;
use Functional\Programs\Services\ProgramService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response as HttpResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* CRUD des programmes de l'utilisateur authentifié, strictement scopés par auth()->id().
*/
class ProgramsController
{
public function __construct(
private readonly ProgramService $programService,
) {
}
/** GET /api/programs */
public function index(): AnonymousResourceCollection
{
$programs = Program::query()
->where('user_id', auth()->id())
->with('programSlots')
->orderBy('name')
->paginate();
return ProgramResource::collection($programs);
}
/** POST /api/programs */
public function store(StoreProgramRequest $request): JsonResponse
{
$program = $this->programService->create($request->user(), $request->validated());
return (new ProgramResource($program))
->response()
->setStatusCode(Response::HTTP_CREATED);
}
/** GET /api/programs/{program} */
public function show(int $program): ProgramResource
{
return new ProgramResource($this->findForUser($program));
}
/** PUT /api/programs/{program} */
public function update(UpdateProgramRequest $request, int $program): ProgramResource
{
$updated = $this->programService->update($this->findForUser($program), $request->validated());
return new ProgramResource($updated);
}
/** DELETE /api/programs/{program} */
public function destroy(int $program): HttpResponse
{
$this->findForUser($program)->delete();
return response()->noContent();
}
/** Charge le programme (avec son planning) en le scopant strictement à l'utilisateur courant. */
private function findForUser(int $programId): Program
{
return Program::query()
->where('user_id', auth()->id())
->with('programSlots')
->findOrFail($programId);
}
}
@@ -0,0 +1,37 @@
<?php
namespace Functional\Programs\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Validation de la création d'un programme, avec la liste (optionnelle) de ses créneaux (slots).
*/
class StoreProgramRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'duration_weeks' => ['required', 'integer', 'min:1'],
'days_per_week' => ['nullable', 'integer', 'between:1,7'],
'is_public' => ['sometimes', 'boolean'],
'slots' => ['sometimes', 'array'],
'slots.*.week' => ['required', 'integer', 'min:1'],
'slots.*.weekday' => ['required', 'integer', 'between:1,7'],
'slots.*.routine_id' => [
'nullable',
'integer',
Rule::exists('routines', 'id')->where(fn ($query) => $query->where('user_id', $this->user()?->id)),
],
'slots.*.label' => ['nullable', 'string', 'max:255'],
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace Functional\Programs\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Validation de la mise à jour d'un programme. Les slots, si fournis, remplacent
* intégralement le planning existant.
*/
class UpdateProgramRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'duration_weeks' => ['sometimes', 'integer', 'min:1'],
'days_per_week' => ['nullable', 'integer', 'between:1,7'],
'is_public' => ['sometimes', 'boolean'],
'slots' => ['sometimes', 'array'],
'slots.*.week' => ['required', 'integer', 'min:1'],
'slots.*.weekday' => ['required', 'integer', 'between:1,7'],
'slots.*.routine_id' => [
'nullable',
'integer',
Rule::exists('routines', 'id')->where(fn ($query) => $query->where('user_id', $this->user()?->id)),
],
'slots.*.label' => ['nullable', 'string', 'max:255'],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace Functional\Programs\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Un programme, avec son planning de créneaux (chargé via programSlots).
*/
class ProgramResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'duration_weeks' => $this->duration_weeks,
'days_per_week' => $this->days_per_week,
'slots' => ProgramSlotResource::collection($this->whenLoaded('programSlots')),
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace Functional\Programs\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Une case du planning d'un programme (semaine/jour, routine optionnelle).
*/
class ProgramSlotResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'week' => $this->week,
'weekday' => $this->weekday,
'routine_id' => $this->routine_id,
'label' => $this->label,
];
}
}
@@ -0,0 +1,47 @@
<?php
namespace Functional\Programs\Models;
use Functional\Programs\Database\Factories\ProgramFactory;
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;
/**
* Un programme = planning de routines sur plusieurs semaines, possédé par un utilisateur.
*
* @property string $name
* @property string $slug
* @property string|null $description
* @property int $duration_weeks
* @property int|null $days_per_week
* @property bool $is_public
*/
#[UseFactory(ProgramFactory::class)]
class Program extends Model
{
use HasFactory;
protected $fillable = [
'user_id', 'name', 'slug', 'description', 'duration_weeks', 'days_per_week', 'is_public',
];
protected $casts = [
'duration_weeks' => 'integer',
'days_per_week' => 'integer',
'is_public' => 'boolean',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function programSlots(): HasMany
{
return $this->hasMany(ProgramSlot::class);
}
}
@@ -0,0 +1,42 @@
<?php
namespace Functional\Programs\Models;
use Functional\Programs\Database\Factories\ProgramSlotFactory;
use Functional\Routines\Models\Routine;
use Illuminate\Database\Eloquent\Attributes\UseFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Une case du planning d'un programme : semaine/jour, optionnellement liée à une routine.
*
* @property int $week
* @property int $weekday
* @property string|null $label
*/
#[UseFactory(ProgramSlotFactory::class)]
class ProgramSlot extends Model
{
use HasFactory;
protected $fillable = [
'program_id', 'week', 'weekday', 'routine_id', 'label',
];
protected $casts = [
'week' => 'integer',
'weekday' => 'integer',
];
public function program(): BelongsTo
{
return $this->belongsTo(Program::class);
}
public function routine(): BelongsTo
{
return $this->belongsTo(Routine::class);
}
}
@@ -0,0 +1,30 @@
<?php
namespace Functional\Programs\Providers;
use Functional\Programs\Database\Seeders\ProgramsSeeder;
use Xefi\LaravelOSDD\LayerServiceProvider;
class ProgramsServiceProvider extends LayerServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
$this->loadSeeders([ProgramsSeeder::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\Programs\Database\Seeders\ProgramsSeeder::class]);
}
public function register(): void
{
//
}
}
@@ -0,0 +1,65 @@
<?php
namespace Functional\Programs\Services;
use Functional\Programs\Models\Program;
use Functional\Users\Models\User;
use Illuminate\Support\Str;
/**
* Création/mise à jour d'un programme et synchronisation de son planning (program_slots).
*/
class ProgramService
{
/** Champs propres au programme (les slots sont synchronisés à part). */
private const PROGRAM_FIELDS = ['name', 'description', 'duration_weeks', 'days_per_week', 'is_public'];
public function create(User $user, array $data): Program
{
$program = new Program($this->programAttributes($data));
$program->user_id = $user->id;
$program->slug = Str::slug($data['name']);
$program->save();
$this->syncSlots($program, $data['slots'] ?? []);
return $program->load('programSlots');
}
public function update(Program $program, array $data): Program
{
$program->fill($this->programAttributes($data));
if (array_key_exists('name', $data)) {
$program->slug = Str::slug($data['name']);
}
$program->save();
if (array_key_exists('slots', $data)) {
$this->syncSlots($program, $data['slots']);
}
return $program->load('programSlots');
}
private function programAttributes(array $data): array
{
return array_intersect_key($data, array_flip(self::PROGRAM_FIELDS));
}
/** Remplace intégralement le planning par la liste fournie (purge puis recrée). */
private function syncSlots(Program $program, array $slots): void
{
$program->programSlots()->delete();
foreach ($slots as $slot) {
$program->programSlots()->create([
'week' => $slot['week'],
'weekday' => $slot['weekday'],
'routine_id' => $slot['routine_id'] ?? null,
'label' => $slot['label'] ?? null,
]);
}
}
}