Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "functional/programs",
|
||||
"description": "",
|
||||
"type": "layer",
|
||||
"version": "1.0.0",
|
||||
"require": {
|
||||
"xefi/laravel-osdd": "*"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Functional\\Programs\\": "src/",
|
||||
"Functional\\Programs\\Database\\Seeders\\": "database/seeders/",
|
||||
"Functional\\Programs\\Database\\Factories\\": "database/factories/"
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Functional\\Programs\\Providers\\ProgramsServiceProvider"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Functional\Programs\Database\Factories;
|
||||
|
||||
use Functional\Programs\Models\Program;
|
||||
use Functional\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<Program>
|
||||
*/
|
||||
class ProgramFactory extends Factory
|
||||
{
|
||||
protected $model = Program::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$name = fake()->randomElement([
|
||||
'Prise de masse 8 semaines', 'Sèche été', 'Préparation force',
|
||||
'Remise en forme progressive', 'Cross-training intensif', 'Retour de blessure',
|
||||
]) . ' ' . 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(15) : null,
|
||||
'duration_weeks' => fake()->numberBetween(4, 16),
|
||||
'days_per_week' => fake()->optional(0.8)->numberBetween(2, 6),
|
||||
'is_public' => fake()->boolean(25),
|
||||
];
|
||||
}
|
||||
|
||||
/** Programme partagé publiquement (visible du catalogue communautaire). */
|
||||
public function public(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => ['is_public' => true]);
|
||||
}
|
||||
|
||||
/** Programme privé, réservé à son propriétaire. */
|
||||
public function private(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => ['is_public' => false]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Functional\Programs\Database\Factories;
|
||||
|
||||
use Functional\Programs\Models\Program;
|
||||
use Functional\Programs\Models\ProgramSlot;
|
||||
use Functional\Routines\Models\Routine;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ProgramSlot>
|
||||
*/
|
||||
class ProgramSlotFactory extends Factory
|
||||
{
|
||||
protected $model = ProgramSlot::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'program_id' => Program::factory(),
|
||||
'week' => fake()->numberBetween(1, 8),
|
||||
'weekday' => fake()->numberBetween(1, 7),
|
||||
'routine_id' => fake()->boolean(85) ? Routine::factory() : null,
|
||||
'label' => fake()->optional(0.3)->randomElement(['Repos actif', 'Séance test', 'Deload']),
|
||||
];
|
||||
}
|
||||
|
||||
/** Case de repos, sans routine associée. */
|
||||
public function restDay(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'routine_id' => null,
|
||||
'label' => 'Repos',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('programs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained();
|
||||
$table->string('name');
|
||||
$table->string('slug')->index();
|
||||
$table->text('description')->nullable();
|
||||
$table->unsignedInteger('duration_weeks');
|
||||
$table->unsignedTinyInteger('days_per_week')->nullable();
|
||||
$table->boolean('is_public')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('programs');
|
||||
}
|
||||
};
|
||||
+33
@@ -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
|
||||
{
|
||||
// Une case du planning : une semaine/jour donnée du programme, optionnellement liée à une routine.
|
||||
Schema::create('program_slots', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('program_id')->constrained();
|
||||
$table->unsignedInteger('week'); // 1..duration_weeks
|
||||
$table->unsignedTinyInteger('weekday'); // 1 (lundi) .. 7 (dimanche)
|
||||
$table->foreignId('routine_id')->nullable()->constrained();
|
||||
$table->string('label')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('program_slots');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Functional\Programs\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ProgramsSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
use Functional\Programs\Http\Controllers\ProgramsController;
|
||||
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és à l'utilisateur authentifié (auth()->id()).
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
Route::apiResource('programs', ProgramsController::class);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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.'));
|
||||
@@ -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\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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user