diff --git a/api/app/Console/Commands/Artist/DemoteExpiredOptions.php b/api/app/Console/Commands/Artist/DemoteExpiredOptions.php new file mode 100644 index 00000000..a3d7f8eb --- /dev/null +++ b/api/app/Console/Commands/Artist/DemoteExpiredOptions.php @@ -0,0 +1,79 @@ +withoutGlobalScope(OrganisationScope::class) + ->where('booking_status', ArtistEngagementStatus::Option->value) + ->whereNotNull('option_expires_at') + ->where('option_expires_at', '<=', now()) + ->whereNull('deleted_at') + ->get(); + + $demotedIds = []; + foreach ($expired as $engagement) { + // Re-check status under fresh state — another worker / a + // user UI action may have already transitioned this row. + if ($engagement->booking_status !== ArtistEngagementStatus::Option) { + continue; + } + + $service->transitionStatus($engagement, ArtistEngagementStatus::Draft); + + activity('artist_engagement') + ->performedOn($engagement) + ->event('option_expired') + ->withProperties([ + 'organisation_id' => $engagement->organisation_id, + 'event_id' => $engagement->event_id, + 'option_expires_at' => optional($engagement->option_expires_at)->toIso8601String(), + ]) + ->log('option_expired'); + + $demotedIds[] = (string) $engagement->id; + } + + $count = count($demotedIds); + $this->info("Demoted {$count} option(s) on ".now()->toDateString().'.'); + if ($count > 0) { + $this->line('IDs: '.implode(', ', $demotedIds)); + } + + return self::SUCCESS; + } +} diff --git a/api/app/Exceptions/Artist/DuplicateArtistException.php b/api/app/Exceptions/Artist/DuplicateArtistException.php new file mode 100644 index 00000000..5658f4d7 --- /dev/null +++ b/api/app/Exceptions/Artist/DuplicateArtistException.php @@ -0,0 +1,24 @@ +name, + $referencingArtistsCount, + )); + } +} diff --git a/api/app/Exceptions/Artist/InvalidStatusTransitionException.php b/api/app/Exceptions/Artist/InvalidStatusTransitionException.php new file mode 100644 index 00000000..6dfa6096 --- /dev/null +++ b/api/app/Exceptions/Artist/InvalidStatusTransitionException.php @@ -0,0 +1,28 @@ +value, + $to->value, + $reason, + )); + } +} diff --git a/api/app/Exceptions/Artist/StageDaysOrphanedPerformancesException.php b/api/app/Exceptions/Artist/StageDaysOrphanedPerformancesException.php new file mode 100644 index 00000000..de4b52fa --- /dev/null +++ b/api/app/Exceptions/Artist/StageDaysOrphanedPerformancesException.php @@ -0,0 +1,34 @@ + $performanceIds + * @param array $removedEventIds + */ + public function __construct( + public readonly array $performanceIds, + public readonly array $removedEventIds, + ) { + parent::__construct(sprintf( + 'Stage-day removal would orphan %d performance(s) on %d event(s).', + count($performanceIds), + count($removedEventIds), + )); + } +} diff --git a/api/app/Exceptions/Artist/VersionMismatchException.php b/api/app/Exceptions/Artist/VersionMismatchException.php new file mode 100644 index 00000000..8b53977a --- /dev/null +++ b/api/app/Exceptions/Artist/VersionMismatchException.php @@ -0,0 +1,27 @@ +where('organisation_id', $organisation->id) + ->with(['defaultGenre', 'agentCompany']); + + if ($request->boolean('with_trashed')) { + $query->withTrashed(); + } + if ($request->boolean('trashed_only')) { + $query->onlyTrashed(); + } + + if ($request->filled('search')) { + $term = '%'.$request->string('search').'%'; + $query->where(function ($q) use ($term): void { + $q->where('name', 'like', $term)->orWhere('slug', 'like', $term); + }); + } + if ($request->filled('genre_id')) { + $query->where('default_genre_id', $request->string('genre_id')); + } + if ($request->filled('agent_company_id')) { + $query->where('agent_company_id', $request->string('agent_company_id')); + } + + return ArtistResource::collection($query->orderBy('name')->paginate(50)); + } + + public function show(Organisation $organisation, Artist $artist): JsonResponse + { + Gate::authorize('view', $artist); + $artist->loadMissing(['defaultGenre', 'agentCompany', 'contacts']); + + return $this->success(ArtistResource::make($artist)); + } + + public function store(CreateArtistRequest $request, Organisation $organisation): JsonResponse + { + Gate::authorize('create', [Artist::class, $organisation]); + + try { + $artist = $this->service->create($organisation, $request->validated()); + } catch (DuplicateArtistException $e) { + return $this->error('Duplicate artist name.', 409, [ + 'duplicate_artist_id' => $e->existing->id, + ]); + } + + return $this->created(ArtistResource::make($artist->load(['defaultGenre', 'agentCompany']))); + } + + public function update(UpdateArtistRequest $request, Organisation $organisation, Artist $artist): JsonResponse + { + Gate::authorize('update', $artist); + + $artist = $this->service->update($artist, $request->validated()); + + return $this->success(ArtistResource::make($artist->load(['defaultGenre', 'agentCompany']))); + } + + public function destroy(Organisation $organisation, Artist $artist): JsonResponse + { + if (! Gate::check('delete', $artist)) { + return $this->forbidden('Cannot delete artist with active engagements.'); + } + + $this->service->softDelete($artist); + + return response()->json(null, 204); + } + + public function restore(Organisation $organisation, string $artist): JsonResponse + { + $model = Artist::withTrashed()->findOrFail($artist); + Gate::authorize('restore', $model); + + $this->service->restore($model); + + return $this->success(ArtistResource::make($model->fresh())); + } +} diff --git a/api/app/Http/Controllers/Api/V1/Artist/ArtistEngagementController.php b/api/app/Http/Controllers/Api/V1/Artist/ArtistEngagementController.php new file mode 100644 index 00000000..7da09ffc --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/Artist/ArtistEngagementController.php @@ -0,0 +1,117 @@ +verifyEventBelongsToOrganisation($organisation, $event); + Gate::authorize('viewAny', [ArtistEngagement::class, $event]); + + $query = ArtistEngagement::query() + ->where('event_id', $event->id) + ->with(['artist.defaultGenre', 'projectLeader']); + + if ($request->filled('status')) { + $query->where('booking_status', $request->string('status')); + } + if ($request->filled('search')) { + $term = '%'.$request->string('search').'%'; + $query->whereHas('artist', fn ($q) => $q->where('name', 'like', $term)); + } + + return ArtistEngagementResource::collection( + $query->orderBy('created_at', 'desc')->paginate(50), + ); + } + + public function show(Organisation $organisation, Event $event, ArtistEngagement $engagement): JsonResponse + { + $this->verifyEventBelongsToOrganisation($organisation, $event); + Gate::authorize('view', [$engagement, $event]); + + $engagement->loadMissing([ + 'artist.defaultGenre', 'artist.agentCompany', 'artist.contacts', + 'projectLeader', 'performances.stage', + ]); + + return $this->success(ArtistEngagementResource::make($engagement)); + } + + public function store(CreateArtistEngagementRequest $request, Organisation $organisation, Event $event): JsonResponse + { + $this->verifyEventBelongsToOrganisation($organisation, $event); + Gate::authorize('create', [ArtistEngagement::class, $event]); + + $data = $request->validated(); + $artist = Artist::query()->findOrFail($data['artist_id']); + + try { + $engagement = $this->service->create($event, $artist, $data); + } catch (InvalidStatusTransitionException $e) { + return $this->error($e->getMessage(), 422); + } + + return $this->created( + ArtistEngagementResource::make($engagement->load(['artist.defaultGenre', 'projectLeader'])), + ); + } + + public function update( + UpdateArtistEngagementRequest $request, + Organisation $organisation, + Event $event, + ArtistEngagement $engagement, + ): JsonResponse { + $this->verifyEventBelongsToOrganisation($organisation, $event); + Gate::authorize('update', [$engagement, $event]); + + try { + $engagement = $this->service->update($engagement, $request->validated()); + } catch (InvalidStatusTransitionException $e) { + return $this->error($e->getMessage(), 422); + } + + return $this->success( + ArtistEngagementResource::make($engagement->load(['artist.defaultGenre', 'projectLeader'])), + ); + } + + public function destroy( + Organisation $organisation, + Event $event, + ArtistEngagement $engagement, + ): JsonResponse { + $this->verifyEventBelongsToOrganisation($organisation, $event); + Gate::authorize('delete', [$engagement, $event]); + + $this->service->softDelete($engagement); + + return response()->json(null, 204); + } +} diff --git a/api/app/Http/Controllers/Api/V1/Artist/GenreController.php b/api/app/Http/Controllers/Api/V1/Artist/GenreController.php new file mode 100644 index 00000000..72d7e23d --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/Artist/GenreController.php @@ -0,0 +1,70 @@ +where('organisation_id', $organisation->id) + ->orderBy('sort_order') + ->orderBy('name') + ->get(); + + return GenreResource::collection($genres); + } + + public function store(CreateGenreRequest $request, Organisation $organisation): JsonResponse + { + Gate::authorize('create', [Genre::class, $organisation]); + + $genre = $this->service->create($organisation, $request->validated()); + + return $this->created(GenreResource::make($genre)); + } + + public function update(UpdateGenreRequest $request, Organisation $organisation, Genre $genre): JsonResponse + { + Gate::authorize('update', $genre); + + $genre = $this->service->update($genre, $request->validated()); + + return $this->success(GenreResource::make($genre)); + } + + public function destroy(Organisation $organisation, Genre $genre): JsonResponse + { + Gate::authorize('delete', $genre); + + try { + $this->service->delete($genre); + } catch (GenreInUseException $e) { + return $this->error($e->getMessage(), 409, [ + 'referencing_artists_count' => $e->referencingArtistsCount, + ]); + } + + return response()->json(null, 204); + } +} diff --git a/api/app/Http/Controllers/Api/V1/Artist/PerformanceController.php b/api/app/Http/Controllers/Api/V1/Artist/PerformanceController.php new file mode 100644 index 00000000..15501e63 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/Artist/PerformanceController.php @@ -0,0 +1,102 @@ +verifyEventBelongsToOrganisation($organisation, $event); + Gate::authorize('viewAny', [Performance::class, $event]); + + $query = Performance::query() + ->whereHas('engagement', fn ($q) => $q->where('event_id', $event->id)) + ->with(['engagement.artist.defaultGenre', 'stage']); + + if ($request->filled('day')) { + $query->where('event_id', $request->string('day')); + } + if ($request->query('stage_id') === 'null') { + $query->whereNull('stage_id'); + } elseif ($request->filled('stage_id')) { + $query->where('stage_id', $request->string('stage_id')); + } + + return PerformanceResource::collection($query->orderBy('start_at')->get()); + } + + public function show(Organisation $organisation, Event $event, Performance $performance): JsonResponse + { + $this->verifyEventBelongsToOrganisation($organisation, $event); + Gate::authorize('view', [$performance, $event]); + + $performance->loadMissing(['engagement.artist.defaultGenre', 'stage']); + + return $this->success(PerformanceResource::make($performance)); + } + + public function store(CreatePerformanceRequest $request, Organisation $organisation, Event $event): JsonResponse + { + $this->verifyEventBelongsToOrganisation($organisation, $event); + Gate::authorize('create', [Performance::class, $event]); + + $data = $request->validated(); + $engagement = ArtistEngagement::query()->findOrFail($data['engagement_id']); + + $performance = $this->service->create($engagement, $data); + + return $this->created( + PerformanceResource::make($performance->load(['engagement.artist.defaultGenre', 'stage'])), + ); + } + + public function update( + UpdatePerformanceRequest $request, + Organisation $organisation, + Event $event, + Performance $performance, + ): JsonResponse { + $this->verifyEventBelongsToOrganisation($organisation, $event); + Gate::authorize('update', [$performance, $event]); + + $performance = $this->service->update($performance, $request->validated()); + + return $this->success(PerformanceResource::make($performance)); + } + + public function destroy( + Organisation $organisation, + Event $event, + Performance $performance, + ): JsonResponse { + $this->verifyEventBelongsToOrganisation($organisation, $event); + Gate::authorize('delete', [$performance, $event]); + + $this->service->delete($performance); + + return response()->json(null, 204); + } +} diff --git a/api/app/Http/Controllers/Api/V1/Artist/StageController.php b/api/app/Http/Controllers/Api/V1/Artist/StageController.php new file mode 100644 index 00000000..3bd4530d --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/Artist/StageController.php @@ -0,0 +1,131 @@ +verifyEventBelongsToOrganisation($organisation, $event); + Gate::authorize('viewAny', [Stage::class, $event]); + + $stages = Stage::query() + ->where('event_id', $event->id) + ->with('stageDays') + ->ordered() + ->get(); + + return StageResource::collection($stages); + } + + public function show(Organisation $organisation, Event $event, Stage $stage): JsonResponse + { + $this->verifyEventBelongsToOrganisation($organisation, $event); + Gate::authorize('view', [$stage, $event]); + + $stage->loadMissing('stageDays'); + + return $this->success(StageResource::make($stage)); + } + + public function store(CreateStageRequest $request, Organisation $organisation, Event $event): JsonResponse + { + $this->verifyEventBelongsToOrganisation($organisation, $event); + Gate::authorize('create', [Stage::class, $event]); + + $stage = $this->stageService->create($event, $request->validated()); + + return $this->created(StageResource::make($stage)); + } + + public function update(UpdateStageRequest $request, Organisation $organisation, Event $event, Stage $stage): JsonResponse + { + $this->verifyEventBelongsToOrganisation($organisation, $event); + Gate::authorize('update', [$stage, $event]); + + $stage = $this->stageService->update($stage, $request->validated()); + + return $this->success(StageResource::make($stage)); + } + + public function destroy(Organisation $organisation, Event $event, Stage $stage): JsonResponse + { + $this->verifyEventBelongsToOrganisation($organisation, $event); + Gate::authorize('delete', [$stage, $event]); + + $parkedCount = $this->stageService->delete($stage); + + return response()->json(['parked_performances' => $parkedCount], 200); + } + + public function reorder(ReorderStagesRequest $request, Organisation $organisation, Event $event): JsonResponse + { + $this->verifyEventBelongsToOrganisation($organisation, $event); + Gate::authorize('reorder', [Stage::class, $event]); + + $this->stageService->reorder($event, $request->validated('stage_ids')); + + $stages = Stage::query()->where('event_id', $event->id)->ordered()->get(); + + return $this->success(StageResource::collection($stages)); + } + + public function replaceDays( + ReplaceStageDaysRequest $request, + Organisation $organisation, + Event $event, + Stage $stage, + ): JsonResponse { + $this->verifyEventBelongsToOrganisation($organisation, $event); + Gate::authorize('update', [$stage, $event]); + + $forceOrphan = $request->boolean('force_orphan') + || $request->query('force_orphan') === 'true'; + + try { + $diff = $this->stageDayService->replaceDays( + $stage, + $request->validated('event_ids'), + $forceOrphan, + ); + } catch (StageDaysOrphanedPerformancesException $e) { + return $this->error('Removing day(s) would orphan scheduled performances.', 409, [ + 'conflict' => 'orphaned_performances', + 'performances_on_removed_events' => $e->performanceIds, + 'removed_event_ids' => $e->removedEventIds, + ]); + } + + return $this->success([ + 'stage' => StageResource::make($stage->fresh()->load('stageDays')), + 'added_event_ids' => $diff['added'], + 'removed_event_ids' => $diff['removed'], + ]); + } +} diff --git a/api/app/Http/Controllers/Api/V1/Artist/TimetableMoveController.php b/api/app/Http/Controllers/Api/V1/Artist/TimetableMoveController.php new file mode 100644 index 00000000..ad4616b4 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/Artist/TimetableMoveController.php @@ -0,0 +1,83 @@ +verifyEventBelongsToOrganisation($organisation, $event); + + $data = $request->validated(); + $performance = Performance::query()->findOrFail($data['performance_id']); + + Gate::authorize('move', [$performance, $event]); + + $targetStage = isset($data['target_stage_id']) + ? Stage::query()->find($data['target_stage_id']) + : null; + + $start = isset($data['target_start_at']) + ? CarbonImmutable::parse((string) $data['target_start_at']) + : null; + $end = isset($data['target_end_at']) + ? CarbonImmutable::parse((string) $data['target_end_at']) + : null; + + try { + $result = $this->service->move( + performance: $performance, + targetStage: $targetStage, + start: $start, + end: $end, + targetLane: isset($data['target_lane']) ? (int) $data['target_lane'] : null, + clientVersion: (int) $data['version'], + ); + } catch (VersionMismatchException $e) { + $performance->refresh(); + + return $this->error('Version mismatch — performance was modified by another request.', 409, [ + 'conflict' => 'version_mismatch', + 'current_version' => $e->currentVersion, + 'client_version' => $e->clientVersion, + 'server_data' => PerformanceResource::make( + $performance->load(['engagement.artist.defaultGenre', 'stage']), + )->toArray(request()), + ]); + } + + return $this->success([ + 'moved' => PerformanceResource::make( + $result->moved->load(['engagement.artist.defaultGenre', 'stage']), + ), + 'cascaded' => PerformanceResource::collection( + collect($result->cascaded)->each->load(['engagement.artist.defaultGenre', 'stage']), + ), + ]); + } +} diff --git a/api/app/Http/Middleware/IdempotencyKey60sRedis.php b/api/app/Http/Middleware/IdempotencyKey60sRedis.php new file mode 100644 index 00000000..91964fa9 --- /dev/null +++ b/api/app/Http/Middleware/IdempotencyKey60sRedis.php @@ -0,0 +1,76 @@ +header('Idempotency-Key'); + + if (! is_string($key) || trim($key) === '') { + return response()->json( + ['error' => 'idempotency_key_required'], + 400, + ); + } + + $cacheKey = 'idempotency:60s:'.$key; + $cached = Cache::get($cacheKey); + + if (is_array($cached)) { + $response = response($cached['body'], $cached['status']); + foreach ($cached['headers'] ?? [] as $name => $value) { + $response->headers->set($name, $value); + } + $response->headers->set('Idempotency-Replayed', 'true'); + + return $response; + } + + /** @var Response $response */ + $response = $next($request); + + if ($response->isSuccessful()) { + Cache::put($cacheKey, [ + 'status' => $response->getStatusCode(), + 'body' => $response->getContent(), + 'headers' => [ + 'Content-Type' => $response->headers->get('Content-Type'), + ], + ], 60); + } + + return $response; + } +} 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..083bd76b --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/MoveTimetablePerformanceRequest.php @@ -0,0 +1,108 @@ + + */ + public function rules(): array + { + $event = $this->route('event'); + $resolvedEventId = $this->resolveTargetEventId(); + + return [ + // performances has no organisation_id column (FK-chain via + // engagement_id); cross-tenant is caught by the policy in + // TimetableMoveController via Gate::authorize('move', ...). + 'performance_id' => [ + 'required', 'string', 'max:30', + Rule::exists('performances', 'id'), + ], + '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_date', '<=', $start->toDateString()) + ->where('events.end_date', '>=', $start->toDateString()) + ->orderBy('events.start_date', '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'], + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/Artist/ArtistContactResource.php b/api/app/Http/Resources/Api/V1/Artist/ArtistContactResource.php new file mode 100644 index 00000000..11c4384e --- /dev/null +++ b/api/app/Http/Resources/Api/V1/Artist/ArtistContactResource.php @@ -0,0 +1,33 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'artist_id' => $this->artist_id, + 'name' => $this->name, + 'email' => $this->email, + 'phone' => $this->phone, + 'role' => $this->role, + 'is_primary' => (bool) $this->is_primary, + 'receives_briefing' => (bool) $this->receives_briefing, + 'receives_infosheet' => (bool) $this->receives_infosheet, + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php b/api/app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php new file mode 100644 index 00000000..d3a2778a --- /dev/null +++ b/api/app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php @@ -0,0 +1,124 @@ + + */ + public function toArray(Request $request): array + { + $fee = (float) ($this->fee_amount ?? 0); + $bumaPercentage = (float) ($this->buma_percentage ?? 0); + $vatPercentage = (float) ($this->vat_percentage ?? 0); + + $bumaAmount = ($this->buma_applicable && $this->buma_handled_by === BumaHandledBy::Organisation) + ? round($fee * $bumaPercentage / 100, 2) + : 0.0; + + $vatGrondslag = $fee + ( + $this->buma_handled_by === BumaHandledBy::Organisation + ? $bumaAmount + : 0.0 + ); + + $vatAmount = $this->vat_applicable + ? round($vatGrondslag * $vatPercentage / 100, 2) + : 0.0; + + $breakdownTotal = 0.0; + foreach ((array) $this->deal_breakdown as $line) { + if (is_array($line) && isset($line['amount'])) { + $breakdownTotal += (float) $line['amount']; + } + } + + $totalCost = round($fee + $bumaAmount + $vatAmount + $breakdownTotal, 2); + + return [ + 'id' => $this->id, + 'organisation_id' => $this->organisation_id, + 'artist_id' => $this->artist_id, + 'event_id' => $this->event_id, + 'artist' => ArtistResource::make($this->whenLoaded('artist')), + 'project_leader_id' => $this->project_leader_id, + 'project_leader' => $this->whenLoaded('projectLeader', fn () => [ + 'id' => $this->projectLeader?->id, + 'name' => trim(($this->projectLeader?->first_name ?? '').' '.($this->projectLeader?->last_name ?? '')), + 'email' => $this->projectLeader?->email, + ]), + 'booking_status' => [ + 'value' => $this->booking_status?->value, + 'label' => $this->booking_status?->label(), + ], + 'fee_amount' => $this->fee_amount, + 'fee_currency' => $this->fee_currency, + 'fee_type' => [ + 'value' => $this->fee_type?->value, + 'label' => $this->fee_type?->label(), + ], + 'buma_applicable' => (bool) $this->buma_applicable, + 'buma_percentage' => $this->buma_percentage, + 'buma_handled_by' => [ + 'value' => $this->buma_handled_by?->value, + 'label' => $this->buma_handled_by?->label(), + ], + 'vat_applicable' => (bool) $this->vat_applicable, + 'vat_percentage' => $this->vat_percentage, + 'deal_breakdown' => $this->deal_breakdown, + 'deposit_percentage' => $this->deposit_percentage, + 'deposit_due_date' => optional($this->deposit_due_date)->toIso8601String(), + 'balance_due_date' => optional($this->balance_due_date)->toIso8601String(), + 'payment_status' => [ + 'value' => $this->payment_status?->value, + 'label' => $this->payment_status?->label(), + ], + 'crew_count' => $this->crew_count, + 'guests_count' => $this->guests_count, + 'requested_at' => optional($this->requested_at)->toIso8601String(), + 'option_expires_at' => optional($this->option_expires_at)->toIso8601String(), + 'advance_open_from' => optional($this->advance_open_from)->toIso8601String(), + 'advance_open_to' => optional($this->advance_open_to)->toIso8601String(), + 'advancing_completed_count' => $this->advancing_completed_count, + 'advancing_total_count' => $this->advancing_total_count, + 'notes' => $this->notes, + 'computed' => [ + 'buma_amount' => $bumaAmount, + 'vat_grondslag' => $vatGrondslag, + 'vat_amount' => $vatAmount, + 'breakdown_total' => $breakdownTotal, + 'total_cost' => $totalCost, + ], + 'performances' => PerformanceResource::collection($this->whenLoaded('performances')), + 'created_at' => optional($this->created_at)->toIso8601String(), + 'updated_at' => optional($this->updated_at)->toIso8601String(), + 'deleted_at' => optional($this->deleted_at)->toIso8601String(), + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/Artist/ArtistResource.php b/api/app/Http/Resources/Api/V1/Artist/ArtistResource.php new file mode 100644 index 00000000..0df72db5 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/Artist/ArtistResource.php @@ -0,0 +1,69 @@ + + */ + public function toArray(Request $request): array + { + $lifetime = $this->engagements() + ->whereNotIn('booking_status', [ + ArtistEngagementStatus::Cancelled->value, + ArtistEngagementStatus::Rejected->value, + ArtistEngagementStatus::Declined->value, + ]) + ->count(); + + $upcoming = $this->engagements() + ->whereNotIn('booking_status', [ + ArtistEngagementStatus::Cancelled->value, + ArtistEngagementStatus::Rejected->value, + ArtistEngagementStatus::Declined->value, + ]) + ->whereHas('event', fn ($q) => $q->where('end_date', '>=', now()->toDateString())) + ->count(); + + return [ + 'id' => $this->id, + 'organisation_id' => $this->organisation_id, + 'name' => $this->name, + 'slug' => $this->slug, + 'default_genre_id' => $this->default_genre_id, + 'default_genre' => GenreResource::make($this->whenLoaded('defaultGenre')), + 'default_draw' => $this->default_draw, + 'star_rating' => $this->star_rating, + 'home_base_country' => $this->home_base_country, + 'agent_company_id' => $this->agent_company_id, + 'agent_company' => $this->whenLoaded( + 'agentCompany', + fn () => [ + 'id' => $this->agentCompany?->id, + 'name' => $this->agentCompany?->name, + 'handles_buma' => (bool) ($this->agentCompany?->handles_buma ?? false), + ], + ), + 'notes' => $this->notes, + 'contacts' => ArtistContactResource::collection($this->whenLoaded('contacts')), + 'engagements_summary' => [ + 'lifetime_count' => $lifetime, + 'upcoming_count' => $upcoming, + ], + 'created_at' => optional($this->created_at)->toIso8601String(), + 'updated_at' => optional($this->updated_at)->toIso8601String(), + 'deleted_at' => optional($this->deleted_at)->toIso8601String(), + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/Artist/GenreResource.php b/api/app/Http/Resources/Api/V1/Artist/GenreResource.php new file mode 100644 index 00000000..5be38fab --- /dev/null +++ b/api/app/Http/Resources/Api/V1/Artist/GenreResource.php @@ -0,0 +1,32 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'organisation_id' => $this->organisation_id, + 'name' => $this->name, + 'color' => $this->color, + 'sort_order' => $this->sort_order, + 'is_active' => (bool) $this->is_active, + 'created_at' => optional($this->created_at)->toIso8601String(), + 'updated_at' => optional($this->updated_at)->toIso8601String(), + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/Artist/PerformanceResource.php b/api/app/Http/Resources/Api/V1/Artist/PerformanceResource.php new file mode 100644 index 00000000..cdb5d5ef --- /dev/null +++ b/api/app/Http/Resources/Api/V1/Artist/PerformanceResource.php @@ -0,0 +1,109 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'engagement_id' => $this->engagement_id, + 'event_id' => $this->event_id, + 'stage_id' => $this->stage_id, + 'lane' => (int) $this->lane, + 'lane_resolved' => $this->resolveLane(), + 'start_at' => optional($this->start_at)->toIso8601String(), + 'end_at' => optional($this->end_at)->toIso8601String(), + 'version' => (int) $this->version, + 'notes' => $this->notes, + 'warnings' => $this->computeWarnings(), + 'engagement' => ArtistEngagementResource::make($this->whenLoaded('engagement')), + 'stage' => StageResource::make($this->whenLoaded('stage')), + 'created_at' => optional($this->created_at)->toIso8601String(), + 'updated_at' => optional($this->updated_at)->toIso8601String(), + 'deleted_at' => optional($this->deleted_at)->toIso8601String(), + ]; + } + + /** + * Computed via LaneResolver over the (stage, sub-event) cohort. + * For parked performances (stage_id = null) the persisted lane is + * surfaced as-is — the wachtrij is a flat list, not a lane grid. + */ + private function resolveLane(): int + { + if ($this->stage_id === null) { + return (int) $this->lane; + } + + $cohort = Performance::query() + ->where('stage_id', $this->stage_id) + ->where('event_id', $this->event_id) + ->get(); + + $resolved = app(LaneResolver::class)->resolve($cohort); + + return $resolved[(string) $this->id] ?? (int) $this->lane; + } + + /** + * RFC v0.2 D5 / D6 / D25 — overlap, B2B, capacity warnings. + * Naive implementation for Session 2; refined as the timetable + * frontend lands in Session 4. + * + * @return array + */ + private function computeWarnings(): array + { + $warnings = []; + + if ($this->stage_id === null) { + return $warnings; + } + + $start = CarbonImmutable::instance($this->start_at); + $end = CarbonImmutable::instance($this->end_at); + + $peers = Performance::query() + ->where('stage_id', $this->stage_id) + ->where('event_id', $this->event_id) + ->where('id', '!=', $this->id) + ->get(); + + foreach ($peers as $other) { + $oStart = CarbonImmutable::instance($other->start_at); + $oEnd = CarbonImmutable::instance($other->end_at); + + if ($start < $oEnd && $oStart < $end && (int) $other->lane === (int) $this->lane) { + $warnings[] = 'overlap'; + break; + } + } + + foreach ($peers as $other) { + $oEnd = CarbonImmutable::instance($other->end_at); + $oStart = CarbonImmutable::instance($other->start_at); + if ($oEnd->equalTo($start) || $oStart->equalTo($end)) { + $warnings[] = 'b2b'; + break; + } + } + + return array_values(array_unique($warnings)); + } +} diff --git a/api/app/Http/Resources/Api/V1/Artist/StageResource.php b/api/app/Http/Resources/Api/V1/Artist/StageResource.php new file mode 100644 index 00000000..b6d46f30 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/Artist/StageResource.php @@ -0,0 +1,36 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'event_id' => $this->event_id, + 'name' => $this->name, + 'color' => $this->color, + 'capacity' => $this->capacity, + 'sort_order' => $this->sort_order, + 'stage_days' => $this->whenLoaded( + 'stageDays', + fn () => $this->stageDays->pluck('event_id')->all(), + ), + 'created_at' => optional($this->created_at)->toIso8601String(), + 'updated_at' => optional($this->updated_at)->toIso8601String(), + ]; + } +} diff --git a/api/app/Models/Artist.php b/api/app/Models/Artist.php index f16e31a2..20eb7d1f 100644 --- a/api/app/Models/Artist.php +++ b/api/app/Models/Artist.php @@ -56,8 +56,17 @@ final class Artist extends Model public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() - ->logFillable() - ->dontLogEmptyChanges(); + ->logOnly(['name', 'slug', 'default_genre_id', 'default_draw', 'agent_company_id']) + ->logOnlyDirty() + ->dontLogIfAttributesChangedOnly(['updated_at']) + ->useLogName('artist'); + } + + public function tapActivity(Activity $activity, string $eventName): void + { + $properties = $activity->properties?->toArray() ?? []; + $properties['organisation_id'] = $this->organisation_id; + $activity->properties = collect($properties); } private function generateUniqueSlug(string $name): string diff --git a/api/app/Models/ArtistEngagement.php b/api/app/Models/ArtistEngagement.php index 7849de8e..bdde42e4 100644 --- a/api/app/Models/ArtistEngagement.php +++ b/api/app/Models/ArtistEngagement.php @@ -87,8 +87,26 @@ final class ArtistEngagement extends Model public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() - ->logFillable() - ->dontLogEmptyChanges(); + ->logOnly([ + 'booking_status', + 'fee_amount', 'fee_currency', 'fee_type', + 'buma_applicable', 'buma_percentage', 'buma_handled_by', + 'vat_applicable', 'vat_percentage', + 'project_leader_id', + 'option_expires_at', + 'payment_status', + ]) + ->logOnlyDirty() + ->dontLogIfAttributesChangedOnly(['updated_at']) + ->useLogName('artist_engagement'); + } + + public function tapActivity(Activity $activity, string $eventName): void + { + $properties = $activity->properties?->toArray() ?? []; + $properties['organisation_id'] = $this->organisation_id; + $properties['event_id'] = $this->event_id; + $activity->properties = collect($properties); } public function organisation(): BelongsTo diff --git a/api/app/Models/Genre.php b/api/app/Models/Genre.php index 076b27db..2e5757d7 100644 --- a/api/app/Models/Genre.php +++ b/api/app/Models/Genre.php @@ -43,8 +43,17 @@ final class Genre extends Model public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() - ->logFillable() - ->dontLogEmptyChanges(); + ->logOnly(['name', 'color', 'is_active', 'sort_order']) + ->logOnlyDirty() + ->dontLogIfAttributesChangedOnly(['updated_at']) + ->useLogName('genre'); + } + + public function tapActivity(Activity $activity, string $eventName): void + { + $properties = $activity->properties?->toArray() ?? []; + $properties['organisation_id'] = $this->organisation_id; + $activity->properties = collect($properties); } public function organisation(): BelongsTo diff --git a/api/app/Models/Performance.php b/api/app/Models/Performance.php index 5b16927e..36ac2153 100644 --- a/api/app/Models/Performance.php +++ b/api/app/Models/Performance.php @@ -55,8 +55,22 @@ final class Performance extends Model public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() - ->logFillable() - ->dontLogEmptyChanges(); + ->logOnly(['start_at', 'end_at', 'stage_id', 'lane', 'notes']) + ->logOnlyDirty() + // Performance.version is bumped by PerformanceObserver on every + // dirty save. Skip the auto-log when *only* updated_at + version + // moved — those rows correspond to bookkeeping touches, not + // user-meaningful changes (D14). + ->dontLogIfAttributesChangedOnly(['updated_at', 'version']) + ->useLogName('performance'); + } + + public function tapActivity(Activity $activity, string $eventName): void + { + $properties = $activity->properties?->toArray() ?? []; + $properties['event_id'] = $this->event_id; + $properties['organisation_id'] = $this->engagement?->organisation_id; + $activity->properties = collect($properties); } public function engagement(): BelongsTo diff --git a/api/app/Models/Stage.php b/api/app/Models/Stage.php index 539421e8..cbf8fdb5 100644 --- a/api/app/Models/Stage.php +++ b/api/app/Models/Stage.php @@ -50,8 +50,18 @@ final class Stage extends Model public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() - ->logFillable() - ->dontLogEmptyChanges(); + ->logOnly(['name', 'color', 'capacity', 'sort_order']) + ->logOnlyDirty() + ->dontLogIfAttributesChangedOnly(['updated_at']) + ->useLogName('stage'); + } + + public function tapActivity(Activity $activity, string $eventName): void + { + $properties = $activity->properties?->toArray() ?? []; + $properties['event_id'] = $this->event_id; + $properties['organisation_id'] = $this->event?->organisation_id; + $activity->properties = collect($properties); } public function event(): BelongsTo diff --git a/api/app/Policies/ArtistEngagementPolicy.php b/api/app/Policies/ArtistEngagementPolicy.php new file mode 100644 index 00000000..364feb29 --- /dev/null +++ b/api/app/Policies/ArtistEngagementPolicy.php @@ -0,0 +1,101 @@ +canViewProgram($user, $event); + } + + public function view(User $user, ArtistEngagement $engagement, Event $event): bool + { + if ($engagement->event_id !== $event->id) { + return false; + } + + return $this->canViewProgram($user, $event); + } + + public function create(User $user, Event $event): bool + { + return $this->canManageProgram($user, $event); + } + + public function update(User $user, ArtistEngagement $engagement, Event $event): bool + { + if ($engagement->event_id !== $event->id) { + return false; + } + + return $this->canManageProgram($user, $event); + } + + public function delete(User $user, ArtistEngagement $engagement, Event $event): bool + { + if ($engagement->event_id !== $event->id) { + return false; + } + + return $this->canManageProgram($user, $event); + } + + private function canViewProgram(User $user, Event $event): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + $isOrgMember = $event->organisation->users() + ->where('user_id', $user->id) + ->wherePivotIn('role', ['org_admin', 'program_manager', 'production_assistant']) + ->exists(); + + if ($isOrgMember) { + return true; + } + + return $event->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'event_manager') + ->exists(); + } + + private function canManageProgram(User $user, Event $event): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + $isOrgManager = $event->organisation->users() + ->where('user_id', $user->id) + ->wherePivotIn('role', ['org_admin', 'program_manager']) + ->exists(); + + if ($isOrgManager) { + return true; + } + + return $event->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'event_manager') + ->exists(); + } +} diff --git a/api/app/Policies/ArtistPolicy.php b/api/app/Policies/ArtistPolicy.php new file mode 100644 index 00000000..06205898 --- /dev/null +++ b/api/app/Policies/ArtistPolicy.php @@ -0,0 +1,82 @@ +hasRole('super_admin') + || $organisation->users()->where('user_id', $user->id)->exists(); + } + + public function view(User $user, Artist $artist): bool + { + return $user->hasRole('super_admin') + || $artist->organisation->users()->where('user_id', $user->id)->exists(); + } + + public function create(User $user, Organisation $organisation): bool + { + return $this->canManageArtists($user, $organisation); + } + + public function update(User $user, Artist $artist): bool + { + return $this->canManageArtists($user, $artist->organisation); + } + + /** + * Soft-delete is blocked while the artist still has any non-terminal + * engagement (D27 — soft-delete preserves historical engagements but + * the master must not vanish underneath an active booking). + */ + public function delete(User $user, Artist $artist): bool + { + if (! $this->canManageArtists($user, $artist->organisation)) { + return false; + } + + $hasActive = $artist->engagements() + ->whereNotIn('booking_status', [ + ArtistEngagementStatus::Cancelled->value, + ArtistEngagementStatus::Rejected->value, + ArtistEngagementStatus::Declined->value, + ]) + ->exists(); + + return ! $hasActive; + } + + public function restore(User $user, Artist $artist): bool + { + return $this->canManageArtists($user, $artist->organisation); + } + + private function canManageArtists(User $user, Organisation $organisation): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + return $organisation->users() + ->where('user_id', $user->id) + ->wherePivotIn('role', ['org_admin', 'program_manager']) + ->exists(); + } +} diff --git a/api/app/Policies/GenrePolicy.php b/api/app/Policies/GenrePolicy.php new file mode 100644 index 00000000..99145b47 --- /dev/null +++ b/api/app/Policies/GenrePolicy.php @@ -0,0 +1,59 @@ +hasRole('super_admin') + || $organisation->users()->where('user_id', $user->id)->exists(); + } + + public function view(User $user, Genre $genre): bool + { + return $user->hasRole('super_admin') + || $genre->organisation->users()->where('user_id', $user->id)->exists(); + } + + public function create(User $user, Organisation $organisation): bool + { + return $this->canManageSettings($user, $organisation); + } + + public function update(User $user, Genre $genre): bool + { + return $this->canManageSettings($user, $genre->organisation); + } + + public function delete(User $user, Genre $genre): bool + { + return $this->canManageSettings($user, $genre->organisation); + } + + private function canManageSettings(User $user, Organisation $organisation): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + return $organisation->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'org_admin') + ->exists(); + } +} diff --git a/api/app/Policies/PerformancePolicy.php b/api/app/Policies/PerformancePolicy.php new file mode 100644 index 00000000..df7481a8 --- /dev/null +++ b/api/app/Policies/PerformancePolicy.php @@ -0,0 +1,110 @@ +canViewProgram($user, $event); + } + + public function view(User $user, Performance $performance, Event $event): bool + { + if ($performance->event_id !== $event->id) { + return false; + } + + return $this->canViewProgram($user, $event); + } + + public function create(User $user, Event $event): bool + { + return $this->canManageProgram($user, $event); + } + + public function update(User $user, Performance $performance, Event $event): bool + { + if ($performance->event_id !== $event->id) { + return false; + } + + return $this->canManageProgram($user, $event); + } + + public function delete(User $user, Performance $performance, Event $event): bool + { + if ($performance->event_id !== $event->id) { + return false; + } + + return $this->canManageProgram($user, $event); + } + + public function move(User $user, Performance $performance, Event $event): bool + { + if ($performance->event_id !== $event->id) { + return false; + } + + return $this->canManageProgram($user, $event); + } + + private function canViewProgram(User $user, Event $event): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + $isOrgMember = $event->organisation->users() + ->where('user_id', $user->id) + ->wherePivotIn('role', ['org_admin', 'program_manager', 'production_assistant']) + ->exists(); + + if ($isOrgMember) { + return true; + } + + return $event->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'event_manager') + ->exists(); + } + + private function canManageProgram(User $user, Event $event): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + $isOrgManager = $event->organisation->users() + ->where('user_id', $user->id) + ->wherePivotIn('role', ['org_admin', 'program_manager']) + ->exists(); + + if ($isOrgManager) { + return true; + } + + return $event->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'event_manager') + ->exists(); + } +} diff --git a/api/app/Policies/StagePolicy.php b/api/app/Policies/StagePolicy.php new file mode 100644 index 00000000..74b5f9c4 --- /dev/null +++ b/api/app/Policies/StagePolicy.php @@ -0,0 +1,106 @@ +canViewProgram($user, $event); + } + + public function view(User $user, Stage $stage, Event $event): bool + { + if ($stage->event_id !== $event->id) { + return false; + } + + return $this->canViewProgram($user, $event); + } + + public function create(User $user, Event $event): bool + { + return $this->canManageProgram($user, $event); + } + + public function update(User $user, Stage $stage, Event $event): bool + { + if ($stage->event_id !== $event->id) { + return false; + } + + return $this->canManageProgram($user, $event); + } + + public function delete(User $user, Stage $stage, Event $event): bool + { + if ($stage->event_id !== $event->id) { + return false; + } + + return $this->canManageProgram($user, $event); + } + + public function reorder(User $user, Event $event): bool + { + return $this->canManageProgram($user, $event); + } + + private function canViewProgram(User $user, Event $event): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + $isOrgMember = $event->organisation->users() + ->where('user_id', $user->id) + ->wherePivotIn('role', ['org_admin', 'program_manager', 'production_assistant']) + ->exists(); + + if ($isOrgMember) { + return true; + } + + return $event->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'event_manager') + ->exists(); + } + + private function canManageProgram(User $user, Event $event): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + $isOrgManager = $event->organisation->users() + ->where('user_id', $user->id) + ->wherePivotIn('role', ['org_admin', 'program_manager']) + ->exists(); + + if ($isOrgManager) { + return true; + } + + return $event->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'event_manager') + ->exists(); + } +} diff --git a/api/app/Rules/Artist/ContractRequiresFee.php b/api/app/Rules/Artist/ContractRequiresFee.php new file mode 100644 index 00000000..63021c68 --- /dev/null +++ b/api/app/Rules/Artist/ContractRequiresFee.php @@ -0,0 +1,40 @@ +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.'); + } + } +} diff --git a/api/app/Rules/Artist/OptionExpiresInFuture.php b/api/app/Rules/Artist/OptionExpiresInFuture.php new file mode 100644 index 00000000..cfc67eb6 --- /dev/null +++ b/api/app/Rules/Artist/OptionExpiresInFuture.php @@ -0,0 +1,42 @@ +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.'); + } + } +} diff --git a/api/app/Rules/Artist/StageActiveOnEvent.php b/api/app/Rules/Artist/StageActiveOnEvent.php new file mode 100644 index 00000000..5dfde581 --- /dev/null +++ b/api/app/Rules/Artist/StageActiveOnEvent.php @@ -0,0 +1,43 @@ +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.'); + } + } +} diff --git a/api/app/Rules/Artist/WithinEventBounds.php b/api/app/Rules/Artist/WithinEventBounds.php new file mode 100644 index 00000000..6935cd7a --- /dev/null +++ b/api/app/Rules/Artist/WithinEventBounds.php @@ -0,0 +1,51 @@ +eventId === null) { + return; + } + + $event = Event::withoutGlobalScopes()->find($this->eventId); + if ($event === null) { + return; + } + + $candidate = CarbonImmutable::parse((string) $value); + $start = CarbonImmutable::instance($event->start_date)->startOfDay(); + $end = CarbonImmutable::instance($event->end_date)->endOfDay(); + + 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'), + )); + } + } +} diff --git a/api/app/Services/Artist/ArtistEngagementService.php b/api/app/Services/Artist/ArtistEngagementService.php new file mode 100644 index 00000000..b1e15dca --- /dev/null +++ b/api/app/Services/Artist/ArtistEngagementService.php @@ -0,0 +1,233 @@ + $attributes + */ + public function create(Event $event, Artist $artist, array $attributes): ArtistEngagement + { + return DB::transaction(function () use ($event, $artist, $attributes): ArtistEngagement { + $engagement = new ArtistEngagement($attributes); + $engagement->artist_id = $artist->id; + $engagement->event_id = $event->id; + + // D26 — Buma auto-flip. If artist's agent_company handles + // BUMA reporting itself, default the engagement to + // BookingAgency; otherwise default to Organisation. Caller + // may override via $attributes. + if (! array_key_exists('buma_handled_by', $attributes)) { + $artist->loadMissing('agentCompany'); + $engagement->buma_handled_by = ($artist->agentCompany?->handles_buma === true) + ? BumaHandledBy::BookingAgency + : BumaHandledBy::Organisation; + } + + $status = $this->coerceStatus($engagement->booking_status ?? ArtistEngagementStatus::Draft); + $this->validateInitialStatus($status, $engagement); + $engagement->booking_status = $status; + + $engagement->save(); + + return $engagement->refresh(); + }); + } + + /** + * Partial update. Status changes route through transitionStatus so + * the state machine fires on every status change regardless of the + * other fields in the payload. + * + * @param array $attributes + */ + public function update(ArtistEngagement $engagement, array $attributes): ArtistEngagement + { + return DB::transaction(function () use ($engagement, $attributes): ArtistEngagement { + unset($attributes['organisation_id'], $attributes['artist_id'], $attributes['event_id']); + + if (array_key_exists('booking_status', $attributes)) { + $newStatus = $this->coerceStatus($attributes['booking_status']); + unset($attributes['booking_status']); + $engagement->fill($attributes); + $this->transitionStatus($engagement, $newStatus, $attributes); + $engagement->save(); + } else { + $engagement->fill($attributes); + $engagement->save(); + } + + return $engagement->refresh(); + }); + } + + /** + * Atomic transition with full state-machine validation. Throws + * InvalidStatusTransitionException on illegal moves. + * + * @param array $contextAttributes fields already filled on $engagement + */ + public function transitionStatus( + ArtistEngagement $engagement, + ArtistEngagementStatus $to, + array $contextAttributes = [], + ): ArtistEngagement { + $from = $this->coerceStatus($engagement->booking_status); + + if ($from === $to) { + return $engagement; + } + + $this->validateTransition($from, $to, $engagement); + + $engagement->booking_status = $to; + $engagement->save(); + + // RFC §8 — explicit `status_changed` audit event, separate from + // the auto-logged `updated` row that captures the diff. Pairs + // with the `cancelled` and `option_expired` events emitted by + // cancel() and the DemoteExpiredOptions command respectively. + activity('artist_engagement') + ->performedOn($engagement) + ->event($to === ArtistEngagementStatus::Cancelled ? 'cancelled' : 'status_changed') + ->withProperties([ + 'from' => $from->value, + 'to' => $to->value, + 'organisation_id' => $engagement->organisation_id, + 'event_id' => $engagement->event_id, + ]) + ->log($to === ArtistEngagementStatus::Cancelled ? 'cancelled' : 'status_changed'); + + return $engagement; + } + + /** + * Cancel = transition to Cancelled, then soft-delete. Observer + * cascades to performances + advance_sections per RFC §5.4. + */ + public function cancel(ArtistEngagement $engagement): ArtistEngagement + { + return DB::transaction(function () use ($engagement): ArtistEngagement { + $from = $this->coerceStatus($engagement->booking_status); + if ($from !== ArtistEngagementStatus::Cancelled) { + $this->transitionStatus($engagement, ArtistEngagementStatus::Cancelled); + } + $engagement->delete(); + + return $engagement->refresh(); + }); + } + + /** + * Soft delete only. Observer cascades to children. Use cancel() if + * the booking_status should also flip — the API destroy endpoint + * routes here directly to keep status untouched (§6.2 — DELETE + * /engagements/{id} is "remove the booking", semantically distinct + * from "cancel"). + */ + public function softDelete(ArtistEngagement $engagement): void + { + $engagement->delete(); + } + + private function validateInitialStatus(ArtistEngagementStatus $status, ArtistEngagement $engagement): void + { + if ($status === ArtistEngagementStatus::Option) { + $expires = $engagement->option_expires_at; + if ($expires === null || $expires->isPast()) { + throw new InvalidStatusTransitionException( + ArtistEngagementStatus::Draft, + $status, + 'Option requires option_expires_at in the future.', + ); + } + } + + if ($status === ArtistEngagementStatus::Contracted) { + $fee = $engagement->fee_amount; + if ($fee === null || (float) $fee <= 0) { + throw new InvalidStatusTransitionException( + ArtistEngagementStatus::Draft, + $status, + 'Contracted requires fee_amount.', + ); + } + } + } + + private function validateTransition( + ArtistEngagementStatus $from, + ArtistEngagementStatus $to, + ArtistEngagement $engagement, + ): void { + if ($from === $to) { + return; + } + + if (in_array($from, self::TERMINAL, true)) { + throw new InvalidStatusTransitionException( + $from, + $to, + sprintf('%s is terminal — re-booking creates a new engagement.', $from->value), + ); + } + + if ($to === ArtistEngagementStatus::Option) { + $expires = $engagement->option_expires_at; + if ($expires === null || $expires->isPast()) { + throw new InvalidStatusTransitionException( + $from, + $to, + 'Option requires option_expires_at in the future.', + ); + } + } + + if ($to === ArtistEngagementStatus::Contracted) { + $fee = $engagement->fee_amount; + if ($fee === null || (float) $fee <= 0) { + throw new InvalidStatusTransitionException( + $from, + $to, + 'Contracted requires fee_amount.', + ); + } + } + } + + private function coerceStatus(ArtistEngagementStatus|string $value): ArtistEngagementStatus + { + return $value instanceof ArtistEngagementStatus + ? $value + : ArtistEngagementStatus::from($value); + } +} diff --git a/api/app/Services/Artist/ArtistService.php b/api/app/Services/Artist/ArtistService.php new file mode 100644 index 00000000..06a90e07 --- /dev/null +++ b/api/app/Services/Artist/ArtistService.php @@ -0,0 +1,79 @@ + $attributes + */ + public function create(Organisation $organisation, array $attributes): Artist + { + return DB::transaction(function () use ($organisation, $attributes): Artist { + $name = (string) $attributes['name']; + $existing = Artist::query() + ->where('organisation_id', $organisation->id) + ->whereRaw('LOWER(name) = ?', [mb_strtolower($name)]) + ->first(); + + if ($existing !== null) { + throw new DuplicateArtistException($existing); + } + + unset($attributes['organisation_id'], $attributes['slug']); + $artist = new Artist($attributes); + $artist->organisation_id = $organisation->id; + $artist->save(); + + return $artist->refresh(); + }); + } + + /** + * Master-update. Disallows organisation_id mutation (cross-tenant + * move is a separate, audited operation we don't expose here). + * Slug is not editable post-create either; rename produces a fresh + * slug only on Session 4 admin "force-rename" flow if/when added. + * + * @param array $attributes + */ + public function update(Artist $artist, array $attributes): Artist + { + unset($attributes['organisation_id'], $attributes['slug']); + + $artist->fill($attributes); + $artist->save(); + + return $artist->refresh(); + } + + /** + * Soft delete. Engagements are intentionally untouched per RFC D27 — + * historical engagements survive their master being archived; the + * frontend renders a "trashed" banner on the engagement detail. + */ + public function softDelete(Artist $artist): void + { + $artist->delete(); + } + + public function restore(Artist $artist): Artist + { + $artist->restore(); + + return $artist->refresh(); + } +} diff --git a/api/app/Services/Artist/GenreService.php b/api/app/Services/Artist/GenreService.php new file mode 100644 index 00000000..b8994574 --- /dev/null +++ b/api/app/Services/Artist/GenreService.php @@ -0,0 +1,59 @@ + $attributes + */ + public function create(Organisation $organisation, array $attributes): Genre + { + return DB::transaction(function () use ($organisation, $attributes): Genre { + $genre = new Genre($attributes); + $genre->organisation_id = $organisation->id; + $genre->is_active = $attributes['is_active'] ?? true; + $genre->save(); + + return $genre->refresh(); + }); + } + + /** + * @param array $attributes + */ + public function update(Genre $genre, array $attributes): Genre + { + unset($attributes['organisation_id']); + + $genre->fill($attributes); + $genre->save(); + + return $genre->refresh(); + } + + /** + * Hard delete (genres are config — no soft-delete column on the table). + * Blocks if any artist references this genre as `default_genre_id`. + */ + public function delete(Genre $genre): void + { + $referencingCount = Artist::query() + ->where('default_genre_id', $genre->id) + ->count(); + + if ($referencingCount > 0) { + throw new GenreInUseException($genre, $referencingCount); + } + + $genre->delete(); + } +} diff --git a/api/app/Services/Artist/LaneCascadeService.php b/api/app/Services/Artist/LaneCascadeService.php new file mode 100644 index 00000000..ecc13df1 --- /dev/null +++ b/api/app/Services/Artist/LaneCascadeService.php @@ -0,0 +1,181 @@ +whereKey($performance->id) + ->lockForUpdate() + ->firstOrFail(); + + if ((int) $locked->version !== $clientVersion) { + throw new VersionMismatchException( + currentVersion: (int) $locked->version, + clientVersion: $clientVersion, + ); + } + + if ($targetStage === null) { + $locked->stage_id = null; + if ($start !== null) { + $locked->start_at = $start; + } + if ($end !== null) { + $locked->end_at = $end; + } + if ($targetLane !== null) { + $locked->lane = $targetLane; + } + $locked->disableLogging(); // suppress auto-log; park is captured in explicit entry below + $locked->save(); + + $parked = $locked->refresh(); + + activity('performance') + ->performedOn($parked) + ->event('parked') + ->withProperties([ + 'event_id' => $parked->event_id, + 'organisation_id' => $parked->engagement?->organisation_id, + ]) + ->log('parked'); + + return new MoveResult($parked, []); + } + + if ($start === null || $end === null || $targetLane === null) { + throw new \InvalidArgumentException( + 'targetStage non-null requires start, end and targetLane.', + ); + } + + $newEventId = $this->resolveEventIdForStageAndStart($targetStage, $start) + ?? $performance->event_id; + + // Lock all peer performances on (stage, event, lane) for the + // duration of the cascade. The first iteration locks the + // already-existing rows; new placements that follow this + // transaction wait on these row locks until commit. + $existingOnLane = Performance::query() + ->where('stage_id', $targetStage->id) + ->where('event_id', $newEventId) + ->where('lane', $targetLane) + ->where('id', '!=', $locked->id) + ->lockForUpdate() + ->get(); + + $cascaded = []; + foreach ($existingOnLane as $other) { + if ($this->overlaps($start, $end, $other->start_at, $other->end_at)) { + $other->lane = (int) $other->lane + 1; + $other->disableLogging(); // suppress auto-log; cascade is captured in parent entry + $other->save(); + $cascaded[] = $other->refresh(); + } + } + + $locked->stage_id = $targetStage->id; + $locked->event_id = $newEventId; + $locked->start_at = $start; + $locked->end_at = $end; + $locked->lane = $targetLane; + $locked->disableLogging(); // suppress auto-log; move is captured in explicit entry below + $locked->save(); + + $moved = $locked->refresh(); + + // RFC §8 — single parent activity entry summarising the cascade. + // All saves inside this transaction call disableLogging() so the + // auto-log trait does not write per-row 'updated' entries; this + // explicit entry is the only audit record for the move. + activity('performance') + ->performedOn($moved) + ->event('moved') + ->withProperties([ + 'cascade_count' => count($cascaded), + 'cascaded_ids' => array_map(fn ($p): string => (string) $p->id, $cascaded), + 'event_id' => $moved->event_id, + 'organisation_id' => $moved->engagement?->organisation_id, + ]) + ->log('moved'); + + return new MoveResult($moved, $cascaded); + }); + } + + /** + * For festivals, `target_start_at` falls inside one of the stage's + * sub-events; for flat events, the stage's own event_id is the + * answer. Returns null if no stage_day matches — caller falls back + * to keeping the performance's existing event_id (validation rules + * in the FormRequest already guarantee a valid stage_day exists). + */ + private function resolveEventIdForStageAndStart(Stage $stage, CarbonImmutable $start): ?string + { + return $stage->stageDays() + ->join('events', 'events.id', '=', 'stage_days.event_id') + ->where('events.start_date', '<=', $start->toDateString()) + ->where('events.end_date', '>=', $start->toDateString()) + ->orderBy('events.start_date', 'desc') + ->limit(1) + ->value('stage_days.event_id'); + } + + private function overlaps( + CarbonImmutable $aStart, + CarbonImmutable $aEnd, + \DateTimeInterface $bStart, + \DateTimeInterface $bEnd, + ): bool { + return $aStart < CarbonImmutable::instance($bEnd) + && CarbonImmutable::instance($bStart) < $aEnd; + } +} diff --git a/api/app/Services/Artist/LaneResolver.php b/api/app/Services/Artist/LaneResolver.php new file mode 100644 index 00000000..4811298c --- /dev/null +++ b/api/app/Services/Artist/LaneResolver.php @@ -0,0 +1,70 @@ + $performances scoped to one + * (stage_id, event_id) + * group + * @return array perf-id → resolved lane + */ + public function resolve(iterable $performances): array + { + $list = Collection::make($performances) + ->sortBy([ + fn (Performance $p): string => (string) $p->start_at, + fn (Performance $p): int => (int) $p->lane, + ]) + ->values(); + + /** @var array $laneEnds */ + $laneEnds = []; + $resolved = []; + + foreach ($list as $perf) { + $start = CarbonImmutable::instance($perf->start_at); + $end = CarbonImmutable::instance($perf->end_at); + + $assigned = null; + foreach ($laneEnds as $idx => $occupiedUntil) { + if ($start >= $occupiedUntil) { + $assigned = $idx; + $laneEnds[$idx] = $end; + break; + } + } + + if ($assigned === null) { + $assigned = count($laneEnds); + $laneEnds[$assigned] = $end; + } + + $resolved[(string) $perf->id] = $assigned; + } + + return $resolved; + } +} diff --git a/api/app/Services/Artist/MoveResult.php b/api/app/Services/Artist/MoveResult.php new file mode 100644 index 00000000..2f22ee75 --- /dev/null +++ b/api/app/Services/Artist/MoveResult.php @@ -0,0 +1,28 @@ + $cascaded + */ + public function __construct( + public readonly Performance $moved, + public readonly array $cascaded, + ) {} +} diff --git a/api/app/Services/Artist/PerformanceService.php b/api/app/Services/Artist/PerformanceService.php new file mode 100644 index 00000000..fb39cb3a --- /dev/null +++ b/api/app/Services/Artist/PerformanceService.php @@ -0,0 +1,109 @@ + $attributes + */ + public function create(ArtistEngagement $engagement, array $attributes): Performance + { + return DB::transaction(function () use ($engagement, $attributes): Performance { + $perf = new Performance($attributes); + $perf->engagement_id = $engagement->id; + $perf->event_id = $attributes['event_id'] ?? $engagement->event_id; + $perf->version = 1; + $perf->save(); + + return $perf->refresh(); + }); + } + + /** + * Non-placement update only. start_at/end_at/stage_id/lane changes + * MUST go through LaneCascadeService::move so the cascade-bump and + * optimistic-lock contract are honoured. + * + * @param array $attributes + */ + public function update(Performance $performance, array $attributes): Performance + { + unset( + $attributes['stage_id'], + $attributes['start_at'], + $attributes['end_at'], + $attributes['lane'], + $attributes['version'], + $attributes['event_id'], + $attributes['engagement_id'], + ); + + $performance->fill($attributes); + $performance->save(); + + return $performance->refresh(); + } + + public function delete(Performance $performance): void + { + $performance->delete(); + } + + /** + * Move into the wachtrij. lane preserved so the user can drag back + * to roughly the same visual position (D12 — wachtrij is just + * stage_id=null, no separate table). + */ + public function park(Performance $performance, int $clientVersion): Performance + { + $result = $this->laneCascade->move( + performance: $performance, + targetStage: null, + start: null, + end: null, + targetLane: null, + clientVersion: $clientVersion, + ); + + return $result->moved; + } + + public function unpark( + Performance $performance, + Stage $targetStage, + CarbonImmutable $start, + CarbonImmutable $end, + int $targetLane, + int $clientVersion, + ): Performance { + $result = $this->laneCascade->move( + performance: $performance, + targetStage: $targetStage, + start: $start, + end: $end, + targetLane: $targetLane, + clientVersion: $clientVersion, + ); + + return $result->moved; + } +} diff --git a/api/app/Services/Artist/StageDayService.php b/api/app/Services/Artist/StageDayService.php new file mode 100644 index 00000000..7a5d2fb2 --- /dev/null +++ b/api/app/Services/Artist/StageDayService.php @@ -0,0 +1,99 @@ + $eventIds + * @return array{added: array, removed: array} + */ + public function replaceDays(Stage $stage, array $eventIds, bool $forceOrphan = false): array + { + return DB::transaction(function () use ($stage, $eventIds, $forceOrphan): array { + $current = $stage->stageDays()->pluck('event_id')->all(); + $proposed = array_values(array_unique($eventIds)); + + $added = array_values(array_diff($proposed, $current)); + $removed = array_values(array_diff($current, $proposed)); + + if ($removed !== [] && ! $forceOrphan) { + $orphanIds = Performance::query() + ->where('stage_id', $stage->id) + ->whereIn('event_id', $removed) + ->whereHas('engagement', function ($q): void { + $q->whereNotIn('booking_status', [ + ArtistEngagementStatus::Cancelled->value, + ArtistEngagementStatus::Rejected->value, + ArtistEngagementStatus::Declined->value, + ]); + }) + ->pluck('id') + ->all(); + + if ($orphanIds !== []) { + throw new StageDaysOrphanedPerformancesException($orphanIds, $removed); + } + } + + if ($removed !== []) { + StageDay::query() + ->where('stage_id', $stage->id) + ->whereIn('event_id', $removed) + ->delete(); + } + + foreach ($added as $eventId) { + StageDay::query()->create([ + 'stage_id' => $stage->id, + 'event_id' => $eventId, + ]); + } + + // RFC §8 — one activity entry per added/removed event_id, + // performed-on the parent stage so the audit log groups + // changes per stage rather than per pivot row. + $stage->loadMissing('event'); + $organisationId = $stage->event?->organisation_id; + foreach ($added as $eventId) { + activity('stage') + ->performedOn($stage) + ->event('day_added') + ->withProperties([ + 'event_id' => $eventId, + 'organisation_id' => $organisationId, + ]) + ->log('day_added'); + } + foreach ($removed as $eventId) { + activity('stage') + ->performedOn($stage) + ->event('day_removed') + ->withProperties([ + 'event_id' => $eventId, + 'organisation_id' => $organisationId, + ]) + ->log('day_removed'); + } + + return ['added' => $added, 'removed' => $removed]; + }); + } +} diff --git a/api/app/Services/Artist/StageService.php b/api/app/Services/Artist/StageService.php new file mode 100644 index 00000000..f4649461 --- /dev/null +++ b/api/app/Services/Artist/StageService.php @@ -0,0 +1,94 @@ + $attributes + */ + public function create(Event $event, array $attributes): Stage + { + return DB::transaction(function () use ($event, $attributes): Stage { + $stage = new Stage($attributes); + $stage->event_id = $event->id; + if (! isset($attributes['sort_order'])) { + $stage->sort_order = (int) Stage::query()->where('event_id', $event->id)->max('sort_order') + 1; + } + $stage->save(); + + return $stage->refresh(); + }); + } + + /** + * @param array $attributes + */ + public function update(Stage $stage, array $attributes): Stage + { + unset($attributes['event_id']); + + $stage->fill($attributes); + $stage->save(); + + return $stage->refresh(); + } + + /** + * Hard delete (stages have no SoftDeletes trait — they are config- + * level structure, not event-history). Performances on this stage + * are cascade-parked: stage_id → null, lane preserved so they re- + * appear in the wachtrij with their previous lane index for visual + * continuity. Caller wraps the cascade count into the activity log + * (Step 9, RFC §8 stage.deleted entry). + */ + public function delete(Stage $stage): int + { + return DB::transaction(function () use ($stage): int { + $parkedCount = Performance::query() + ->where('stage_id', $stage->id) + ->update(['stage_id' => null]); + + $stage->stageDays()->delete(); + $stage->delete(); + + return $parkedCount; + }); + } + + /** + * Persist a new stage order. The request layer validates that + * `$orderedStageIds` is a permutation of all stage ids on + * `$event` — here we trust that and just write the new sort_order. + * + * @param array $orderedStageIds + */ + public function reorder(Event $event, array $orderedStageIds): void + { + DB::transaction(function () use ($event, $orderedStageIds): void { + foreach ($orderedStageIds as $position => $stageId) { + Stage::query() + ->where('event_id', $event->id) + ->where('id', $stageId) + ->update(['sort_order' => $position]); + } + + activity('stage') + ->on($event) + ->event('reordered') + ->withProperties([ + 'event_id' => $event->id, + 'organisation_id' => $event->organisation_id, + 'stage_ids' => $orderedStageIds, + ]) + ->log('reordered'); + }); + } +} diff --git a/api/bootstrap/app.php b/api/bootstrap/app.php index d8978393..8e5268f8 100644 --- a/api/bootstrap/app.php +++ b/api/bootstrap/app.php @@ -59,6 +59,9 @@ return Application::configure(basePath: dirname(__DIR__)) 'portal.token' => \App\Http\Middleware\PortalTokenMiddleware::class, 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, 'impersonation' => \App\Http\Middleware\HandleImpersonation::class, + // RFC-TIMETABLE v0.2 R1 — 60s Redis idempotency window for + // POST /timetable/move. Narrow scope by design. + 'idempotency.60s' => \App\Http\Middleware\IdempotencyKey60sRedis::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/api/database/seeders/RoleSeeder.php b/api/database/seeders/RoleSeeder.php index c32d7bf4..92100d98 100644 --- a/api/database/seeders/RoleSeeder.php +++ b/api/database/seeders/RoleSeeder.php @@ -22,5 +22,15 @@ class RoleSeeder extends Seeder Role::findOrCreate('event_manager', 'web'); Role::findOrCreate('staff_coordinator', 'web'); Role::findOrCreate('volunteer_coordinator', 'web'); + + // RFC-TIMETABLE v0.2 §9 — program/production roles. Per Phase A + // decision (2026-05-08), Crewli authorises by role only; the four + // RFC §9 permission strings (events.view_program, events.manage_program, + // organisations.manage_artists, organisations.manage_settings) are + // mapped to roles in policy class docblocks rather than seeded as + // Spatie permissions. See BACKLOG entry AUTH-PERMISSIONS-MIGRATION + // for the eventual cross-cutting migration. + Role::findOrCreate('program_manager', 'web'); + Role::findOrCreate('production_assistant', 'web'); } } diff --git a/api/phpstan-baseline.neon b/api/phpstan-baseline.neon index c08a06a2..2cb7659c 100644 --- a/api/phpstan-baseline.neon +++ b/api/phpstan-baseline.neon @@ -1,5 +1,23 @@ parameters: ignoreErrors: + - + message: '#^Comparison operation "\>" between 0 and 0 is always false\.$#' + identifier: greater.alwaysFalse + count: 1 + path: app/Console/Commands/Artist/DemoteExpiredOptions.php + + - + message: '#^Strict comparison using \!\=\= between string and App\\Enums\\Artist\\ArtistEngagementStatus\:\:Option will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: app/Console/Commands/Artist/DemoteExpiredOptions.php + + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: app/Console/Commands/Artist/DemoteExpiredOptions.php + - message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$organisation_id\.$#' identifier: property.notFound @@ -756,6 +774,36 @@ parameters: count: 1 path: app/Http/Controllers/Controller.php + - + message: '#^Cannot access property \$value on string\.$#' + identifier: property.nonObject + count: 1 + path: app/Http/Requests/Api/V1/Artist/UpdateArtistEngagementRequest.php + + - + message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull + count: 1 + path: app/Http/Requests/Api/V1/Artist/UpdateArtistEngagementRequest.php + + - + message: '#^Using nullsafe property access "\?\-\>id" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull + count: 1 + path: app/Http/Requests/Api/V1/Artist/UpdateArtistRequest.php + + - + message: '#^Using nullsafe property access "\?\-\>id" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull + count: 1 + path: app/Http/Requests/Api/V1/Artist/UpdateGenreRequest.php + + - + message: '#^Using nullsafe property access "\?\-\>id" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull + count: 1 + path: app/Http/Requests/Api/V1/Artist/UpdateStageRequest.php + - message: '#^Method App\\Http\\Requests\\Api\\V1\\Auth\\MfaConfirmRequest\:\:rules\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -1062,6 +1110,120 @@ parameters: count: 1 path: app/Http/Resources/Admin/ImpersonationSessionResource.php + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$email\.$#' + identifier: property.notFound + count: 1 + path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$first_name\.$#' + identifier: property.notFound + count: 1 + path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#' + identifier: property.notFound + count: 1 + path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$last_name\.$#' + identifier: property.notFound + count: 1 + path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php + + - + message: '#^Call to function is_array\(\) with string will always evaluate to false\.$#' + identifier: function.impossibleType + count: 1 + path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php + + - + message: '#^Cannot access property \$value on string\.$#' + identifier: property.nonObject + count: 4 + path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php + + - + message: '#^Cannot call method label\(\) on string\.$#' + identifier: method.nonObject + count: 4 + path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php + + - + message: '#^Offset ''amount'' on \*NEVER\* in isset\(\) always exists and is not nullable\.$#' + identifier: isset.offset + count: 1 + path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php + + - + message: '#^Result of && is always false\.$#' + identifier: booleanAnd.alwaysFalse + count: 2 + path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php + + - + message: '#^Strict comparison using \=\=\= between string and App\\Enums\\Artist\\BumaHandledBy\:\:Organisation will always evaluate to false\.$#' + identifier: identical.alwaysFalse + count: 2 + path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php + + - + message: '#^Using nullsafe method call on non\-nullable type string\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull + count: 3 + path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php + + - + message: '#^Using nullsafe property access "\?\-\>first_name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull + count: 1 + path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php + + - + message: '#^Using nullsafe property access "\?\-\>last_name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull + count: 1 + path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php + + - + message: '#^Using nullsafe property access on non\-nullable type string\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull + count: 3 + path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$handles_buma\.$#' + identifier: property.notFound + count: 1 + path: app/Http/Resources/Api/V1/Artist/ArtistResource.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#' + identifier: property.notFound + count: 1 + path: app/Http/Resources/Api/V1/Artist/ArtistResource.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$name\.$#' + identifier: property.notFound + count: 1 + path: app/Http/Resources/Api/V1/Artist/ArtistResource.php + + - + message: '#^Using nullsafe property access "\?\-\>handles_buma" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' + identifier: nullsafe.neverNull + count: 1 + path: app/Http/Resources/Api/V1/Artist/ArtistResource.php + + - + message: '#^Parameter \#1 \$date of static method Carbon\\CarbonImmutable\:\:instance\(\) expects DateTimeInterface, string given\.$#' + identifier: argument.type + count: 6 + path: app/Http/Resources/Api/V1/Artist/PerformanceResource.php + - message: '#^Access to an undefined property App\\Http\\Resources\\Api\\V1\\CompanyResource\:\:\$contact_email\.$#' identifier: property.notFound @@ -3462,6 +3624,12 @@ parameters: count: 1 path: app/Models/AdvanceSubmission.php + - + message: '#^Access to property \$properties on an unknown class App\\Models\\Activity\.$#' + identifier: class.notFound + count: 3 + path: app/Models/Artist.php + - message: '#^Class App\\Models\\Artist uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#' identifier: missingType.generics @@ -3498,6 +3666,24 @@ parameters: count: 1 path: app/Models/Artist.php + - + message: '#^Parameter \$activity of method App\\Models\\Artist\:\:tapActivity\(\) has invalid type App\\Models\\Activity\.$#' + identifier: class.notFound + count: 1 + path: app/Models/Artist.php + + - + message: '#^Unable to resolve the template type TKey in call to function collect$#' + identifier: argument.templateType + count: 1 + path: app/Models/Artist.php + + - + message: '#^Unable to resolve the template type TValue in call to function collect$#' + identifier: argument.templateType + count: 1 + path: app/Models/Artist.php + - message: '#^Class App\\Models\\ArtistContact uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#' identifier: missingType.generics @@ -3522,6 +3708,12 @@ parameters: count: 1 path: app/Models/ArtistContact.php + - + message: '#^Access to property \$properties on an unknown class App\\Models\\Activity\.$#' + identifier: class.notFound + count: 3 + path: app/Models/ArtistEngagement.php + - message: '#^Class App\\Models\\ArtistEngagement uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#' identifier: missingType.generics @@ -3564,6 +3756,24 @@ parameters: count: 1 path: app/Models/ArtistEngagement.php + - + message: '#^Parameter \$activity of method App\\Models\\ArtistEngagement\:\:tapActivity\(\) has invalid type App\\Models\\Activity\.$#' + identifier: class.notFound + count: 1 + path: app/Models/ArtistEngagement.php + + - + message: '#^Unable to resolve the template type TKey in call to function collect$#' + identifier: argument.templateType + count: 1 + path: app/Models/ArtistEngagement.php + + - + message: '#^Unable to resolve the template type TValue in call to function collect$#' + identifier: argument.templateType + count: 1 + path: app/Models/ArtistEngagement.php + - message: '#^Class App\\Models\\Company uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#' identifier: missingType.generics @@ -4446,6 +4656,12 @@ parameters: count: 1 path: app/Models/FormBuilder/FormWebhookDelivery.php + - + message: '#^Access to property \$properties on an unknown class App\\Models\\Activity\.$#' + identifier: class.notFound + count: 3 + path: app/Models/Genre.php + - message: '#^Class App\\Models\\Genre uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#' identifier: missingType.generics @@ -4464,6 +4680,24 @@ parameters: count: 1 path: app/Models/Genre.php + - + message: '#^Parameter \$activity of method App\\Models\\Genre\:\:tapActivity\(\) has invalid type App\\Models\\Activity\.$#' + identifier: class.notFound + count: 1 + path: app/Models/Genre.php + + - + message: '#^Unable to resolve the template type TKey in call to function collect$#' + identifier: argument.templateType + count: 1 + path: app/Models/Genre.php + + - + message: '#^Unable to resolve the template type TValue in call to function collect$#' + identifier: argument.templateType + count: 1 + path: app/Models/Genre.php + - message: '#^Class App\\Models\\ImpersonationSession uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#' identifier: missingType.generics @@ -4644,6 +4878,18 @@ parameters: count: 1 path: app/Models/OrganisationEmailTemplate.php + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$organisation_id\.$#' + identifier: property.notFound + count: 1 + path: app/Models/Performance.php + + - + message: '#^Access to property \$properties on an unknown class App\\Models\\Activity\.$#' + identifier: class.notFound + count: 3 + path: app/Models/Performance.php + - message: '#^Class App\\Models\\Performance uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#' identifier: missingType.generics @@ -4668,6 +4914,24 @@ parameters: count: 1 path: app/Models/Performance.php + - + message: '#^Parameter \$activity of method App\\Models\\Performance\:\:tapActivity\(\) has invalid type App\\Models\\Activity\.$#' + identifier: class.notFound + count: 1 + path: app/Models/Performance.php + + - + message: '#^Unable to resolve the template type TKey in call to function collect$#' + identifier: argument.templateType + count: 1 + path: app/Models/Performance.php + + - + message: '#^Unable to resolve the template type TValue in call to function collect$#' + identifier: argument.templateType + count: 1 + path: app/Models/Performance.php + - message: '#^Class App\\Models\\Person uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#' identifier: missingType.generics @@ -5154,6 +5418,18 @@ parameters: count: 1 path: app/Models/ShiftWaitlist.php + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$organisation_id\.$#' + identifier: property.notFound + count: 1 + path: app/Models/Stage.php + + - + message: '#^Access to property \$properties on an unknown class App\\Models\\Activity\.$#' + identifier: class.notFound + count: 3 + path: app/Models/Stage.php + - message: '#^Class App\\Models\\Stage uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#' identifier: missingType.generics @@ -5190,6 +5466,24 @@ parameters: count: 1 path: app/Models/Stage.php + - + message: '#^Parameter \$activity of method App\\Models\\Stage\:\:tapActivity\(\) has invalid type App\\Models\\Activity\.$#' + identifier: class.notFound + count: 1 + path: app/Models/Stage.php + + - + message: '#^Unable to resolve the template type TKey in call to function collect$#' + identifier: argument.templateType + count: 1 + path: app/Models/Stage.php + + - + message: '#^Unable to resolve the template type TValue in call to function collect$#' + identifier: argument.templateType + count: 1 + path: app/Models/Stage.php + - message: '#^Class App\\Models\\StageDay uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#' identifier: missingType.generics @@ -5508,6 +5802,24 @@ parameters: count: 1 path: app/Observers/FormBuilder/FormValueObserver.php + - + message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:users\(\)\.$#' + identifier: method.notFound + count: 2 + path: app/Policies/ArtistEngagementPolicy.php + + - + message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:users\(\)\.$#' + identifier: method.notFound + count: 1 + path: app/Policies/ArtistPolicy.php + + - + message: '#^Parameter \#2 \$organisation of method App\\Policies\\ArtistPolicy\:\:canManageArtists\(\) expects App\\Models\\Organisation, Illuminate\\Database\\Eloquent\\Model\|null given\.$#' + identifier: argument.type + count: 3 + path: app/Policies/ArtistPolicy.php + - message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:users\(\)\.$#' identifier: method.notFound @@ -5598,12 +5910,30 @@ parameters: count: 2 path: app/Policies/FormBuilder/FormTemplatePolicy.php + - + message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:users\(\)\.$#' + identifier: method.notFound + count: 1 + path: app/Policies/GenrePolicy.php + + - + message: '#^Parameter \#2 \$organisation of method App\\Policies\\GenrePolicy\:\:canManageSettings\(\) expects App\\Models\\Organisation, Illuminate\\Database\\Eloquent\\Model\|null given\.$#' + identifier: argument.type + count: 2 + path: app/Policies/GenrePolicy.php + - message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:users\(\)\.$#' identifier: method.notFound count: 2 path: app/Policies/LocationPolicy.php + - + message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:users\(\)\.$#' + identifier: method.notFound + count: 2 + path: app/Policies/PerformancePolicy.php + - message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$event\.$#' identifier: property.notFound @@ -5640,12 +5970,90 @@ parameters: count: 3 path: app/Policies/ShiftPolicy.php + - + message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:users\(\)\.$#' + identifier: method.notFound + count: 2 + path: app/Policies/StagePolicy.php + - message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:users\(\)\.$#' identifier: method.notFound count: 2 path: app/Policies/TimeSlotPolicy.php + - + message: '#^Parameter \#1 \$date of static method Carbon\\CarbonImmutable\:\:instance\(\) expects DateTimeInterface, string given\.$#' + identifier: argument.type + count: 2 + path: app/Rules/Artist/WithinEventBounds.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$handles_buma\.$#' + identifier: property.notFound + count: 1 + path: app/Services/Artist/ArtistEngagementService.php + + - + message: '#^Cannot call method isPast\(\) on string\.$#' + identifier: method.nonObject + count: 2 + path: app/Services/Artist/ArtistEngagementService.php + + - + message: '#^Property App\\Models\\ArtistEngagement\:\:\$booking_status \(string\) does not accept App\\Enums\\Artist\\ArtistEngagementStatus\.$#' + identifier: assign.propertyType + count: 2 + path: app/Services/Artist/ArtistEngagementService.php + + - + message: '#^Property App\\Models\\ArtistEngagement\:\:\$buma_handled_by \(string\) does not accept App\\Enums\\Artist\\BumaHandledBy\:\:BookingAgency\|App\\Enums\\Artist\\BumaHandledBy\:\:Organisation\.$#' + identifier: assign.propertyType + count: 1 + path: app/Services/Artist/ArtistEngagementService.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$organisation_id\.$#' + identifier: property.notFound + count: 2 + path: app/Services/Artist/LaneCascadeService.php + + - + message: '#^Parameter \#3 \$bStart of method App\\Services\\Artist\\LaneCascadeService\:\:overlaps\(\) expects DateTimeInterface, string given\.$#' + identifier: argument.type + count: 1 + path: app/Services/Artist/LaneCascadeService.php + + - + message: '#^Parameter \#4 \$bEnd of method App\\Services\\Artist\\LaneCascadeService\:\:overlaps\(\) expects DateTimeInterface, string given\.$#' + identifier: argument.type + count: 1 + path: app/Services/Artist/LaneCascadeService.php + + - + message: '#^Property App\\Models\\Performance\:\:\$end_at \(string\) does not accept Carbon\\CarbonImmutable\.$#' + identifier: assign.propertyType + count: 2 + path: app/Services/Artist/LaneCascadeService.php + + - + message: '#^Property App\\Models\\Performance\:\:\$start_at \(string\) does not accept Carbon\\CarbonImmutable\.$#' + identifier: assign.propertyType + count: 2 + path: app/Services/Artist/LaneCascadeService.php + + - + message: '#^Parameter \#1 \$date of static method Carbon\\CarbonImmutable\:\:instance\(\) expects DateTimeInterface, string given\.$#' + identifier: argument.type + count: 2 + path: app/Services/Artist/LaneResolver.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$organisation_id\.$#' + identifier: property.notFound + count: 1 + path: app/Services/Artist/StageDayService.php + - message: '#^Method App\\Services\\CrowdListService\:\:create\(\) has parameter \$data with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -7260,6 +7668,18 @@ parameters: count: 2 path: tests/Feature/Api/V1/ShiftAssignmentWorkflowTest.php + - + message: '#^Method Tests\\Feature\\Artist\\BumaVatCalculationTest\:\:compute\(\) has parameter \$attrs with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: tests/Feature/Artist/BumaVatCalculationTest.php + + - + message: '#^Method Tests\\Feature\\Artist\\BumaVatCalculationTest\:\:compute\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: tests/Feature/Artist/BumaVatCalculationTest.php + - message: '#^Unable to resolve the template type TKey in call to function collect$#' identifier: argument.templateType diff --git a/api/routes/api.php b/api/routes/api.php index 412c70d1..f532ec5f 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -337,8 +337,31 @@ Route::middleware(['auth:sanctum', 'impersonation'])->group(function () { Route::get('crowd-lists/{crowdList}/persons', [CrowdListController::class, 'persons']); Route::post('crowd-lists/{crowdList}/persons', [CrowdListController::class, 'addPerson']); Route::delete('crowd-lists/{crowdList}/persons/{person}', [CrowdListController::class, 'removePerson']); + + // RFC-TIMETABLE v0.2 — artist domain (Session 2) + // Engagements + Route::apiResource('engagements', \App\Http\Controllers\Api\V1\Artist\ArtistEngagementController::class); + + // Stages — specific routes before {stage} wildcard + Route::post('stages/order', [\App\Http\Controllers\Api\V1\Artist\StageController::class, 'reorder']); + Route::put('stages/{stage}/days', [\App\Http\Controllers\Api\V1\Artist\StageController::class, 'replaceDays']); + Route::apiResource('stages', \App\Http\Controllers\Api\V1\Artist\StageController::class); + + // Performances + Route::apiResource('performances', \App\Http\Controllers\Api\V1\Artist\PerformanceController::class); + + // Timetable move (D18 — guarded by 60s Redis idempotency window per R1) + Route::post('timetable/move', \App\Http\Controllers\Api\V1\Artist\TimetableMoveController::class) + ->middleware('idempotency.60s'); }); + // RFC-TIMETABLE v0.2 — org-level artist resources (Session 2) + Route::apiResource('artists', \App\Http\Controllers\Api\V1\Artist\ArtistController::class); + Route::post('artists/{artist}/restore', [\App\Http\Controllers\Api\V1\Artist\ArtistController::class, 'restore']) + ->withTrashed(); + Route::apiResource('genres', \App\Http\Controllers\Api\V1\Artist\GenreController::class) + ->except(['show']); + // Form Builder (ARCH-FORM-BUILDER.md) Route::prefix('forms')->group(function (): void { // Filter registry diff --git a/api/routes/console.php b/api/routes/console.php index 2713d22a..ee2ff4bf 100644 --- a/api/routes/console.php +++ b/api/routes/console.php @@ -10,6 +10,13 @@ Artisan::command('inspire', function () { Schedule::command('invitations:expire')->daily(); +// RFC-TIMETABLE v0.2 — demote engagements whose option_expires_at has +// passed back to Draft. Daily at 03:00 Europe/Amsterdam (matches the +// scheduler's nightly window for low-traffic state changes). +Schedule::command('artist:demote-expired-options') + ->dailyAt('03:00') + ->timezone('Europe/Amsterdam'); + // Telescope retention — dev-only (mirrors AppServiceProvider's // environment gate). 48h is enough for debugging without filling the // dev database. diff --git a/api/tests/Feature/Artist/ActivityLogShapeTest.php b/api/tests/Feature/Artist/ActivityLogShapeTest.php new file mode 100644 index 00000000..164c1826 --- /dev/null +++ b/api/tests/Feature/Artist/ActivityLogShapeTest.php @@ -0,0 +1,177 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->event = Event::factory()->create([ + 'organisation_id' => $this->org->id, + 'start_date' => CarbonImmutable::now()->subDay(), + 'end_date' => CarbonImmutable::now()->addDays(30), + ]); + $this->stage = Stage::factory()->create(['event_id' => $this->event->id]); + StageDay::query()->create(['stage_id' => $this->stage->id, 'event_id' => $this->event->id]); + + $artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + $this->engagement = ArtistEngagement::factory()->create([ + 'artist_id' => $artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Draft, + 'fee_amount' => 1500, + ]); + + $start = CarbonImmutable::now()->addDays(2)->setTime(20, 0); + $this->perf = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => $this->stage->id, + 'lane' => 0, + 'start_at' => $start, + 'end_at' => $start->addHour(), + 'version' => 0, + ]); + } + + public function test_performance_moved_carries_cascade_props(): void + { + $other = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => $this->stage->id, + 'lane' => 0, + 'start_at' => CarbonImmutable::now()->addDays(2)->setTime(20, 30), + 'end_at' => CarbonImmutable::now()->addDays(2)->setTime(21, 30), + 'version' => 0, + ]); + + $start = CarbonImmutable::now()->addDays(2)->setTime(20, 0); + $this->app->make(LaneCascadeService::class)->move( + performance: $this->perf, + targetStage: $this->stage, + start: $start, + end: $start->addHour(), + targetLane: 0, + clientVersion: 0, + ); + + $entry = Activity::query() + ->where('event', 'moved') + ->where('subject_id', $this->perf->id) + ->latest('id') + ->first(); + + $this->assertNotNull($entry); + $props = $entry->properties->toArray(); + $this->assertArrayHasKey('cascade_count', $props); + $this->assertArrayHasKey('cascaded_ids', $props); + $this->assertSame(1, $props['cascade_count']); + $this->assertContains((string) $other->id, $props['cascaded_ids']); + } + + public function test_status_changed_distinct_from_cancelled(): void + { + $service = $this->app->make(ArtistEngagementService::class); + $service->transitionStatus($this->engagement, ArtistEngagementStatus::Requested); + + $this->assertTrue( + Activity::query() + ->where('event', 'status_changed') + ->where('subject_id', $this->engagement->id) + ->exists(), + ); + $this->assertFalse( + Activity::query() + ->where('event', 'cancelled') + ->where('subject_id', $this->engagement->id) + ->exists(), + ); + + $eng2 = ArtistEngagement::factory()->create([ + 'artist_id' => Artist::factory()->create(['organisation_id' => $this->org->id])->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Confirmed, + 'fee_amount' => 1000, + ]); + $service->cancel($eng2); + + $this->assertTrue( + Activity::query() + ->where('event', 'cancelled') + ->where('subject_id', $eng2->id) + ->exists(), + ); + } + + public function test_stage_day_added_emitted(): void + { + $sub = Event::factory()->create([ + 'organisation_id' => $this->org->id, + 'parent_event_id' => $this->event->id, + ]); + + $this->app->make(StageDayService::class)->replaceDays( + $this->stage, + [$this->event->id, $sub->id], + ); + + $this->assertTrue( + Activity::query() + ->where('event', 'day_added') + ->where('subject_id', $this->stage->id) + ->whereJsonContains('properties->event_id', $sub->id) + ->exists(), + ); + } + + public function test_stage_reordered_emitted_on_event_subject(): void + { + $other = Stage::factory()->create(['event_id' => $this->event->id]); + + $this->app->make(StageService::class)->reorder($this->event, [$other->id, $this->stage->id]); + + $this->assertTrue( + Activity::query() + ->where('event', 'reordered') + ->where('subject_id', $this->event->id) + ->exists(), + ); + } +} diff --git a/api/tests/Feature/Artist/ArtistControllerTest.php b/api/tests/Feature/Artist/ArtistControllerTest.php new file mode 100644 index 00000000..f25db26c --- /dev/null +++ b/api/tests/Feature/Artist/ArtistControllerTest.php @@ -0,0 +1,145 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->otherOrg = Organisation::factory()->create(); + + $this->orgAdmin = User::factory()->create(); + $this->org->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + + $this->programManager = User::factory()->create(); + $this->org->users()->attach($this->programManager, ['role' => 'program_manager']); + + $this->outsider = User::factory()->create(); + $this->otherOrg->users()->attach($this->outsider, ['role' => 'org_admin']); + } + + public function test_index_lists_artists_for_member(): void + { + Artist::factory()->count(3)->create(['organisation_id' => $this->org->id]); + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->org->id}/artists"); + + $response->assertOk(); + $this->assertCount(3, $response->json('data')); + } + + public function test_index_unauthenticated_returns_401(): void + { + $this->getJson("/api/v1/organisations/{$this->org->id}/artists")->assertUnauthorized(); + } + + public function test_outsider_cannot_view_other_org_artists(): void + { + Artist::factory()->create(['organisation_id' => $this->org->id]); + Sanctum::actingAs($this->outsider); + + $this->getJson("/api/v1/organisations/{$this->org->id}/artists")->assertForbidden(); + } + + public function test_program_manager_can_create(): void + { + Sanctum::actingAs($this->programManager); + + $response = $this->postJson("/api/v1/organisations/{$this->org->id}/artists", [ + 'name' => 'Headhunterz', + ]); + + $response->assertCreated(); + $this->assertSame('Headhunterz', $response->json('data.name')); + } + + public function test_duplicate_name_returns_409_with_existing_id(): void + { + $existing = Artist::factory()->create([ + 'organisation_id' => $this->org->id, + 'name' => 'Devin Wild', + ]); + + Sanctum::actingAs($this->programManager); + $response = $this->postJson("/api/v1/organisations/{$this->org->id}/artists", [ + 'name' => 'devin wild', + ]); + + $response->assertStatus(409); + $this->assertSame((string) $existing->id, (string) $response->json('errors.duplicate_artist_id')); + } + + public function test_destroy_blocked_with_active_engagement(): void + { + $artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + $event = Event::factory()->create(['organisation_id' => $this->org->id]); + ArtistEngagement::factory()->create([ + 'artist_id' => $artist->id, + 'event_id' => $event->id, + 'booking_status' => ArtistEngagementStatus::Confirmed, + ]); + + Sanctum::actingAs($this->orgAdmin); + $this->deleteJson("/api/v1/organisations/{$this->org->id}/artists/{$artist->id}") + ->assertForbidden(); + + $this->assertDatabaseHas('artists', ['id' => $artist->id, 'deleted_at' => null]); + } + + public function test_destroy_with_only_terminal_engagements_succeeds(): void + { + $artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + $event = Event::factory()->create(['organisation_id' => $this->org->id]); + ArtistEngagement::factory()->create([ + 'artist_id' => $artist->id, + 'event_id' => $event->id, + 'booking_status' => ArtistEngagementStatus::Cancelled, + ]); + + Sanctum::actingAs($this->orgAdmin); + $this->deleteJson("/api/v1/organisations/{$this->org->id}/artists/{$artist->id}") + ->assertNoContent(); + } + + public function test_restore_brings_back_soft_deleted_artist(): void + { + $artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + $artist->delete(); + + Sanctum::actingAs($this->orgAdmin); + $response = $this->postJson("/api/v1/organisations/{$this->org->id}/artists/{$artist->id}/restore"); + + $response->assertOk(); + $this->assertDatabaseHas('artists', ['id' => $artist->id, 'deleted_at' => null]); + } +} diff --git a/api/tests/Feature/Artist/ArtistEngagementControllerTest.php b/api/tests/Feature/Artist/ArtistEngagementControllerTest.php new file mode 100644 index 00000000..9d5cd063 --- /dev/null +++ b/api/tests/Feature/Artist/ArtistEngagementControllerTest.php @@ -0,0 +1,115 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->orgAdmin = User::factory()->create(); + $this->org->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + $this->event = Event::factory()->create(['organisation_id' => $this->org->id]); + $this->artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + } + + private function url(string $tail = ''): string + { + return "/api/v1/organisations/{$this->org->id}/events/{$this->event->id}/engagements{$tail}"; + } + + public function test_index_returns_engagements(): void + { + $a = Artist::factory()->create(['organisation_id' => $this->org->id]); + $b = Artist::factory()->create(['organisation_id' => $this->org->id]); + ArtistEngagement::factory()->create(['artist_id' => $a->id, 'event_id' => $this->event->id]); + ArtistEngagement::factory()->create(['artist_id' => $b->id, 'event_id' => $this->event->id]); + + Sanctum::actingAs($this->orgAdmin); + $this->getJson($this->url())->assertOk()->assertJsonCount(2, 'data'); + } + + public function test_create_engagement(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson($this->url(), [ + 'artist_id' => $this->artist->id, + 'booking_status' => ArtistEngagementStatus::Draft->value, + ]); + + $response->assertCreated(); + } + + public function test_create_with_invalid_status_transition_returns_422(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson($this->url(), [ + 'artist_id' => $this->artist->id, + 'booking_status' => ArtistEngagementStatus::Option->value, + // Missing option_expires_at — service should refuse + ]); + + $response->assertStatus(422); + } + + public function test_update_status_transition(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Draft, + ]); + + Sanctum::actingAs($this->orgAdmin); + $response = $this->patchJson($this->url("/{$eng->id}"), [ + 'booking_status' => ArtistEngagementStatus::Requested->value, + ]); + + $response->assertOk(); + $this->assertSame( + ArtistEngagementStatus::Requested, + $eng->refresh()->booking_status, + ); + } + + public function test_destroy_soft_deletes(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + $this->deleteJson($this->url("/{$eng->id}"))->assertNoContent(); + + $this->assertSoftDeleted('artist_engagements', ['id' => $eng->id]); + } +} diff --git a/api/tests/Feature/Artist/ArtistEngagementStateMachineTest.php b/api/tests/Feature/Artist/ArtistEngagementStateMachineTest.php new file mode 100644 index 00000000..5ccb63a0 --- /dev/null +++ b/api/tests/Feature/Artist/ArtistEngagementStateMachineTest.php @@ -0,0 +1,153 @@ +seed(RoleSeeder::class); + + $this->service = $this->app->make(ArtistEngagementService::class); + $this->org = Organisation::factory()->create(); + $this->event = Event::factory()->create(['organisation_id' => $this->org->id]); + $this->artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + } + + public function test_rejected_is_terminal(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Rejected, + ]); + + $this->expectException(InvalidStatusTransitionException::class); + $this->service->transitionStatus($eng, ArtistEngagementStatus::Contracted); + } + + public function test_declined_is_terminal(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Declined, + ]); + + $this->expectException(InvalidStatusTransitionException::class); + $this->service->transitionStatus($eng, ArtistEngagementStatus::Draft); + } + + public function test_cancelled_is_terminal(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Cancelled, + ]); + + $this->expectException(InvalidStatusTransitionException::class); + $this->service->transitionStatus($eng, ArtistEngagementStatus::Confirmed); + } + + public function test_option_requires_future_expiry(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Draft, + 'option_expires_at' => null, + ]); + + $this->expectException(InvalidStatusTransitionException::class); + $this->service->transitionStatus($eng, ArtistEngagementStatus::Option); + } + + public function test_option_with_past_expiry_blocked(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Draft, + 'option_expires_at' => now()->subHour(), + ]); + + $this->expectException(InvalidStatusTransitionException::class); + $this->service->transitionStatus($eng, ArtistEngagementStatus::Option); + } + + public function test_contracted_requires_fee(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Confirmed, + 'fee_amount' => null, + ]); + + $this->expectException(InvalidStatusTransitionException::class); + $this->service->transitionStatus($eng, ArtistEngagementStatus::Contracted); + } + + public function test_happy_path_sequence_permitted(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Draft, + 'fee_amount' => 1500.00, + 'option_expires_at' => now()->addDays(14), + ]); + + foreach ([ + ArtistEngagementStatus::Requested, + ArtistEngagementStatus::Option, + ArtistEngagementStatus::Offered, + ArtistEngagementStatus::Confirmed, + ArtistEngagementStatus::Contracted, + ] as $next) { + $this->service->transitionStatus($eng, $next); + $this->assertSame($next, $eng->refresh()->booking_status); + } + } + + public function test_cancel_transitions_and_soft_deletes(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Confirmed, + ]); + + $this->service->cancel($eng); + + $reloaded = ArtistEngagement::withoutGlobalScopes()->withTrashed()->find($eng->id); + $this->assertNotNull($reloaded); + $this->assertSame(ArtistEngagementStatus::Cancelled, $reloaded->booking_status); + $this->assertNotNull($reloaded->deleted_at); + } +} diff --git a/api/tests/Feature/Artist/ArtistPolicyTest.php b/api/tests/Feature/Artist/ArtistPolicyTest.php new file mode 100644 index 00000000..111850db --- /dev/null +++ b/api/tests/Feature/Artist/ArtistPolicyTest.php @@ -0,0 +1,105 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->otherOrg = Organisation::factory()->create(); + + $this->orgAdmin = User::factory()->create(); + $this->org->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + + $this->programManager = User::factory()->create(); + $this->org->users()->attach($this->programManager, ['role' => 'program_manager']); + + $this->crossTenantAdmin = User::factory()->create(); + $this->otherOrg->users()->attach($this->crossTenantAdmin, ['role' => 'org_admin']); + + $this->superAdmin = User::factory()->create(); + $this->superAdmin->assignRole('super_admin'); + + $this->artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + } + + public function test_org_admin_can_create(): void + { + $this->assertTrue(Gate::forUser($this->orgAdmin)->allows('create', [Artist::class, $this->org])); + } + + public function test_program_manager_can_update(): void + { + $this->assertTrue(Gate::forUser($this->programManager)->allows('update', $this->artist)); + } + + public function test_cross_tenant_admin_denied(): void + { + $this->assertFalse(Gate::forUser($this->crossTenantAdmin)->allows('view', $this->artist)); + $this->assertFalse(Gate::forUser($this->crossTenantAdmin)->allows('update', $this->artist)); + $this->assertFalse(Gate::forUser($this->crossTenantAdmin)->allows('delete', $this->artist)); + } + + public function test_super_admin_bypass(): void + { + $this->assertTrue(Gate::forUser($this->superAdmin)->allows('view', $this->artist)); + $this->assertTrue(Gate::forUser($this->superAdmin)->allows('update', $this->artist)); + } + + public function test_delete_blocked_with_active_engagement(): void + { + $event = Event::factory()->create(['organisation_id' => $this->org->id]); + ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $event->id, + 'booking_status' => ArtistEngagementStatus::Confirmed, + ]); + + $this->assertFalse(Gate::forUser($this->orgAdmin)->allows('delete', $this->artist)); + } + + public function test_delete_allowed_with_only_terminal_engagements(): void + { + $event = Event::factory()->create(['organisation_id' => $this->org->id]); + ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $event->id, + 'booking_status' => ArtistEngagementStatus::Cancelled, + ]); + + $this->assertTrue(Gate::forUser($this->orgAdmin)->allows('delete', $this->artist)); + } +} diff --git a/api/tests/Feature/Artist/BumaVatCalculationTest.php b/api/tests/Feature/Artist/BumaVatCalculationTest.php new file mode 100644 index 00000000..a0fb2c07 --- /dev/null +++ b/api/tests/Feature/Artist/BumaVatCalculationTest.php @@ -0,0 +1,155 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->event = Event::factory()->create(['organisation_id' => $this->org->id]); + $this->artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + } + + private function compute(array $attrs): array + { + $eng = ArtistEngagement::factory()->create(array_merge([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + ], $attrs)); + + $req = Request::create('/'); + $payload = (new ArtistEngagementResource($eng))->toArray($req); + + return $payload['computed']; + } + + public function test_organisation_handles_buma_includes_buma_in_vat_grondslag(): void + { + $c = $this->compute([ + 'fee_amount' => 1000.00, + 'buma_applicable' => true, + 'buma_percentage' => 7.00, + 'buma_handled_by' => BumaHandledBy::Organisation, + 'vat_applicable' => true, + 'vat_percentage' => 21.00, + 'deal_breakdown' => [], + ]); + + $this->assertSame(70.0, $c['buma_amount']); + $this->assertSame(1070.0, $c['vat_grondslag']); + $this->assertSame(224.7, $c['vat_amount']); + $this->assertSame(1294.7, $c['total_cost']); + } + + public function test_booking_agency_handles_buma_excludes_from_vat_grondslag(): void + { + $c = $this->compute([ + 'fee_amount' => 1000.00, + 'buma_applicable' => true, + 'buma_percentage' => 7.00, + 'buma_handled_by' => BumaHandledBy::BookingAgency, + 'vat_applicable' => true, + 'vat_percentage' => 21.00, + 'deal_breakdown' => [], + ]); + + $this->assertSame(0.0, $c['buma_amount']); + $this->assertSame(1000.0, $c['vat_grondslag']); + $this->assertSame(210.0, $c['vat_amount']); + $this->assertSame(1210.0, $c['total_cost']); + } + + public function test_not_applicable_buma_yields_zero_buma(): void + { + $c = $this->compute([ + 'fee_amount' => 1000.00, + 'buma_applicable' => false, + 'buma_percentage' => 7.00, + 'buma_handled_by' => BumaHandledBy::NotApplicable, + 'vat_applicable' => true, + 'vat_percentage' => 21.00, + 'deal_breakdown' => [], + ]); + + $this->assertSame(0.0, $c['buma_amount']); + $this->assertSame(1000.0, $c['vat_grondslag']); + } + + public function test_vat_disabled_yields_zero_vat(): void + { + $c = $this->compute([ + 'fee_amount' => 1000.00, + 'buma_applicable' => true, + 'buma_percentage' => 7.00, + 'buma_handled_by' => BumaHandledBy::Organisation, + 'vat_applicable' => false, + 'vat_percentage' => 21.00, + 'deal_breakdown' => [], + ]); + + $this->assertSame(70.0, $c['buma_amount']); + $this->assertSame(0.0, $c['vat_amount']); + } + + public function test_breakdown_summed_into_total_cost(): void + { + $c = $this->compute([ + 'fee_amount' => 500.00, + 'buma_applicable' => false, + 'buma_handled_by' => BumaHandledBy::NotApplicable, + 'vat_applicable' => false, + 'deal_breakdown' => [ + ['label' => 'Hospitality', 'amount' => 50.00], + ['label' => 'Hotel', 'amount' => 120.00], + ], + ]); + + $this->assertSame(170.0, $c['breakdown_total']); + $this->assertSame(670.0, $c['total_cost']); + } + + public function test_zero_fee_yields_zero_components(): void + { + $c = $this->compute([ + 'fee_amount' => 0, + 'buma_applicable' => true, + 'buma_percentage' => 7.00, + 'buma_handled_by' => BumaHandledBy::Organisation, + 'vat_applicable' => true, + 'vat_percentage' => 21.00, + 'deal_breakdown' => [], + ]); + + $this->assertSame(0.0, $c['buma_amount']); + $this->assertSame(0.0, $c['vat_amount']); + $this->assertSame(0.0, $c['total_cost']); + } +} diff --git a/api/tests/Feature/Artist/DemoteExpiredOptionsTest.php b/api/tests/Feature/Artist/DemoteExpiredOptionsTest.php new file mode 100644 index 00000000..0d961501 --- /dev/null +++ b/api/tests/Feature/Artist/DemoteExpiredOptionsTest.php @@ -0,0 +1,116 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->event = Event::factory()->create(['organisation_id' => $this->org->id]); + $this->artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + } + + public function test_expired_option_demoted_to_draft(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Option, + 'option_expires_at' => now()->subMinute(), + ]); + + $this->artisan('artist:demote-expired-options')->assertSuccessful(); + + $this->assertSame( + ArtistEngagementStatus::Draft, + $eng->refresh()->booking_status, + ); + + $this->assertTrue( + Activity::query() + ->where('subject_type', $eng->getMorphClass()) + ->where('subject_id', $eng->id) + ->where('event', 'option_expired') + ->exists(), + ); + } + + public function test_future_option_untouched(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Option, + 'option_expires_at' => now()->addHour(), + ]); + + $this->artisan('artist:demote-expired-options')->assertSuccessful(); + + $this->assertSame( + ArtistEngagementStatus::Option, + $eng->refresh()->booking_status, + ); + } + + public function test_non_option_status_untouched(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Confirmed, + 'option_expires_at' => now()->subDay(), + ]); + + $this->artisan('artist:demote-expired-options')->assertSuccessful(); + + $this->assertSame( + ArtistEngagementStatus::Confirmed, + $eng->refresh()->booking_status, + ); + } + + public function test_running_twice_writes_only_one_option_expired_entry(): void + { + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $this->artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Option, + 'option_expires_at' => now()->subMinute(), + ]); + + $this->artisan('artist:demote-expired-options')->assertSuccessful(); + $this->artisan('artist:demote-expired-options')->assertSuccessful(); + + $count = Activity::query() + ->where('subject_type', $eng->getMorphClass()) + ->where('subject_id', $eng->id) + ->where('event', 'option_expired') + ->count(); + + $this->assertSame(1, $count); + } +} diff --git a/api/tests/Feature/Artist/IdempotencyKey60sRedisTest.php b/api/tests/Feature/Artist/IdempotencyKey60sRedisTest.php new file mode 100644 index 00000000..d263aecf --- /dev/null +++ b/api/tests/Feature/Artist/IdempotencyKey60sRedisTest.php @@ -0,0 +1,98 @@ +handle($request, fn () => response('ok')); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertStringContainsString('idempotency_key_required', (string) $response->getContent()); + } + + public function test_first_request_caches_and_passes_through(): void + { + Cache::flush(); + + $middleware = new IdempotencyKey60sRedis; + $request = Request::create('/x', 'POST'); + $request->headers->set('Idempotency-Key', 'abc-123'); + + $count = 0; + $response = $middleware->handle($request, function () use (&$count): Response { + $count++; + + return response()->json(['ok' => true]); + }); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(1, $count); + } + + public function test_replayed_request_returns_cached_body_with_replayed_header(): void + { + Cache::flush(); + + $middleware = new IdempotencyKey60sRedis; + + $request1 = Request::create('/x', 'POST'); + $request1->headers->set('Idempotency-Key', 'replay-key'); + + $count = 0; + $middleware->handle($request1, function () use (&$count) { + $count++; + + return response()->json(['result' => 'one']); + }); + + $request2 = Request::create('/x', 'POST'); + $request2->headers->set('Idempotency-Key', 'replay-key'); + + $response2 = $middleware->handle($request2, function () use (&$count) { + $count++; + + return response()->json(['result' => 'two']); + }); + + $this->assertSame(1, $count, 'inner handler should not run on replay'); + $this->assertSame('true', $response2->headers->get('Idempotency-Replayed')); + $this->assertStringContainsString('one', (string) $response2->getContent()); + } + + public function test_failed_response_not_cached(): void + { + Cache::flush(); + + $middleware = new IdempotencyKey60sRedis; + + $request1 = Request::create('/x', 'POST'); + $request1->headers->set('Idempotency-Key', 'fail-key'); + + $middleware->handle($request1, fn () => response()->json(['x' => 1], 422)); + + $request2 = Request::create('/x', 'POST'); + $request2->headers->set('Idempotency-Key', 'fail-key'); + + $count = 0; + $middleware->handle($request2, function () use (&$count) { + $count++; + + return response()->json(['x' => 2]); + }); + + $this->assertSame(1, $count, 'failed responses should not be cached for replay'); + } +} diff --git a/api/tests/Feature/Artist/LaneCascadeServiceTest.php b/api/tests/Feature/Artist/LaneCascadeServiceTest.php new file mode 100644 index 00000000..a031e87d --- /dev/null +++ b/api/tests/Feature/Artist/LaneCascadeServiceTest.php @@ -0,0 +1,269 @@ +seed(RoleSeeder::class); + + $this->service = $this->app->make(LaneCascadeService::class); + $this->org = Organisation::factory()->create(); + $this->event = Event::factory()->create([ + 'organisation_id' => $this->org->id, + 'start_date' => CarbonImmutable::now()->subDay(), + 'end_date' => CarbonImmutable::now()->addDays(30), + ]); + $this->stage = Stage::factory()->create(['event_id' => $this->event->id]); + StageDay::query()->create(['stage_id' => $this->stage->id, 'event_id' => $this->event->id]); + + $artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + $this->engagement = ArtistEngagement::factory()->create([ + 'artist_id' => $artist->id, + 'event_id' => $this->event->id, + ]); + } + + public function test_simple_move_no_overlap_succeeds(): void + { + $perf = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => $this->stage->id, + 'lane' => 0, + 'version' => 0, + ]); + + $start = CarbonImmutable::now()->addDays(2)->setTime(20, 0); + $result = $this->service->move( + performance: $perf, + targetStage: $this->stage, + start: $start, + end: $start->addHour(), + targetLane: 0, + clientVersion: 0, + ); + + $this->assertSame([], $result->cascaded); + $this->assertGreaterThan(0, $result->moved->version); + } + + public function test_overlap_cascades_existing_to_higher_lane(): void + { + $start = CarbonImmutable::now()->addDays(3)->setTime(22, 0); + + $existing = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => $this->stage->id, + 'lane' => 0, + 'start_at' => $start, + 'end_at' => $start->addHour(), + 'version' => 0, + ]); + + $other = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => null, // parked + 'lane' => 0, + 'start_at' => $start, + 'end_at' => $start->addHour(), + 'version' => 0, + ]); + + $result = $this->service->move( + performance: $other, + targetStage: $this->stage, + start: $start->addMinutes(15), + end: $start->addMinutes(75), + targetLane: 0, + clientVersion: 0, + ); + + $this->assertCount(1, $result->cascaded); + $this->assertSame((string) $existing->id, (string) $result->cascaded[0]->id); + $this->assertSame(1, (int) $result->cascaded[0]->lane); + } + + public function test_version_mismatch_throws(): void + { + $perf = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => $this->stage->id, + 'lane' => 0, + 'version' => 5, + ]); + + $this->expectException(VersionMismatchException::class); + + $this->service->move( + performance: $perf, + targetStage: $this->stage, + start: CarbonImmutable::parse((string) $perf->start_at), + end: CarbonImmutable::parse((string) $perf->end_at), + targetLane: 0, + clientVersion: 4, + ); + } + + public function test_park_clears_stage_id(): void + { + $perf = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => $this->stage->id, + 'lane' => 2, + 'version' => 0, + ]); + + $result = $this->service->move( + performance: $perf, + targetStage: null, + start: null, + end: null, + targetLane: null, + clientVersion: 0, + ); + + $this->assertNull($result->moved->stage_id); + $this->assertSame([], $result->cascaded); + $this->assertSame(2, (int) $result->moved->lane); + } + + public function test_unpark_to_stage_succeeds(): void + { + $perf = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => null, + 'lane' => 0, + 'version' => 0, + ]); + + $start = CarbonImmutable::now()->addDays(4)->setTime(21, 0); + $result = $this->service->move( + performance: $perf, + targetStage: $this->stage, + start: $start, + end: $start->addHour(), + targetLane: 0, + clientVersion: 0, + ); + + $this->assertSame((string) $this->stage->id, (string) $result->moved->stage_id); + } + + public function test_move_with_cascade_writes_exactly_one_activity_entry_on_moved_subject_and_zero_on_peers(): void + { + $start = CarbonImmutable::now()->addDays(5)->setTime(20, 0); + + $p2 = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => $this->stage->id, + 'lane' => 0, + 'start_at' => $start, + 'end_at' => $start->addHour(), + 'version' => 0, + ]); + + $p1 = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => $this->stage->id, + 'lane' => 1, + 'start_at' => $start, + 'end_at' => $start->addHour(), + 'version' => 0, + ]); + + // Clear setup-time activity rows so we measure only the move. + Activity::query()->delete(); + + $this->service->move( + performance: $p1, + targetStage: $this->stage, + start: $start, + end: $start->addHour(), + targetLane: 0, + clientVersion: 0, + ); + + $movedEntries = Activity::query() + ->where('subject_type', $p1->getMorphClass()) + ->where('subject_id', $p1->id) + ->get(); + $this->assertCount(1, $movedEntries, 'Expected exactly one activity entry for moved performance'); + $this->assertSame('moved', $movedEntries->first()->event); + $this->assertSame(1, $movedEntries->first()->properties['cascade_count']); + $this->assertContains((string) $p2->id, $movedEntries->first()->properties['cascaded_ids']); + + $cascadedEntries = Activity::query() + ->where('subject_type', $p2->getMorphClass()) + ->where('subject_id', $p2->id) + ->get(); + $this->assertCount(0, $cascadedEntries, 'Expected zero activity entries on cascade-bumped peer'); + } + + public function test_park_writes_single_parked_activity_entry(): void + { + $perf = Performance::factory()->create([ + 'engagement_id' => $this->engagement->id, + 'event_id' => $this->event->id, + 'stage_id' => $this->stage->id, + 'lane' => 0, + 'version' => 0, + ]); + + Activity::query()->delete(); + + $this->service->move( + performance: $perf, + targetStage: null, + start: null, + end: null, + targetLane: null, + clientVersion: 0, + ); + + $entries = Activity::query() + ->where('subject_type', $perf->getMorphClass()) + ->where('subject_id', $perf->id) + ->get(); + $this->assertCount(1, $entries, 'Expected exactly one parked entry'); + $this->assertSame('parked', $entries->first()->event); + } +} diff --git a/api/tests/Feature/Artist/StageControllerTest.php b/api/tests/Feature/Artist/StageControllerTest.php new file mode 100644 index 00000000..a9144703 --- /dev/null +++ b/api/tests/Feature/Artist/StageControllerTest.php @@ -0,0 +1,186 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->orgAdmin = User::factory()->create(); + $this->org->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + + $this->event = Event::factory()->create(['organisation_id' => $this->org->id]); + } + + private function url(string $tail = ''): string + { + return "/api/v1/organisations/{$this->org->id}/events/{$this->event->id}/stages{$tail}"; + } + + public function test_create_stage(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson($this->url(), [ + 'name' => 'Mainstage', + 'color' => '#ff0000', + 'capacity' => 5000, + ]); + + $response->assertCreated(); + $this->assertSame('Mainstage', $response->json('data.name')); + } + + public function test_create_unique_name_per_event(): void + { + Stage::factory()->create(['event_id' => $this->event->id, 'name' => 'Hardstyle']); + + Sanctum::actingAs($this->orgAdmin); + $response = $this->postJson($this->url(), [ + 'name' => 'Hardstyle', + 'color' => '#000000', + ]); + + $response->assertStatus(422); + } + + public function test_destroy_cascade_parks_performances(): void + { + $stage = Stage::factory()->create(['event_id' => $this->event->id]); + $artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $artist->id, + 'event_id' => $this->event->id, + ]); + $perf = Performance::factory()->create([ + 'engagement_id' => $eng->id, + 'event_id' => $this->event->id, + 'stage_id' => $stage->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + $this->deleteJson($this->url("/{$stage->id}"))->assertOk(); + + $perf->refresh(); + $this->assertNull($perf->stage_id); + } + + public function test_reorder_updates_sort_order(): void + { + $a = Stage::factory()->create(['event_id' => $this->event->id, 'sort_order' => 0]); + $b = Stage::factory()->create(['event_id' => $this->event->id, 'sort_order' => 1]); + $c = Stage::factory()->create(['event_id' => $this->event->id, 'sort_order' => 2]); + + Sanctum::actingAs($this->orgAdmin); + $response = $this->postJson($this->url('/order'), [ + 'stage_ids' => [$c->id, $a->id, $b->id], + ]); + + $response->assertOk(); + $this->assertSame(0, (int) $c->fresh()->sort_order); + $this->assertSame(1, (int) $a->fresh()->sort_order); + $this->assertSame(2, (int) $b->fresh()->sort_order); + } + + public function test_reorder_rejects_partial_permutation(): void + { + $a = Stage::factory()->create(['event_id' => $this->event->id]); + Stage::factory()->create(['event_id' => $this->event->id]); + + Sanctum::actingAs($this->orgAdmin); + $response = $this->postJson($this->url('/order'), ['stage_ids' => [$a->id]]); + + $response->assertStatus(422); + } + + public function test_replace_days_orphans_performances_returns_409(): void + { + $stage = Stage::factory()->create(['event_id' => $this->event->id]); + StageDay::query()->create(['stage_id' => $stage->id, 'event_id' => $this->event->id]); + + $artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Confirmed, + ]); + Performance::factory()->create([ + 'engagement_id' => $eng->id, + 'event_id' => $this->event->id, + 'stage_id' => $stage->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + // Build a sub-event (different event_id) to replace days with + $other = Event::factory()->create([ + 'organisation_id' => $this->org->id, + 'parent_event_id' => $this->event->id, + ]); + + $response = $this->putJson("/api/v1/organisations/{$this->org->id}/events/{$this->event->id}/stages/{$stage->id}/days", [ + 'event_ids' => [$other->id], + ]); + + $response->assertStatus(409); + $this->assertSame('orphaned_performances', $response->json('errors.conflict')); + } + + public function test_replace_days_with_force_orphan_succeeds(): void + { + $stage = Stage::factory()->create(['event_id' => $this->event->id]); + StageDay::query()->create(['stage_id' => $stage->id, 'event_id' => $this->event->id]); + + $artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $artist->id, + 'event_id' => $this->event->id, + 'booking_status' => ArtistEngagementStatus::Confirmed, + ]); + Performance::factory()->create([ + 'engagement_id' => $eng->id, + 'event_id' => $this->event->id, + 'stage_id' => $stage->id, + ]); + + $other = Event::factory()->create([ + 'organisation_id' => $this->org->id, + 'parent_event_id' => $this->event->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + $response = $this->putJson( + "/api/v1/organisations/{$this->org->id}/events/{$this->event->id}/stages/{$stage->id}/days?force_orphan=true", + ['event_ids' => [$other->id]], + ); + + $response->assertOk(); + } +} diff --git a/api/tests/Feature/Artist/TimetableMoveControllerTest.php b/api/tests/Feature/Artist/TimetableMoveControllerTest.php new file mode 100644 index 00000000..eae33184 --- /dev/null +++ b/api/tests/Feature/Artist/TimetableMoveControllerTest.php @@ -0,0 +1,131 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->orgAdmin = User::factory()->create(); + $this->org->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + $this->event = Event::factory()->create([ + 'organisation_id' => $this->org->id, + 'start_date' => CarbonImmutable::now()->subDay(), + 'end_date' => CarbonImmutable::now()->addDays(30), + ]); + $this->stage = Stage::factory()->create(['event_id' => $this->event->id]); + StageDay::query()->create(['stage_id' => $this->stage->id, 'event_id' => $this->event->id]); + + $artist = Artist::factory()->create(['organisation_id' => $this->org->id]); + $eng = ArtistEngagement::factory()->create([ + 'artist_id' => $artist->id, + 'event_id' => $this->event->id, + ]); + $start = CarbonImmutable::now()->addDays(2)->setTime(20, 0); + $this->perf = Performance::factory()->create([ + 'engagement_id' => $eng->id, + 'event_id' => $this->event->id, + 'stage_id' => $this->stage->id, + 'lane' => 0, + 'start_at' => $start, + 'end_at' => $start->addHour(), + 'version' => 0, + ]); + } + + private function url(): string + { + return "/api/v1/organisations/{$this->org->id}/events/{$this->event->id}/timetable/move"; + } + + public function test_move_succeeds_with_idempotency_key(): void + { + Sanctum::actingAs($this->orgAdmin); + + $newStart = CarbonImmutable::parse((string) $this->perf->start_at)->addHour(); + $response = $this->postJson( + $this->url(), + [ + 'performance_id' => $this->perf->id, + 'target_stage_id' => $this->stage->id, + 'target_start_at' => $newStart->format('Y-m-d H:i:s'), + 'target_end_at' => $newStart->addHour()->format('Y-m-d H:i:s'), + 'target_lane' => 0, + 'version' => 0, + ], + ['Idempotency-Key' => 'test-1'], + ); + + $response->assertOk(); + } + + public function test_move_without_idempotency_key_returns_400(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson($this->url(), [ + 'performance_id' => $this->perf->id, + 'target_stage_id' => $this->stage->id, + 'target_start_at' => '2026-07-10 22:00:00', + 'target_end_at' => '2026-07-10 23:00:00', + 'target_lane' => 0, + 'version' => 0, + ]); + + $response->assertStatus(400); + } + + public function test_version_mismatch_returns_409(): void + { + Sanctum::actingAs($this->orgAdmin); + + $newStart = CarbonImmutable::parse((string) $this->perf->start_at)->addHour(); + $response = $this->postJson( + $this->url(), + [ + 'performance_id' => $this->perf->id, + 'target_stage_id' => $this->stage->id, + 'target_start_at' => $newStart->format('Y-m-d H:i:s'), + 'target_end_at' => $newStart->addHour()->format('Y-m-d H:i:s'), + 'target_lane' => 0, + 'version' => 99, + ], + ['Idempotency-Key' => 'test-2'], + ); + + $response->assertStatus(409); + $this->assertSame('version_mismatch', $response->json('errors.conflict')); + } +} diff --git a/dev-docs/BACKLOG.md b/dev-docs/BACKLOG.md index 00eea35d..e2b0b8c4 100644 --- a/dev-docs/BACKLOG.md +++ b/dev-docs/BACKLOG.md @@ -700,6 +700,17 @@ Session 1 surfaced deze drift; Step 7 reduceerde tot een in-place rewrite van **Wat:** Bij de eerstvolgende RFC v0.2 amendement, vervang of verwijder de `ARCH-PLANNED-MODULES.md` cross-references in §1 en §15. Dit is geen blocker voor implementatie van Sessions 2–6. + +**Aanvulling Session 2 (2026-05-08):** RFC §9 noemt vier permission-strings +(`events.view_program`, `events.manage_program`, `organisations.manage_artists`, +`organisations.manage_settings`). De implementatie-keuze (Phase A Option B) +bindt deze permissions aan Spatie roles in plaats van aan `Permission`-rijen, +omdat de bestaande codebase rolgebaseerd is en migratie naar fine-grained +permissions cross-cutting is. De docblocks van `ArtistPolicy`, +`ArtistEngagementPolicy`, `StagePolicy`, `PerformancePolicy` en `GenrePolicy` +documenteren de exacte mapping. Cross-cutting migratie wordt gevolgd onder +`AUTH-PERMISSIONS-MIGRATION` (zie hieronder). + **Prioriteit:** Laag — documentatie-hygiëne, niet code. --- @@ -725,6 +736,99 @@ de divergentie totdat een legitieme amendement langskomt. --- +### EVENT-START-END-TIME + +`events` table currently has `start_date`/`end_date` (date type), which +forces day-boundary semantics in `WithinEventBounds` and similar checks. +For festivals running past midnight or with intentionally non-24h +operating windows, a `start_time`/`end_time` pair (or a unified +`start_at`/`end_at` datetime) on the `events` table would let: + +- the timetable viewport (Session 4 frontend) honour real event hours + instead of always showing 00:00 → 23:59 +- boundary checks like `WithinEventBounds` reject performances that + run past the event's actual close time, even within the same date + +**Why not now:** +- Cross-cutting schema change touching the `events` table — used by + 30+ modules across the codebase +- Out of scope for the Artist Timetable sprint per Charter §2 (no + opportunistic feature-creep) +- Sub-events absorb the granularity in 90% of cases via Performance + datetimes + +**When:** +- Session 4 frontend timetable viewport reveals concrete UX gaps from + date-only event bounds +- OR a customer onboards with a non-day-aligned schedule (e.g. a club + with a 22:00 → 06:00 nightly window) + +**Surfaced during:** Session 2 review of +`app/Rules/Artist/WithinEventBounds.php`, which uses +`startOfDay()`/`endOfDay()` to bridge the date-vs-datetime gap. That +bridge is correct given current schema; this ticket is about the +schema, not the rule. + +**Prioriteit:** Middel — works today; upgrade is feature-not-bug. + +--- + +### AUTH-PERMISSIONS-MIGRATION — Migrate alle policies van hasRole() naar hasPermissionTo() + +**Aanleiding:** Crewli gebruikt vandaag uitsluitend Spatie *roles*; geen +`Permission`-rijen worden geseed en geen policy roept `hasPermissionTo()` +of `Gate::can()` tegen permission-strings aan. RFC-TIMETABLE v0.2 §9 +beschrijft de toegangscontrole in termen van permission-strings +(`events.manage_program`, etc.); Phase A van Session 2 (2026-05-08) +besloot Option B — de permission-strings worden in policy-docblocks +gedocumenteerd en role-based geautoriseerd. Een hybride aanpak (perms +seeden maar niet gebruiken) werd afgewezen omdat dat strings creëert +zonder source-of-truth-status. + +**Wat:** Eén dedicated cross-cutting sprint die ALLE policies (niet +alleen Artist-domein) overzet van `hasRole()` naar `hasPermissionTo()`. +Inclusief: +- `PermissionSeeder` voor de complete set permissions die we vandaag + via roles uitdrukken (per-domein audit van bestaande policies) +- Policy-by-policy refactor met behoud van semantiek +- Policy-tests bijwerken (bestaande tests gebruiken role-strings) +- Documentatie in `dev-docs/CLAUDE.md` (`Roles and permissions`-blok) + +**Trigger:** Klant- of charter-vereiste — een specifieke gebruiker +moet wel X kunnen maar niet Y, en X+Y delen vandaag dezelfde rol. +Niet: interne preferentie of "het is netter". + +**Reference:** Session 2 Phase A (2026-05-08) Option B beslissing; +`feedback_authorization_pattern` user-memory (intent-only — niet in +auto-memory geschreven door file-protect hook). + +**Prioriteit:** Laag — wachten op concrete operationele behoefte. + +--- + +### ART-DEMOTE-NOTIFICATION — Notify project-leader on option-expiry demotion + +**Aanleiding:** RFC-TIMETABLE v0.2 noemt notificatie van de project-leader +wanneer een Option afloopt en automatisch gedemoteerd wordt naar Draft +(via de `artist:demote-expired-options` daily command, Session 2 Step 10). +Het notificatie-framework landt pas post-Accreditation; daarom schrijft +de Session 2 command alleen een `option_expired` activity-log entry, geen +e-mail. + +**Wat:** Wanneer notification-framework live is, hook het in de command +in: na elke `transitionStatus()` succes een notificatie naar de project- +leader (en optioneel de `program_manager` rol op het evenement). Houd +rekening met aggregatie als veel Options tegelijk verlopen — niet één +mail per Option. + +**Reference:** Session 2 commit `feat(timetable): DemoteExpiredOptions +scheduled command`; `app/Console/Commands/Artist/DemoteExpiredOptions.php`. + +**Prioriteit:** Medium — wachten op notification-framework, maar wel een +zichtbare gap voor program managers tot dan. + +--- + ### TECH-01 — Bestaande tests bijwerken na festival/event refactor **Aanleiding:** Na toevoegen parent_event_id worden bestaande tests