feat(timetable): four custom validation rules for artist domain

StageActiveOnEvent — checks the candidate stage_id is linked to the
given event_id via stage_days. Covers performance create/update
(perf.event_id ↔ stage) and the timetable move endpoint
(target_stage_id ↔ resolved target event).

WithinEventBounds — checks a candidate datetime is inside the event's
[start_at, end_at] window. Used for performance start/end dates and
move-target dates against the relevant sub-event for festivals.

OptionExpiresInFuture — conditional rule fired only when
booking_status === 'option'. Asserts option_expires_at is set and in
the future. Implementation of RFC §10.1 transition gate at the
request layer (the service layer enforces the same invariant).

ContractRequiresFee — conditional rule fired only when
booking_status === 'contracted'. Asserts fee_amount is set and > 0.
Same dual-layer enforcement as OptionExpiresInFuture.

All four pass silently when the validated field is null or the
context is irrelevant — the FormRequest still owns the surrounding
required/nullable/exists rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 20:50:12 +02:00
parent f7ed03237c
commit 378b6fe970
4 changed files with 176 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Rules\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Conditional rule: when `booking_status === 'contracted'`, the
* `fee_amount` field must be set and greater than zero.
*
* Bound from the engagement FormRequest, taking the inbound
* booking_status value at construction time.
*/
final class ContractRequiresFee implements ValidationRule
{
public function __construct(
private readonly ?string $bookingStatus,
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($this->bookingStatus !== ArtistEngagementStatus::Contracted->value) {
return;
}
if ($value === null || $value === '') {
$fail('Bij status "Gecontracteerd" is een fee verplicht.');
return;
}
if ((float) $value <= 0) {
$fail('De fee moet groter dan 0 zijn.');
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Rules\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use Carbon\CarbonImmutable;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Conditional rule: when `booking_status === 'option'`, the
* `option_expires_at` field must be set and in the future.
*
* Bound from the engagement FormRequest, taking the inbound
* booking_status value at construction time.
*/
final class OptionExpiresInFuture implements ValidationRule
{
public function __construct(
private readonly ?string $bookingStatus,
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($this->bookingStatus !== ArtistEngagementStatus::Option->value) {
return;
}
if ($value === null || $value === '') {
$fail('Bij status "Optie" is een vervaldatum verplicht.');
return;
}
$candidate = CarbonImmutable::parse((string) $value);
if ($candidate->isPast()) {
$fail('De optie-vervaldatum moet in de toekomst liggen.');
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Rules\Artist;
use App\Models\StageDay;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Validates that a candidate `stage_id` is linked to the given
* `event_id` via the `stage_days` pivot. Used by both
* CreatePerformanceRequest / UpdatePerformanceRequest (the perf's
* event_id sub-event must be active on the chosen stage) and
* MoveTimetablePerformanceRequest (target stage must be active on the
* resolved target event).
*
* Rule passes silently when value is null (the FormRequest still
* owns the `nullable` decision).
*/
final class StageActiveOnEvent implements ValidationRule
{
public function __construct(
private readonly ?string $eventId,
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value === null || $this->eventId === null) {
return;
}
$exists = StageDay::query()
->where('stage_id', (string) $value)
->where('event_id', $this->eventId)
->exists();
if (! $exists) {
$fail('De gekozen stage is niet actief op deze (sub-)event.');
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Rules\Artist;
use App\Models\Event;
use Carbon\CarbonImmutable;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Validates that a candidate datetime sits inside the event's
* [start_at, end_at] window. Works for flat events (single window)
* and for sub-events (sub-event window); festival-level events are
* usually not the target the FormRequest passes the *sub-event* id
* for performances and the *containing* event for the stage's day.
*
* Rule passes silently when value is null or when the event cannot
* be found (the FormRequest owns the `required`/`exists` decision).
*/
final class WithinEventBounds implements ValidationRule
{
public function __construct(
private readonly ?string $eventId,
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value === null || $this->eventId === null) {
return;
}
$event = Event::withoutGlobalScopes()->find($this->eventId);
if ($event === null) {
return;
}
$candidate = CarbonImmutable::parse((string) $value);
$start = CarbonImmutable::instance($event->start_at);
$end = CarbonImmutable::instance($event->end_at);
if ($candidate->lt($start) || $candidate->gt($end)) {
$fail(sprintf(
'De datum moet binnen het evenement (%s — %s) vallen.',
$start->format('Y-m-d H:i'),
$end->format('Y-m-d H:i'),
));
}
}
}