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>
This commit is contained in:
2026-05-08 20:51:59 +02:00
parent 378b6fe970
commit bb1bd8361a
13 changed files with 724 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Enums\Artist\BumaHandledBy;
use App\Enums\Artist\FeeType;
use App\Enums\Artist\PaymentStatus;
use App\Models\Event;
use App\Rules\Artist\ContractRequiresFee;
use App\Rules\Artist\OptionExpiresInFuture;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* RFC v0.2 §10.3.
*/
final class CreateArtistEngagementRequest 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;
$bookingStatus = $this->input('booking_status');
return [
'artist_id' => [
'required', 'string', 'max:30',
Rule::exists('artists', 'id')->where('organisation_id', $organisationId),
],
'booking_status' => ['required', Rule::enum(ArtistEngagementStatus::class)],
'project_leader_id' => ['nullable', 'string', 'max:30', 'exists:users,id'],
'fee_amount' => ['nullable', 'numeric', 'min:0', 'max:9999999.99', new ContractRequiresFee($bookingStatus)],
'fee_currency' => ['nullable', 'string', 'size:3', Rule::in(['EUR', 'USD', 'GBP'])],
'fee_type' => ['nullable', Rule::enum(FeeType::class)],
'buma_applicable' => ['nullable', 'boolean'],
'buma_percentage' => ['nullable', 'numeric', 'min:0', 'max:100'],
'buma_handled_by' => ['nullable', Rule::enum(BumaHandledBy::class)],
'vat_applicable' => ['nullable', 'boolean'],
'vat_percentage' => ['nullable', 'numeric', 'min:0', 'max:100'],
'deal_breakdown' => ['nullable', 'array'],
'deposit_percentage' => ['nullable', 'numeric', 'min:0', 'max:100'],
'deposit_due_date' => ['nullable', 'date'],
'balance_due_date' => ['nullable', 'date'],
'payment_status' => ['nullable', Rule::enum(PaymentStatus::class)],
'crew_count' => ['nullable', 'integer', 'min:0', 'max:200'],
'guests_count' => ['nullable', 'integer', 'min:0', 'max:1000'],
'requested_at' => ['nullable', 'date'],
'option_expires_at' => ['nullable', 'date', new OptionExpiresInFuture($bookingStatus)],
'advance_open_from' => ['nullable', 'date'],
'advance_open_to' => ['nullable', 'date', 'after_or_equal:advance_open_from'],
'notes' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Organisation;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* RFC v0.2 §5.3 (artists table) + §10.3 derived shape.
*
* Authorization is handled in the controller via Gate::authorize per
* the codebase convention; this request returns true.
*/
final class CreateArtistRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$organisationId = $this->route('organisation') instanceof Organisation
? $this->route('organisation')->id
: (string) $this->route('organisation');
return [
'name' => ['required', 'string', 'max:120'],
'default_genre_id' => [
'nullable', 'string', 'max:30',
Rule::exists('genres', 'id')->where('organisation_id', $organisationId),
],
'default_draw' => ['nullable', 'integer', 'min:0'],
'star_rating' => ['nullable', 'integer', 'between:1,5'],
'home_base_country' => ['nullable', 'string', 'size:2', 'alpha'],
'agent_company_id' => [
'nullable', 'string', 'max:30',
Rule::exists('companies', 'id')->where('organisation_id', $organisationId),
],
'notes' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Organisation;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class CreateGenreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$organisationId = $this->route('organisation') instanceof Organisation
? $this->route('organisation')->id
: (string) $this->route('organisation');
return [
'name' => [
'required', 'string', 'max:40',
Rule::unique('genres', 'name')->where('organisation_id', $organisationId),
],
'color' => ['nullable', 'string', 'size:7', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'sort_order' => ['nullable', 'integer', 'min:0'],
'is_active' => ['nullable', 'boolean'],
];
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Rules\Artist\StageActiveOnEvent;
use App\Rules\Artist\WithinEventBounds;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* RFC v0.2 §10.2.
*/
final class CreatePerformanceRequest 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;
$eventIdInput = (string) $this->input('event_id', '');
return [
'engagement_id' => [
'required', 'string', 'max:30',
Rule::exists('artist_engagements', 'id')->where('organisation_id', $organisationId),
],
'event_id' => [
'required', 'string', 'max:30',
Rule::exists('events', 'id')->where('organisation_id', $organisationId),
],
'stage_id' => [
'nullable', 'string', 'max:30',
Rule::exists('stages', 'id'),
new StageActiveOnEvent($eventIdInput),
],
'start_at' => ['required', 'date_format:Y-m-d H:i:s', new WithinEventBounds($eventIdInput)],
'end_at' => ['required', 'date_format:Y-m-d H:i:s', 'after:start_at', new WithinEventBounds($eventIdInput)],
'lane' => ['nullable', 'integer', 'min:0', 'max:9'],
'notes' => ['nullable', 'string', 'max:1000'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$engagementId = $this->input('engagement_id');
$eventId = $this->input('event_id');
if (! is_string($engagementId) || ! is_string($eventId)) {
return;
}
$engagement = ArtistEngagement::query()->find($engagementId);
if ($engagement === null) {
return;
}
$event = Event::withoutGlobalScopes()->find($eventId);
if ($event === null) {
return;
}
// event_id must equal engagement.event_id (flat case) OR be a
// sub-event of engagement.event_id (festival case).
if (
$eventId !== $engagement->event_id
&& $event->parent_event_id !== $engagement->event_id
) {
$validator->errors()->add(
'event_id',
'event_id moet gelijk zijn aan de engagement.event_id of een sub-event daarvan.',
);
}
});
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Event;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class CreateStageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$eventId = $this->route('event') instanceof Event
? $this->route('event')->id
: (string) $this->route('event');
return [
'name' => [
'required', 'string', 'max:120',
Rule::unique('stages', 'name')->where('event_id', $eventId),
],
'color' => ['required', 'string', 'size:7', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'capacity' => ['nullable', 'integer', 'min:0'],
'sort_order' => ['nullable', 'integer', 'min:0'],
];
}
}

View File

@@ -0,0 +1,107 @@
<?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;
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Event;
use App\Models\Stage;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
final class ReorderStagesRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'stage_ids' => ['required', 'array', 'min:1'],
'stage_ids.*' => ['string', 'max:30'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$event = $this->route('event');
if (! $event instanceof Event) {
return;
}
$submitted = (array) $this->input('stage_ids', []);
$existing = Stage::query()
->where('event_id', $event->id)
->pluck('id')
->all();
$missing = array_diff($existing, $submitted);
$extra = array_diff($submitted, $existing);
if ($missing !== [] || $extra !== []) {
$validator->errors()->add(
'stage_ids',
'stage_ids moet een permutatie zijn van alle stages op dit evenement.',
);
}
});
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Event;
use App\Models\Stage;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* RFC v0.2 §10.5 atomic stage_days matrix replace.
*/
final class ReplaceStageDaysRequest 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;
return [
'event_ids' => ['required', 'array', 'min:1'],
'event_ids.*' => [
'string', 'max:30',
Rule::exists('events', 'id')->where('organisation_id', $organisationId),
],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$stage = $this->route('stage');
if (! $stage instanceof Stage) {
return;
}
$eventIds = (array) $this->input('event_ids', []);
$events = Event::withoutGlobalScopes()
->whereIn('id', $eventIds)
->get(['id', 'parent_event_id']);
foreach ($events as $event) {
$isFlatMatch = $event->id === $stage->event_id;
$isSubEventMatch = $event->parent_event_id === $stage->event_id;
if (! $isFlatMatch && ! $isSubEventMatch) {
$validator->errors()->add(
'event_ids',
sprintf(
'event_id %s is geen sub-event van of gelijk aan stage.event_id (%s).',
$event->id,
$stage->event_id,
),
);
}
}
});
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Enums\Artist\BumaHandledBy;
use App\Enums\Artist\FeeType;
use App\Enums\Artist\PaymentStatus;
use App\Models\ArtistEngagement;
use App\Rules\Artist\ContractRequiresFee;
use App\Rules\Artist\OptionExpiresInFuture;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class UpdateArtistEngagementRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$engagement = $this->route('engagement');
$effectiveStatus = $this->input(
'booking_status',
$engagement instanceof ArtistEngagement
? ($engagement->booking_status?->value ?? null)
: null,
);
return [
'booking_status' => ['sometimes', Rule::enum(ArtistEngagementStatus::class)],
'project_leader_id' => ['sometimes', 'nullable', 'string', 'max:30', 'exists:users,id'],
'fee_amount' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:9999999.99', new ContractRequiresFee($effectiveStatus)],
'fee_currency' => ['sometimes', 'nullable', 'string', 'size:3', Rule::in(['EUR', 'USD', 'GBP'])],
'fee_type' => ['sometimes', 'nullable', Rule::enum(FeeType::class)],
'buma_applicable' => ['sometimes', 'boolean'],
'buma_percentage' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:100'],
'buma_handled_by' => ['sometimes', 'nullable', Rule::enum(BumaHandledBy::class)],
'vat_applicable' => ['sometimes', 'boolean'],
'vat_percentage' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:100'],
'deal_breakdown' => ['sometimes', 'nullable', 'array'],
'deposit_percentage' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:100'],
'deposit_due_date' => ['sometimes', 'nullable', 'date'],
'balance_due_date' => ['sometimes', 'nullable', 'date'],
'payment_status' => ['sometimes', 'nullable', Rule::enum(PaymentStatus::class)],
'crew_count' => ['sometimes', 'nullable', 'integer', 'min:0', 'max:200'],
'guests_count' => ['sometimes', 'nullable', 'integer', 'min:0', 'max:1000'],
'requested_at' => ['sometimes', 'nullable', 'date'],
'option_expires_at' => ['sometimes', 'nullable', 'date', new OptionExpiresInFuture($effectiveStatus)],
'advance_open_from' => ['sometimes', 'nullable', 'date'],
'advance_open_to' => ['sometimes', 'nullable', 'date', 'after_or_equal:advance_open_from'],
'notes' => ['sometimes', 'nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Artist;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class UpdateArtistRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$artist = $this->route('artist');
$organisationId = $artist instanceof Artist
? $artist->organisation_id
: ($this->route('organisation')?->id ?? null);
return [
'name' => ['sometimes', 'required', 'string', 'max:120'],
'default_genre_id' => [
'sometimes', 'nullable', 'string', 'max:30',
Rule::exists('genres', 'id')->where('organisation_id', $organisationId),
],
'default_draw' => ['sometimes', 'nullable', 'integer', 'min:0'],
'star_rating' => ['sometimes', 'nullable', 'integer', 'between:1,5'],
'home_base_country' => ['sometimes', 'nullable', 'string', 'size:2', 'alpha'],
'agent_company_id' => [
'sometimes', 'nullable', 'string', 'max:30',
Rule::exists('companies', 'id')->where('organisation_id', $organisationId),
],
'notes' => ['sometimes', 'nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Genre;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class UpdateGenreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$genre = $this->route('genre');
$organisationId = $genre instanceof Genre
? $genre->organisation_id
: ($this->route('organisation')?->id ?? null);
$genreId = $genre instanceof Genre ? $genre->id : null;
return [
'name' => [
'sometimes', 'required', 'string', 'max:40',
Rule::unique('genres', 'name')
->where('organisation_id', $organisationId)
->ignore($genreId),
],
'color' => ['sometimes', 'nullable', 'string', 'size:7', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'sort_order' => ['sometimes', 'nullable', 'integer', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use Illuminate\Foundation\Http\FormRequest;
/**
* RFC v0.2 §10.2 non-placement edits only. Placement (start_at,
* end_at, stage_id, lane) is NOT updateable here; placement changes
* route through POST /timetable/move so the cascade-bump and
* optimistic-lock contract is honoured.
*/
final class UpdatePerformanceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'notes' => ['sometimes', 'nullable', 'string', 'max:1000'],
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Stage;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class UpdateStageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$stage = $this->route('stage');
$eventId = $stage instanceof Stage
? $stage->event_id
: ($this->route('event')?->id ?? null);
$stageId = $stage instanceof Stage ? $stage->id : null;
return [
'name' => [
'sometimes', 'required', 'string', 'max:120',
Rule::unique('stages', 'name')->where('event_id', $eventId)->ignore($stageId),
],
'color' => ['sometimes', 'required', 'string', 'size:7', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'capacity' => ['sometimes', 'nullable', 'integer', 'min:0'],
'sort_order' => ['sometimes', 'nullable', 'integer', 'min:0'],
];
}
}