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:
40
api/app/Rules/Artist/ContractRequiresFee.php
Normal file
40
api/app/Rules/Artist/ContractRequiresFee.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
42
api/app/Rules/Artist/OptionExpiresInFuture.php
Normal file
42
api/app/Rules/Artist/OptionExpiresInFuture.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
43
api/app/Rules/Artist/StageActiveOnEvent.php
Normal file
43
api/app/Rules/Artist/StageActiveOnEvent.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
51
api/app/Rules/Artist/WithinEventBounds.php
Normal file
51
api/app/Rules/Artist/WithinEventBounds.php
Normal 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'),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user