Files
crewli/api/app/Http/Requests/Api/V1/Artist/MoveTimetablePerformanceRequest.php
bert.hausmans bb1bd8361a feat(timetable): 13 form requests for artist domain endpoints
Created under app/Http/Requests/Api/V1/Artist/, mirroring the
existing FormRequest pattern (final class, authorize() returns true,
controller-level Gate::authorize). One request per CRUD shape plus the
two domain-specific endpoints:

  artists                     create / update
  genres                      create / update (with org-scoped unique)
  stages                      create / update (with event-scoped unique)
  stages/order                ReorderStagesRequest — permutation check
  engagements                 create / update — per RFC §10.3, with
                              ContractRequiresFee + OptionExpiresInFuture
                              conditional rules wired
  performances                create / update — per §10.2; cross-FK
                              engagement.event_id ↔ event_id chain
                              enforced via withValidator closure;
                              update is non-placement only (placement
                              edits go through /timetable/move)
  timetable/move              per §10.4; resolves target_event_id from
                              target_stage_id + target_start_at via
                              stage_days, then reuses StageActiveOnEvent
                              + WithinEventBounds for downstream rules
  stages/{stage}/days         §10.5 matrix replace; each event_id must
                              equal stage.event_id (flat) or be sub-event
                              (festival)

Custom error messages in Dutch where user-facing. Cross-FK rules that
span request inputs (engagement vs event-id chain, day matrix sub-event
membership) live in withValidator after-closures so the rule cache is
stable per request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:51:59 +02:00

108 lines
3.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Event;
use App\Models\Stage;
use App\Models\StageDay;
use App\Rules\Artist\StageActiveOnEvent;
use App\Rules\Artist\WithinEventBounds;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* RFC v0.2 §10.4 — D18 transactional move endpoint.
*/
final class MoveTimetablePerformanceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$event = $this->route('event');
$organisationId = $event instanceof Event ? $event->organisation_id : null;
$resolvedEventId = $this->resolveTargetEventId();
return [
'performance_id' => [
'required', 'string', 'max:30',
Rule::exists('performances', 'id')->where('organisation_id', $organisationId),
],
'target_stage_id' => [
'nullable', 'string', 'max:30',
Rule::exists('stages', 'id'),
new StageActiveOnEvent($resolvedEventId),
],
'target_start_at' => [
'nullable', 'date_format:Y-m-d H:i:s',
'required_unless:target_stage_id,null',
new WithinEventBounds($resolvedEventId),
],
'target_end_at' => [
'nullable', 'date_format:Y-m-d H:i:s',
'required_unless:target_stage_id,null',
'after:target_start_at',
new WithinEventBounds($resolvedEventId),
],
'target_lane' => ['nullable', 'integer', 'min:0', 'max:9'],
'version' => ['required', 'integer', 'min:0'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
// When target_stage_id is non-null, target_lane must be set
// (the move algorithm requires a definite lane).
if ($this->input('target_stage_id') !== null && $this->input('target_lane') === null) {
$validator->errors()->add('target_lane', 'target_lane is verplicht bij een niet-leeg target_stage_id.');
}
});
}
/**
* Resolve the event_id the candidate move lands on so the
* StageActiveOnEvent and WithinEventBounds rules can validate
* against a concrete event window.
*
* For flat events: stage.event_id is the answer.
* For festivals: walk stage_days for target_stage_id and find the
* sub-event whose [start, end] contains target_start_at.
*/
private function resolveTargetEventId(): ?string
{
$stageId = $this->input('target_stage_id');
$startAt = $this->input('target_start_at');
if (! is_string($stageId) || ! is_string($startAt)) {
return null;
}
$start = CarbonImmutable::parse($startAt);
$stage = Stage::query()->find($stageId);
if ($stage === null) {
return null;
}
$match = StageDay::query()
->where('stage_id', $stage->id)
->join('events', 'events.id', '=', 'stage_days.event_id')
->where('events.start_at', '<=', $start)
->where('events.end_at', '>=', $start)
->orderBy('events.start_at', 'desc')
->limit(1)
->value('stage_days.event_id');
return is_string($match) ? $match : $stage->event_id;
}
}