diff --git a/api/app/Http/Requests/Api/V1/Artist/CreateArtistEngagementRequest.php b/api/app/Http/Requests/Api/V1/Artist/CreateArtistEngagementRequest.php new file mode 100644 index 00000000..bc7321b8 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/CreateArtistEngagementRequest.php @@ -0,0 +1,65 @@ + + */ + 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'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/CreateArtistRequest.php b/api/app/Http/Requests/Api/V1/Artist/CreateArtistRequest.php new file mode 100644 index 00000000..8a0f37e4 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/CreateArtistRequest.php @@ -0,0 +1,49 @@ + + */ + 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'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/CreateGenreRequest.php b/api/app/Http/Requests/Api/V1/Artist/CreateGenreRequest.php new file mode 100644 index 00000000..8ed10bfb --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/CreateGenreRequest.php @@ -0,0 +1,37 @@ + + */ + 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'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/CreatePerformanceRequest.php b/api/app/Http/Requests/Api/V1/Artist/CreatePerformanceRequest.php new file mode 100644 index 00000000..85e48e4d --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/CreatePerformanceRequest.php @@ -0,0 +1,87 @@ + + */ + 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.', + ); + } + }); + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/CreateStageRequest.php b/api/app/Http/Requests/Api/V1/Artist/CreateStageRequest.php new file mode 100644 index 00000000..92a6fafd --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/CreateStageRequest.php @@ -0,0 +1,37 @@ + + */ + 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'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/MoveTimetablePerformanceRequest.php b/api/app/Http/Requests/Api/V1/Artist/MoveTimetablePerformanceRequest.php new file mode 100644 index 00000000..192f2078 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/MoveTimetablePerformanceRequest.php @@ -0,0 +1,107 @@ + + */ + 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; + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/ReorderStagesRequest.php b/api/app/Http/Requests/Api/V1/Artist/ReorderStagesRequest.php new file mode 100644 index 00000000..d95c7607 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/ReorderStagesRequest.php @@ -0,0 +1,55 @@ + + */ + 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.', + ); + } + }); + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/ReplaceStageDaysRequest.php b/api/app/Http/Requests/Api/V1/Artist/ReplaceStageDaysRequest.php new file mode 100644 index 00000000..1050ef12 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/ReplaceStageDaysRequest.php @@ -0,0 +1,70 @@ + + */ + 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, + ), + ); + } + } + }); + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/UpdateArtistEngagementRequest.php b/api/app/Http/Requests/Api/V1/Artist/UpdateArtistEngagementRequest.php new file mode 100644 index 00000000..2aec537b --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/UpdateArtistEngagementRequest.php @@ -0,0 +1,62 @@ + + */ + 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'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/UpdateArtistRequest.php b/api/app/Http/Requests/Api/V1/Artist/UpdateArtistRequest.php new file mode 100644 index 00000000..447eec8f --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/UpdateArtistRequest.php @@ -0,0 +1,44 @@ + + */ + 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'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/UpdateGenreRequest.php b/api/app/Http/Requests/Api/V1/Artist/UpdateGenreRequest.php new file mode 100644 index 00000000..eb6da804 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/UpdateGenreRequest.php @@ -0,0 +1,41 @@ + + */ + 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'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/UpdatePerformanceRequest.php b/api/app/Http/Requests/Api/V1/Artist/UpdatePerformanceRequest.php new file mode 100644 index 00000000..9233dcf3 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/UpdatePerformanceRequest.php @@ -0,0 +1,31 @@ + + */ + public function rules(): array + { + return [ + 'notes' => ['sometimes', 'nullable', 'string', 'max:1000'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/UpdateStageRequest.php b/api/app/Http/Requests/Api/V1/Artist/UpdateStageRequest.php new file mode 100644 index 00000000..ad7b6aef --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/UpdateStageRequest.php @@ -0,0 +1,39 @@ + + */ + 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'], + ]; + } +}