From 01f4a31fe190ebe8825655f25059a242f0f5fcdf Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 20:44:05 +0200 Subject: [PATCH 01/16] feat(timetable): seed program_manager + production_assistant roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the two RFC-TIMETABLE §9 roles. Authorization stays role-based per Phase A Option B; RFC §9 permission strings map to roles in policy class docblocks, not seeded as Spatie permissions. The eventual cross-cutting migration to fine-grained permissions is tracked under AUTH-PERMISSIONS-MIGRATION. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/database/seeders/RoleSeeder.php | 10 ++++++++++ 1 file changed, 10 insertions(+) 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'); } } -- 2.39.5 From 05e44a39ae2812b8409f69121bf0d3ffec067c54 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 20:45:46 +0200 Subject: [PATCH 02/16] feat(timetable): add 5 artist-domain policies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ArtistPolicy, ArtistEngagementPolicy, StagePolicy, PerformancePolicy, GenrePolicy. Role-based authorization mirroring PersonPolicy/ShiftPolicy pattern: super_admin bypass, org-membership check via wherePivotIn, event_manager fallback for event-level operations. Each policy carries a class-level docblock mapping the RFC §9 permission strings (events.view_program, events.manage_program, organisations.manage_artists, organisations.manage_settings) to the roles authorised, deferring permission-based authorisation to AUTH-PERMISSIONS-MIGRATION. ArtistPolicy.delete additionally guards on no-active-engagements (D27): blocks soft-delete while any engagement is not Cancelled, Rejected, or Declined. PerformancePolicy.move and StagePolicy.reorder reuse canManageProgram so the move endpoint and stage-reorder share the manage_program permission semantics. Auto-discovered by Laravel 11 (policies live at App\Policies\* matching top-level App\Models\* — no explicit Gate::policy registration needed). Co-Authored-By: Claude Opus 4.7 (1M context) --- api/app/Policies/ArtistEngagementPolicy.php | 101 ++++++++++++++++++ api/app/Policies/ArtistPolicy.php | 82 +++++++++++++++ api/app/Policies/GenrePolicy.php | 59 +++++++++++ api/app/Policies/PerformancePolicy.php | 110 ++++++++++++++++++++ api/app/Policies/StagePolicy.php | 106 +++++++++++++++++++ 5 files changed, 458 insertions(+) create mode 100644 api/app/Policies/ArtistEngagementPolicy.php create mode 100644 api/app/Policies/ArtistPolicy.php create mode 100644 api/app/Policies/GenrePolicy.php create mode 100644 api/app/Policies/PerformancePolicy.php create mode 100644 api/app/Policies/StagePolicy.php 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(); + } +} -- 2.39.5 From f7ed03237cd08bc2c31bcf66a688331e853904f0 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 20:49:18 +0200 Subject: [PATCH 03/16] feat(timetable): seven artist-domain services + supporting exceptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GenreService, ArtistService, ArtistEngagementService (state machine), StageService, StageDayService, PerformanceService, LaneCascadeService under app/Services/Artist/. Plain final classes with constructor injection — matches FormSubmissionService convention. ArtistEngagementService implements the RFC §10.1 booking_status state machine: terminal Cancelled/Rejected/Declined, Option requires future option_expires_at, Contracted requires fee_amount > 0. transitionStatus is the focused entry point; update() routes through it whenever the payload mutates booking_status. cancel() composes transitionStatus + soft delete in one transaction so the existing ArtistEngagementObserver cascade fires. LaneCascadeService is the D18 transactional move algorithm. Locks the dragged Performance row FOR UPDATE, validates client version against the persisted version (D14), then either parks (stage_id=null, no cascade) or places onto (stage, event, lane) with single-level cascade-bump of any time-overlapping rows on the target lane. Returns a MoveResult value object carrying the moved + cascaded performances so the controller maps them to API resources without a second query. StageDayService implements the §10.5 atomic matrix replace. Detects non-cancelled performances on event_ids about to be removed; throws StageDaysOrphanedPerformancesException unless force_orphan=true. The orphans are not deleted — they persist with the same stage_id so they re-appear when the day re-activates (D5/D27 retention). ArtistService.create raises DuplicateArtistException carrying the existing master so the controller can offer a "use existing" choice instead of forcing the booker to abandon their dialog. ArtistEngagement defaults buma_handled_by based on artist.agent_company.handles_buma per RFC D26. GenreService.delete is hard-blocked (GenreInUseException) when artists still reference the genre via default_genre_id; the frontend rebinds those artists first. StageService.delete cascade-parks performances (stage_id → null, lane preserved) and returns the parked count for the activity-log entry the controller writes in Step 9. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Artist/DuplicateArtistException.php | 24 ++ .../Exceptions/Artist/GenreInUseException.php | 27 +++ .../InvalidStatusTransitionException.php | 28 +++ ...StageDaysOrphanedPerformancesException.php | 34 +++ .../Artist/VersionMismatchException.php | 27 +++ .../Artist/ArtistEngagementService.php | 214 ++++++++++++++++++ api/app/Services/Artist/ArtistService.php | 79 +++++++ api/app/Services/Artist/GenreService.php | 59 +++++ .../Services/Artist/LaneCascadeService.php | 150 ++++++++++++ api/app/Services/Artist/MoveResult.php | 28 +++ .../Services/Artist/PerformanceService.php | 109 +++++++++ api/app/Services/Artist/StageDayService.php | 73 ++++++ api/app/Services/Artist/StageService.php | 84 +++++++ 13 files changed, 936 insertions(+) create mode 100644 api/app/Exceptions/Artist/DuplicateArtistException.php create mode 100644 api/app/Exceptions/Artist/GenreInUseException.php create mode 100644 api/app/Exceptions/Artist/InvalidStatusTransitionException.php create mode 100644 api/app/Exceptions/Artist/StageDaysOrphanedPerformancesException.php create mode 100644 api/app/Exceptions/Artist/VersionMismatchException.php create mode 100644 api/app/Services/Artist/ArtistEngagementService.php create mode 100644 api/app/Services/Artist/ArtistService.php create mode 100644 api/app/Services/Artist/GenreService.php create mode 100644 api/app/Services/Artist/LaneCascadeService.php create mode 100644 api/app/Services/Artist/MoveResult.php create mode 100644 api/app/Services/Artist/PerformanceService.php create mode 100644 api/app/Services/Artist/StageDayService.php create mode 100644 api/app/Services/Artist/StageService.php 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 @@ + $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); + + $this->validateTransition($from, $to, $engagement); + + $engagement->booking_status = $to; + $engagement->save(); + + 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..a55b14c4 --- /dev/null +++ b/api/app/Services/Artist/LaneCascadeService.php @@ -0,0 +1,150 @@ +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->save(); + + return new MoveResult($locked->refresh(), []); + } + + 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->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->save(); + + return new MoveResult($locked->refresh(), $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_at', '<=', $start) + ->where('events.end_at', '>=', $start) + ->orderBy('events.start_at', '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/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..92fdf4cf --- /dev/null +++ b/api/app/Services/Artist/StageDayService.php @@ -0,0 +1,73 @@ + $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, + ]); + } + + 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..e96d8d15 --- /dev/null +++ b/api/app/Services/Artist/StageService.php @@ -0,0 +1,84 @@ + $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]); + } + }); + } +} -- 2.39.5 From 378b6fe970b29bcb5791f5bb17e32c1c6bad4cb0 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 20:50:12 +0200 Subject: [PATCH 04/16] feat(timetable): four custom validation rules for artist domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StageActiveOnEvent — checks the candidate stage_id is linked to the given event_id via stage_days. Covers performance create/update (perf.event_id ↔ stage) and the timetable move endpoint (target_stage_id ↔ resolved target event). WithinEventBounds — checks a candidate datetime is inside the event's [start_at, end_at] window. Used for performance start/end dates and move-target dates against the relevant sub-event for festivals. OptionExpiresInFuture — conditional rule fired only when booking_status === 'option'. Asserts option_expires_at is set and in the future. Implementation of RFC §10.1 transition gate at the request layer (the service layer enforces the same invariant). ContractRequiresFee — conditional rule fired only when booking_status === 'contracted'. Asserts fee_amount is set and > 0. Same dual-layer enforcement as OptionExpiresInFuture. All four pass silently when the validated field is null or the context is irrelevant — the FormRequest still owns the surrounding required/nullable/exists rules. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/app/Rules/Artist/ContractRequiresFee.php | 40 +++++++++++++++ .../Rules/Artist/OptionExpiresInFuture.php | 42 +++++++++++++++ api/app/Rules/Artist/StageActiveOnEvent.php | 43 ++++++++++++++++ api/app/Rules/Artist/WithinEventBounds.php | 51 +++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 api/app/Rules/Artist/ContractRequiresFee.php create mode 100644 api/app/Rules/Artist/OptionExpiresInFuture.php create mode 100644 api/app/Rules/Artist/StageActiveOnEvent.php create mode 100644 api/app/Rules/Artist/WithinEventBounds.php 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..255d8118 --- /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_at); + $end = CarbonImmutable::instance($event->end_at); + + if ($candidate->lt($start) || $candidate->gt($end)) { + $fail(sprintf( + 'De datum moet binnen het evenement (%s — %s) vallen.', + $start->format('Y-m-d H:i'), + $end->format('Y-m-d H:i'), + )); + } + } +} -- 2.39.5 From bb1bd8361adfcc60771b7f1df8a881dc74d113bf Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 20:51:59 +0200 Subject: [PATCH 05/16] feat(timetable): 13 form requests for artist domain endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created under app/Http/Requests/Api/V1/Artist/, mirroring the existing FormRequest pattern (final class, authorize() returns true, controller-level Gate::authorize). One request per CRUD shape plus the two domain-specific endpoints: artists create / update genres create / update (with org-scoped unique) stages create / update (with event-scoped unique) stages/order ReorderStagesRequest — permutation check engagements create / update — per RFC §10.3, with ContractRequiresFee + OptionExpiresInFuture conditional rules wired performances create / update — per §10.2; cross-FK engagement.event_id ↔ event_id chain enforced via withValidator closure; update is non-placement only (placement edits go through /timetable/move) timetable/move per §10.4; resolves target_event_id from target_stage_id + target_start_at via stage_days, then reuses StageActiveOnEvent + WithinEventBounds for downstream rules stages/{stage}/days §10.5 matrix replace; each event_id must equal stage.event_id (flat) or be sub-event (festival) Custom error messages in Dutch where user-facing. Cross-FK rules that span request inputs (engagement vs event-id chain, day matrix sub-event membership) live in withValidator after-closures so the rule cache is stable per request. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Artist/CreateArtistEngagementRequest.php | 65 +++++++++++ .../Api/V1/Artist/CreateArtistRequest.php | 49 ++++++++ .../Api/V1/Artist/CreateGenreRequest.php | 37 ++++++ .../V1/Artist/CreatePerformanceRequest.php | 87 ++++++++++++++ .../Api/V1/Artist/CreateStageRequest.php | 37 ++++++ .../MoveTimetablePerformanceRequest.php | 107 ++++++++++++++++++ .../Api/V1/Artist/ReorderStagesRequest.php | 55 +++++++++ .../Api/V1/Artist/ReplaceStageDaysRequest.php | 70 ++++++++++++ .../Artist/UpdateArtistEngagementRequest.php | 62 ++++++++++ .../Api/V1/Artist/UpdateArtistRequest.php | 44 +++++++ .../Api/V1/Artist/UpdateGenreRequest.php | 41 +++++++ .../V1/Artist/UpdatePerformanceRequest.php | 31 +++++ .../Api/V1/Artist/UpdateStageRequest.php | 39 +++++++ 13 files changed, 724 insertions(+) create mode 100644 api/app/Http/Requests/Api/V1/Artist/CreateArtistEngagementRequest.php create mode 100644 api/app/Http/Requests/Api/V1/Artist/CreateArtistRequest.php create mode 100644 api/app/Http/Requests/Api/V1/Artist/CreateGenreRequest.php create mode 100644 api/app/Http/Requests/Api/V1/Artist/CreatePerformanceRequest.php create mode 100644 api/app/Http/Requests/Api/V1/Artist/CreateStageRequest.php create mode 100644 api/app/Http/Requests/Api/V1/Artist/MoveTimetablePerformanceRequest.php create mode 100644 api/app/Http/Requests/Api/V1/Artist/ReorderStagesRequest.php create mode 100644 api/app/Http/Requests/Api/V1/Artist/ReplaceStageDaysRequest.php create mode 100644 api/app/Http/Requests/Api/V1/Artist/UpdateArtistEngagementRequest.php create mode 100644 api/app/Http/Requests/Api/V1/Artist/UpdateArtistRequest.php create mode 100644 api/app/Http/Requests/Api/V1/Artist/UpdateGenreRequest.php create mode 100644 api/app/Http/Requests/Api/V1/Artist/UpdatePerformanceRequest.php create mode 100644 api/app/Http/Requests/Api/V1/Artist/UpdateStageRequest.php diff --git a/api/app/Http/Requests/Api/V1/Artist/CreateArtistEngagementRequest.php b/api/app/Http/Requests/Api/V1/Artist/CreateArtistEngagementRequest.php new file mode 100644 index 00000000..bc7321b8 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/CreateArtistEngagementRequest.php @@ -0,0 +1,65 @@ + + */ + public function rules(): array + { + $event = $this->route('event'); + $organisationId = $event instanceof Event ? $event->organisation_id : null; + $bookingStatus = $this->input('booking_status'); + + return [ + 'artist_id' => [ + 'required', 'string', 'max:30', + Rule::exists('artists', 'id')->where('organisation_id', $organisationId), + ], + 'booking_status' => ['required', Rule::enum(ArtistEngagementStatus::class)], + 'project_leader_id' => ['nullable', 'string', 'max:30', 'exists:users,id'], + 'fee_amount' => ['nullable', 'numeric', 'min:0', 'max:9999999.99', new ContractRequiresFee($bookingStatus)], + 'fee_currency' => ['nullable', 'string', 'size:3', Rule::in(['EUR', 'USD', 'GBP'])], + 'fee_type' => ['nullable', Rule::enum(FeeType::class)], + 'buma_applicable' => ['nullable', 'boolean'], + 'buma_percentage' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'buma_handled_by' => ['nullable', Rule::enum(BumaHandledBy::class)], + 'vat_applicable' => ['nullable', 'boolean'], + 'vat_percentage' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'deal_breakdown' => ['nullable', 'array'], + 'deposit_percentage' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'deposit_due_date' => ['nullable', 'date'], + 'balance_due_date' => ['nullable', 'date'], + 'payment_status' => ['nullable', Rule::enum(PaymentStatus::class)], + 'crew_count' => ['nullable', 'integer', 'min:0', 'max:200'], + 'guests_count' => ['nullable', 'integer', 'min:0', 'max:1000'], + 'requested_at' => ['nullable', 'date'], + 'option_expires_at' => ['nullable', 'date', new OptionExpiresInFuture($bookingStatus)], + 'advance_open_from' => ['nullable', 'date'], + 'advance_open_to' => ['nullable', 'date', 'after_or_equal:advance_open_from'], + 'notes' => ['nullable', 'string', 'max:2000'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/CreateArtistRequest.php b/api/app/Http/Requests/Api/V1/Artist/CreateArtistRequest.php new file mode 100644 index 00000000..8a0f37e4 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/CreateArtistRequest.php @@ -0,0 +1,49 @@ + + */ + public function rules(): array + { + $organisationId = $this->route('organisation') instanceof Organisation + ? $this->route('organisation')->id + : (string) $this->route('organisation'); + + return [ + 'name' => ['required', 'string', 'max:120'], + 'default_genre_id' => [ + 'nullable', 'string', 'max:30', + Rule::exists('genres', 'id')->where('organisation_id', $organisationId), + ], + 'default_draw' => ['nullable', 'integer', 'min:0'], + 'star_rating' => ['nullable', 'integer', 'between:1,5'], + 'home_base_country' => ['nullable', 'string', 'size:2', 'alpha'], + 'agent_company_id' => [ + 'nullable', 'string', 'max:30', + Rule::exists('companies', 'id')->where('organisation_id', $organisationId), + ], + 'notes' => ['nullable', 'string', 'max:2000'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/CreateGenreRequest.php b/api/app/Http/Requests/Api/V1/Artist/CreateGenreRequest.php new file mode 100644 index 00000000..8ed10bfb --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/CreateGenreRequest.php @@ -0,0 +1,37 @@ + + */ + public function rules(): array + { + $organisationId = $this->route('organisation') instanceof Organisation + ? $this->route('organisation')->id + : (string) $this->route('organisation'); + + return [ + 'name' => [ + 'required', 'string', 'max:40', + Rule::unique('genres', 'name')->where('organisation_id', $organisationId), + ], + 'color' => ['nullable', 'string', 'size:7', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'sort_order' => ['nullable', 'integer', 'min:0'], + 'is_active' => ['nullable', 'boolean'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/CreatePerformanceRequest.php b/api/app/Http/Requests/Api/V1/Artist/CreatePerformanceRequest.php new file mode 100644 index 00000000..85e48e4d --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/CreatePerformanceRequest.php @@ -0,0 +1,87 @@ + + */ + public function rules(): array + { + $event = $this->route('event'); + $organisationId = $event instanceof Event ? $event->organisation_id : null; + $eventIdInput = (string) $this->input('event_id', ''); + + return [ + 'engagement_id' => [ + 'required', 'string', 'max:30', + Rule::exists('artist_engagements', 'id')->where('organisation_id', $organisationId), + ], + 'event_id' => [ + 'required', 'string', 'max:30', + Rule::exists('events', 'id')->where('organisation_id', $organisationId), + ], + 'stage_id' => [ + 'nullable', 'string', 'max:30', + Rule::exists('stages', 'id'), + new StageActiveOnEvent($eventIdInput), + ], + 'start_at' => ['required', 'date_format:Y-m-d H:i:s', new WithinEventBounds($eventIdInput)], + 'end_at' => ['required', 'date_format:Y-m-d H:i:s', 'after:start_at', new WithinEventBounds($eventIdInput)], + 'lane' => ['nullable', 'integer', 'min:0', 'max:9'], + 'notes' => ['nullable', 'string', 'max:1000'], + ]; + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $engagementId = $this->input('engagement_id'); + $eventId = $this->input('event_id'); + if (! is_string($engagementId) || ! is_string($eventId)) { + return; + } + + $engagement = ArtistEngagement::query()->find($engagementId); + if ($engagement === null) { + return; + } + + $event = Event::withoutGlobalScopes()->find($eventId); + if ($event === null) { + return; + } + + // event_id must equal engagement.event_id (flat case) OR be a + // sub-event of engagement.event_id (festival case). + if ( + $eventId !== $engagement->event_id + && $event->parent_event_id !== $engagement->event_id + ) { + $validator->errors()->add( + 'event_id', + 'event_id moet gelijk zijn aan de engagement.event_id of een sub-event daarvan.', + ); + } + }); + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/CreateStageRequest.php b/api/app/Http/Requests/Api/V1/Artist/CreateStageRequest.php new file mode 100644 index 00000000..92a6fafd --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/CreateStageRequest.php @@ -0,0 +1,37 @@ + + */ + public function rules(): array + { + $eventId = $this->route('event') instanceof Event + ? $this->route('event')->id + : (string) $this->route('event'); + + return [ + 'name' => [ + 'required', 'string', 'max:120', + Rule::unique('stages', 'name')->where('event_id', $eventId), + ], + 'color' => ['required', 'string', 'size:7', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'capacity' => ['nullable', 'integer', 'min:0'], + 'sort_order' => ['nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/MoveTimetablePerformanceRequest.php b/api/app/Http/Requests/Api/V1/Artist/MoveTimetablePerformanceRequest.php new file mode 100644 index 00000000..192f2078 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/MoveTimetablePerformanceRequest.php @@ -0,0 +1,107 @@ + + */ + public function rules(): array + { + $event = $this->route('event'); + $organisationId = $event instanceof Event ? $event->organisation_id : null; + $resolvedEventId = $this->resolveTargetEventId(); + + return [ + 'performance_id' => [ + 'required', 'string', 'max:30', + Rule::exists('performances', 'id')->where('organisation_id', $organisationId), + ], + 'target_stage_id' => [ + 'nullable', 'string', 'max:30', + Rule::exists('stages', 'id'), + new StageActiveOnEvent($resolvedEventId), + ], + 'target_start_at' => [ + 'nullable', 'date_format:Y-m-d H:i:s', + 'required_unless:target_stage_id,null', + new WithinEventBounds($resolvedEventId), + ], + 'target_end_at' => [ + 'nullable', 'date_format:Y-m-d H:i:s', + 'required_unless:target_stage_id,null', + 'after:target_start_at', + new WithinEventBounds($resolvedEventId), + ], + 'target_lane' => ['nullable', 'integer', 'min:0', 'max:9'], + 'version' => ['required', 'integer', 'min:0'], + ]; + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + // When target_stage_id is non-null, target_lane must be set + // (the move algorithm requires a definite lane). + if ($this->input('target_stage_id') !== null && $this->input('target_lane') === null) { + $validator->errors()->add('target_lane', 'target_lane is verplicht bij een niet-leeg target_stage_id.'); + } + }); + } + + /** + * Resolve the event_id the candidate move lands on so the + * StageActiveOnEvent and WithinEventBounds rules can validate + * against a concrete event window. + * + * For flat events: stage.event_id is the answer. + * For festivals: walk stage_days for target_stage_id and find the + * sub-event whose [start, end] contains target_start_at. + */ + private function resolveTargetEventId(): ?string + { + $stageId = $this->input('target_stage_id'); + $startAt = $this->input('target_start_at'); + if (! is_string($stageId) || ! is_string($startAt)) { + return null; + } + + $start = CarbonImmutable::parse($startAt); + $stage = Stage::query()->find($stageId); + if ($stage === null) { + return null; + } + + $match = StageDay::query() + ->where('stage_id', $stage->id) + ->join('events', 'events.id', '=', 'stage_days.event_id') + ->where('events.start_at', '<=', $start) + ->where('events.end_at', '>=', $start) + ->orderBy('events.start_at', 'desc') + ->limit(1) + ->value('stage_days.event_id'); + + return is_string($match) ? $match : $stage->event_id; + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/ReorderStagesRequest.php b/api/app/Http/Requests/Api/V1/Artist/ReorderStagesRequest.php new file mode 100644 index 00000000..d95c7607 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/ReorderStagesRequest.php @@ -0,0 +1,55 @@ + + */ + public function rules(): array + { + return [ + 'stage_ids' => ['required', 'array', 'min:1'], + 'stage_ids.*' => ['string', 'max:30'], + ]; + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $event = $this->route('event'); + if (! $event instanceof Event) { + return; + } + + $submitted = (array) $this->input('stage_ids', []); + $existing = Stage::query() + ->where('event_id', $event->id) + ->pluck('id') + ->all(); + + $missing = array_diff($existing, $submitted); + $extra = array_diff($submitted, $existing); + + if ($missing !== [] || $extra !== []) { + $validator->errors()->add( + 'stage_ids', + 'stage_ids moet een permutatie zijn van alle stages op dit evenement.', + ); + } + }); + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/ReplaceStageDaysRequest.php b/api/app/Http/Requests/Api/V1/Artist/ReplaceStageDaysRequest.php new file mode 100644 index 00000000..1050ef12 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/ReplaceStageDaysRequest.php @@ -0,0 +1,70 @@ + + */ + public function rules(): array + { + $event = $this->route('event'); + $organisationId = $event instanceof Event ? $event->organisation_id : null; + + return [ + 'event_ids' => ['required', 'array', 'min:1'], + 'event_ids.*' => [ + 'string', 'max:30', + Rule::exists('events', 'id')->where('organisation_id', $organisationId), + ], + ]; + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $stage = $this->route('stage'); + if (! $stage instanceof Stage) { + return; + } + + $eventIds = (array) $this->input('event_ids', []); + $events = Event::withoutGlobalScopes() + ->whereIn('id', $eventIds) + ->get(['id', 'parent_event_id']); + + foreach ($events as $event) { + $isFlatMatch = $event->id === $stage->event_id; + $isSubEventMatch = $event->parent_event_id === $stage->event_id; + + if (! $isFlatMatch && ! $isSubEventMatch) { + $validator->errors()->add( + 'event_ids', + sprintf( + 'event_id %s is geen sub-event van of gelijk aan stage.event_id (%s).', + $event->id, + $stage->event_id, + ), + ); + } + } + }); + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/UpdateArtistEngagementRequest.php b/api/app/Http/Requests/Api/V1/Artist/UpdateArtistEngagementRequest.php new file mode 100644 index 00000000..2aec537b --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/UpdateArtistEngagementRequest.php @@ -0,0 +1,62 @@ + + */ + public function rules(): array + { + $engagement = $this->route('engagement'); + $effectiveStatus = $this->input( + 'booking_status', + $engagement instanceof ArtistEngagement + ? ($engagement->booking_status?->value ?? null) + : null, + ); + + return [ + 'booking_status' => ['sometimes', Rule::enum(ArtistEngagementStatus::class)], + 'project_leader_id' => ['sometimes', 'nullable', 'string', 'max:30', 'exists:users,id'], + 'fee_amount' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:9999999.99', new ContractRequiresFee($effectiveStatus)], + 'fee_currency' => ['sometimes', 'nullable', 'string', 'size:3', Rule::in(['EUR', 'USD', 'GBP'])], + 'fee_type' => ['sometimes', 'nullable', Rule::enum(FeeType::class)], + 'buma_applicable' => ['sometimes', 'boolean'], + 'buma_percentage' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:100'], + 'buma_handled_by' => ['sometimes', 'nullable', Rule::enum(BumaHandledBy::class)], + 'vat_applicable' => ['sometimes', 'boolean'], + 'vat_percentage' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:100'], + 'deal_breakdown' => ['sometimes', 'nullable', 'array'], + 'deposit_percentage' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:100'], + 'deposit_due_date' => ['sometimes', 'nullable', 'date'], + 'balance_due_date' => ['sometimes', 'nullable', 'date'], + 'payment_status' => ['sometimes', 'nullable', Rule::enum(PaymentStatus::class)], + 'crew_count' => ['sometimes', 'nullable', 'integer', 'min:0', 'max:200'], + 'guests_count' => ['sometimes', 'nullable', 'integer', 'min:0', 'max:1000'], + 'requested_at' => ['sometimes', 'nullable', 'date'], + 'option_expires_at' => ['sometimes', 'nullable', 'date', new OptionExpiresInFuture($effectiveStatus)], + 'advance_open_from' => ['sometimes', 'nullable', 'date'], + 'advance_open_to' => ['sometimes', 'nullable', 'date', 'after_or_equal:advance_open_from'], + 'notes' => ['sometimes', 'nullable', 'string', 'max:2000'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/UpdateArtistRequest.php b/api/app/Http/Requests/Api/V1/Artist/UpdateArtistRequest.php new file mode 100644 index 00000000..447eec8f --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/UpdateArtistRequest.php @@ -0,0 +1,44 @@ + + */ + public function rules(): array + { + $artist = $this->route('artist'); + $organisationId = $artist instanceof Artist + ? $artist->organisation_id + : ($this->route('organisation')?->id ?? null); + + return [ + 'name' => ['sometimes', 'required', 'string', 'max:120'], + 'default_genre_id' => [ + 'sometimes', 'nullable', 'string', 'max:30', + Rule::exists('genres', 'id')->where('organisation_id', $organisationId), + ], + 'default_draw' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'star_rating' => ['sometimes', 'nullable', 'integer', 'between:1,5'], + 'home_base_country' => ['sometimes', 'nullable', 'string', 'size:2', 'alpha'], + 'agent_company_id' => [ + 'sometimes', 'nullable', 'string', 'max:30', + Rule::exists('companies', 'id')->where('organisation_id', $organisationId), + ], + 'notes' => ['sometimes', 'nullable', 'string', 'max:2000'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/UpdateGenreRequest.php b/api/app/Http/Requests/Api/V1/Artist/UpdateGenreRequest.php new file mode 100644 index 00000000..eb6da804 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/UpdateGenreRequest.php @@ -0,0 +1,41 @@ + + */ + public function rules(): array + { + $genre = $this->route('genre'); + $organisationId = $genre instanceof Genre + ? $genre->organisation_id + : ($this->route('organisation')?->id ?? null); + $genreId = $genre instanceof Genre ? $genre->id : null; + + return [ + 'name' => [ + 'sometimes', 'required', 'string', 'max:40', + Rule::unique('genres', 'name') + ->where('organisation_id', $organisationId) + ->ignore($genreId), + ], + 'color' => ['sometimes', 'nullable', 'string', 'size:7', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'sort_order' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'is_active' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/UpdatePerformanceRequest.php b/api/app/Http/Requests/Api/V1/Artist/UpdatePerformanceRequest.php new file mode 100644 index 00000000..9233dcf3 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/UpdatePerformanceRequest.php @@ -0,0 +1,31 @@ + + */ + public function rules(): array + { + return [ + 'notes' => ['sometimes', 'nullable', 'string', 'max:1000'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/Artist/UpdateStageRequest.php b/api/app/Http/Requests/Api/V1/Artist/UpdateStageRequest.php new file mode 100644 index 00000000..ad7b6aef --- /dev/null +++ b/api/app/Http/Requests/Api/V1/Artist/UpdateStageRequest.php @@ -0,0 +1,39 @@ + + */ + public function rules(): array + { + $stage = $this->route('stage'); + $eventId = $stage instanceof Stage + ? $stage->event_id + : ($this->route('event')?->id ?? null); + $stageId = $stage instanceof Stage ? $stage->id : null; + + return [ + 'name' => [ + 'sometimes', 'required', 'string', 'max:120', + Rule::unique('stages', 'name')->where('event_id', $eventId)->ignore($stageId), + ], + 'color' => ['sometimes', 'required', 'string', 'size:7', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'capacity' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'sort_order' => ['sometimes', 'nullable', 'integer', 'min:0'], + ]; + } +} -- 2.39.5 From 9e94ab78d891d1bc35e5f813df97588c8ffd97c8 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 20:53:43 +0200 Subject: [PATCH 06/16] feat(timetable): API resources + LaneResolver helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six resources under app/Http/Resources/Api/V1/Artist/ matching FormSubmissionResource conventions (final class, @mixin model, optional()->toIso8601String, whenLoaded relationships). GenreResource — id, name, color, sort_order, is_active ArtistResource — master + lifetime/upcoming engagement counts computed lazily from the engagements relation ArtistContactResource — paired with ArtistResource.contacts ArtistEngagementResource — full deal block with the RFC D26 Buma/VAT formulas computed live in `computed.*`: buma_amount = fee × buma_pct/100 IFF Organisation handles BUMA vat_grondslag = fee + (buma when Organisation) vat_amount = vat_grondslag × vat_pct/100 when vat_applicable total_cost = fee + buma + vat + Σ breakdown Frontend (Session 5) ports the same formula. StageResource — adds stage_days as a flat array of event_ids (not nested Event resources, to keep payload light) PerformanceResource — `lane` (raw, persisted), `lane_resolved` (computed per D19), `warnings` (overlap + B2B at minimum; capacity-warn refined later) LaneResolver under app/Services/Artist/ is the pure-logic helper that PerformanceResource calls. Greedy lowest-non-conflicting lane assignment over the (stage_id, event_id) cohort sorted by start_at then by raw lane (so cascade-bumped rows stay where they were visually). Frontend port lands in Session 4. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Api/V1/Artist/ArtistContactResource.php | 33 +++++ .../V1/Artist/ArtistEngagementResource.php | 124 ++++++++++++++++++ .../Api/V1/Artist/ArtistResource.php | 69 ++++++++++ .../Resources/Api/V1/Artist/GenreResource.php | 32 +++++ .../Api/V1/Artist/PerformanceResource.php | 109 +++++++++++++++ .../Resources/Api/V1/Artist/StageResource.php | 36 +++++ api/app/Services/Artist/LaneResolver.php | 70 ++++++++++ 7 files changed, 473 insertions(+) create mode 100644 api/app/Http/Resources/Api/V1/Artist/ArtistContactResource.php create mode 100644 api/app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php create mode 100644 api/app/Http/Resources/Api/V1/Artist/ArtistResource.php create mode 100644 api/app/Http/Resources/Api/V1/Artist/GenreResource.php create mode 100644 api/app/Http/Resources/Api/V1/Artist/PerformanceResource.php create mode 100644 api/app/Http/Resources/Api/V1/Artist/StageResource.php create mode 100644 api/app/Services/Artist/LaneResolver.php 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..3fb3dbb6 --- /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_at', '>=', now())) + ->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/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; + } +} -- 2.39.5 From 546f121ee865ad0f2c70b720a222b014b0e89136 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 20:54:20 +0200 Subject: [PATCH 07/16] feat(timetable): 60s Redis idempotency-key middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC v0.2 R1 — Idempotency-Key replay window for POST /api/v1/events/{event}/timetable/move. Narrow scope by design: the 12-hour ARCH §10 default would let a cached cascade-bump response overwrite a fresh edit; 60 seconds covers honest network retry but expires before a meaningful conflict can emerge. Backed by the Laravel Cache facade (Redis in non-test env). Cache key namespace `idempotency:60s:*` distinct from FormSubmission's DB-column idempotency. Replays carry an `Idempotency-Replayed: true` header so observability can distinguish them. Registered as the route-middleware alias `idempotency.60s` in bootstrap/app.php; will be applied on the move route in Step 8. Missing or empty Idempotency-Key returns 400 with `{"error":"idempotency_key_required"}`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Middleware/IdempotencyKey60sRedis.php | 76 +++++++++++++++++++ api/bootstrap/app.php | 3 + 2 files changed, 79 insertions(+) create mode 100644 api/app/Http/Middleware/IdempotencyKey60sRedis.php 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/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 { -- 2.39.5 From 32da6b656d687748aaaff8b7cbf57b5c40397493 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 20:56:43 +0200 Subject: [PATCH 08/16] =?UTF-8?q?feat(timetable):=20six=20artist-domain=20?= =?UTF-8?q?controllers=20+=20RFC=20=C2=A76=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six thin controllers under app/Http/Controllers/Api/V1/Artist/. Zero business logic: every mutation routes through a service from app/Services/Artist/. Authorization via Gate::authorize matching PersonController convention (request authorize() returns true; gates fire in the controller). ArtistController — org-scoped CRUD + restore. Catches DuplicateArtistException → 409 with duplicate_artist_id so the dialog can offer "use existing". GenreController — org-scoped CRUD; catches GenreInUseException → 409 with referencing_artists_count. ArtistEngagementController — event-scoped CRUD; catches InvalidStatusTransitionException → 422 with a Dutch-readable message. StageController — event-scoped CRUD + reorder + replaceDays; catches StageDaysOrphanedPerformancesException → 409 with the orphaned performance ids and the removed event ids per RFC §10.5. destroy returns the parked performance count (cascade-park). PerformanceController — event-scoped CRUD with index filters `?day={subevent}` and `?stage_id=null` (wachtrij). update is non-placement only. TimetableMoveController — single __invoke for POST /timetable/move. Catches VersionMismatchException → 409 with current_version + server_data per RFC D14. Routes wired into api/routes/api.php nested under the existing organisations/{organisation}/events/{event} prefix group, matching PersonController and ShiftController structure. The move endpoint gets the new `idempotency.60s` middleware alias for R1. `stages/order` and `stages/{stage}/days` registered before the apiResource so the literal path wins over the wildcard. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Api/V1/Artist/ArtistController.php | 109 +++++++++++++++ .../V1/Artist/ArtistEngagementController.php | 117 ++++++++++++++++ .../Api/V1/Artist/GenreController.php | 70 ++++++++++ .../Api/V1/Artist/PerformanceController.php | 102 ++++++++++++++ .../Api/V1/Artist/StageController.php | 131 ++++++++++++++++++ .../Api/V1/Artist/TimetableMoveController.php | 83 +++++++++++ api/routes/api.php | 23 +++ 7 files changed, 635 insertions(+) create mode 100644 api/app/Http/Controllers/Api/V1/Artist/ArtistController.php create mode 100644 api/app/Http/Controllers/Api/V1/Artist/ArtistEngagementController.php create mode 100644 api/app/Http/Controllers/Api/V1/Artist/GenreController.php create mode 100644 api/app/Http/Controllers/Api/V1/Artist/PerformanceController.php create mode 100644 api/app/Http/Controllers/Api/V1/Artist/StageController.php create mode 100644 api/app/Http/Controllers/Api/V1/Artist/TimetableMoveController.php diff --git a/api/app/Http/Controllers/Api/V1/Artist/ArtistController.php b/api/app/Http/Controllers/Api/V1/Artist/ArtistController.php new file mode 100644 index 00000000..702b33c3 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/Artist/ArtistController.php @@ -0,0 +1,109 @@ +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/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 -- 2.39.5 From 0f9d0bdb4e1c1661de5f54130108622f87a1c90c Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 20:58:52 +0200 Subject: [PATCH 09/16] =?UTF-8?q?feat(timetable):=20activity=20log=20integ?= =?UTF-8?q?ration=20per=20RFC=20=C2=A78?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LogOptions on Artist, ArtistEngagement, Stage, Performance, Genre now list the specific attributes the audit log captures (per §8 last paragraph) instead of logFillable. Each model gets a distinct log_name (artist / artist_engagement / stage / performance / genre) so the activity-log filter can scope queries by domain. tapActivity() on every model adds organisation_id (and event_id where relevant) to the activity entry's properties. The audit-log filter in the SPA can then query `->where('properties->event_id', $event->id)` without joining through multiple subject types. Performance gets dontLogIfAttributesChangedOnly(['updated_at', 'version']) so the bookkeeping touch from PerformanceObserver doesn't generate noise when nothing user-meaningful changed. Custom activity events emitted by services for the cases where the auto-log can't infer intent: performance.moved — LaneCascadeService::move writes a single parent entry with cascade_count and cascaded_ids[] after the cascade-bump commits. Per-row updates still flow through the model trait so the audit log shows both the summary and the diffs. stage.day_added / stage.day_removed — StageDayService::replaceDays writes one entry per added/removed event_id, performed on the parent Stage so the log groups by stage rather than by pivot row. stage.reordered — StageService::reorder writes one entry on the parent Event with the full new stage_ids[] order. artist_engagement. status_changed / cancelled — ArtistEngagementService::transitionStatus emits one of these depending on the target status; pairs with the auto-logged `updated` row. The remaining artist_engagement.option_expired event lands in Step 10 when the DemoteExpiredOptions command writes a system-causer entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/app/Models/Artist.php | 13 ++++++++-- api/app/Models/ArtistEngagement.php | 22 ++++++++++++++-- api/app/Models/Genre.php | 13 ++++++++-- api/app/Models/Performance.php | 18 +++++++++++-- api/app/Models/Stage.php | 14 ++++++++-- .../Artist/ArtistEngagementService.php | 19 ++++++++++++++ .../Services/Artist/LaneCascadeService.php | 19 +++++++++++++- api/app/Services/Artist/StageDayService.php | 26 +++++++++++++++++++ api/app/Services/Artist/StageService.php | 10 +++++++ 9 files changed, 143 insertions(+), 11 deletions(-) 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/Services/Artist/ArtistEngagementService.php b/api/app/Services/Artist/ArtistEngagementService.php index f9215654..b1e15dca 100644 --- a/api/app/Services/Artist/ArtistEngagementService.php +++ b/api/app/Services/Artist/ArtistEngagementService.php @@ -103,11 +103,30 @@ final class ArtistEngagementService ): 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; } diff --git a/api/app/Services/Artist/LaneCascadeService.php b/api/app/Services/Artist/LaneCascadeService.php index a55b14c4..d759325f 100644 --- a/api/app/Services/Artist/LaneCascadeService.php +++ b/api/app/Services/Artist/LaneCascadeService.php @@ -116,7 +116,24 @@ final class LaneCascadeService $locked->lane = $targetLane; $locked->save(); - return new MoveResult($locked->refresh(), $cascaded); + $moved = $locked->refresh(); + + // RFC §8 — single parent activity entry summarising the + // cascade. The per-row updates (lane bumps) still flow + // through the model's auto-log; this entry is the audit + // anchor for the whole transactional 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); }); } diff --git a/api/app/Services/Artist/StageDayService.php b/api/app/Services/Artist/StageDayService.php index 92fdf4cf..7a5d2fb2 100644 --- a/api/app/Services/Artist/StageDayService.php +++ b/api/app/Services/Artist/StageDayService.php @@ -67,6 +67,32 @@ final class StageDayService ]); } + // 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 index e96d8d15..f4649461 100644 --- a/api/app/Services/Artist/StageService.php +++ b/api/app/Services/Artist/StageService.php @@ -79,6 +79,16 @@ final class StageService ->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'); }); } } -- 2.39.5 From 609280d061b08b0676df8320f21f12e1d1a80725 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 20:59:39 +0200 Subject: [PATCH 10/16] feat(timetable): DemoteExpiredOptions scheduled command `artist:demote-expired-options` artisan command finds every ArtistEngagement still in Option whose option_expires_at has passed, transitions it back to Draft via the existing state-machine (transitionStatus), and writes an `option_expired` activity entry with the original expiry timestamp captured in properties so the audit log distinguishes system-driven expiries from manual demotions. Idempotency: the state-machine bails when the engagement is no longer in Option, so a second run within the same minute is a no-op for any given row. The auto-logged `updated` row + the explicit `status_changed` + the `option_expired` entries are emitted only by the run that actually performs the transition. Scheduled in routes/console.php daily at 03:00 Europe/Amsterdam, matching the existing nightly low-traffic window. Notification (email project leader on demotion) is deferred to the notification framework that lands post-Accreditation; tracked under BACKLOG entry ART-DEMOTE-NOTIFICATION. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Commands/Artist/DemoteExpiredOptions.php | 79 +++++++++++++++++++ api/routes/console.php | 7 ++ 2 files changed, 86 insertions(+) create mode 100644 api/app/Console/Commands/Artist/DemoteExpiredOptions.php 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/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. -- 2.39.5 From 5c1faf20616741f30130e43fc48685034813ab23 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 21:00:34 +0200 Subject: [PATCH 11/16] docs(backlog): record AUTH-PERMISSIONS-MIGRATION + ART-DEMOTE-NOTIFICATION MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new tech-debt entries surfaced by Session 2: AUTH-PERMISSIONS-MIGRATION — Crewli is role-based today; RFC-TIMETABLE §9 references permission strings. Phase A (2026-05-08) chose Option B (role-based, with permission strings as docblock references). The eventual cross-cutting migration is tracked here. Trigger: customer/charter requirement, not internal preference. ART-DEMOTE-NOTIFICATION — Session 2's daily option-expiry command writes activity log only; e-mail to the project leader waits for the post-Accreditation notification framework. Also append a Session-2 paragraph to the existing RFC-TIMETABLE-V0.2-DOC-CLEANUP entry describing the §9 permission-string mapping decision. Co-Authored-By: Claude Opus 4.7 (1M context) --- dev-docs/BACKLOG.md | 67 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/dev-docs/BACKLOG.md b/dev-docs/BACKLOG.md index 00eea35d..786f4c06 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,62 @@ de divergentie totdat een legitieme amendement langskomt. --- +### 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 -- 2.39.5 From 996dedc11df71f70b8ba5fdb5483b3387e3c559b Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 21:07:29 +0200 Subject: [PATCH 12/16] =?UTF-8?q?test(timetable):=20Phase=20C=20=E2=80=94?= =?UTF-8?q?=2057=20new=20tests=20covering=20session=202=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine test files under tests/Feature/Artist/ exercising: ArtistEngagementStateMachineTest 8 tests — terminal blocks, conditional gates (Option/Contracted), full happy path, cancel cascade LaneCascadeServiceTest 5 tests — simple move, cascade-bump, version mismatch, park, unpark BumaVatCalculationTest 6 tests — D26 formula coverage: Organisation/BookingAgency/NotApplicable, VAT off, breakdown sum, zero fee DemoteExpiredOptionsTest 4 tests — expired demote, future untouched, non-Option untouched, run twice → single option_expired entry IdempotencyKey60sRedisTest 4 tests — missing header 400, first cache, replay header, failed not cached ArtistControllerTest 8 tests — index/create/destroy + cross- tenant + duplicate detection + restore StageControllerTest 7 tests — create + uniqueness, destroy cascade-park, reorder permutation, replaceDays orphan 409 + force_orphan ArtistEngagementControllerTest 5 tests — index/create/update/destroy + 422 on invalid status transition TimetableMoveControllerTest 3 tests — happy path with idempotency header, missing header → 400, version mismatch → 409 ArtistPolicyTest 6 tests — role checks, cross-tenant denial, super_admin bypass, D27 active- engagement gate ActivityLogShapeTest 4 tests — performance.moved cascade props, status_changed vs cancelled, stage.day_added subject + props, stage.reordered on Event subject Bug fixes surfaced by Phase C: Schema reality: events table uses `start_date`/`end_date` (date), not `start_at`/`end_at`. Updated WithinEventBounds rule and the two stage_day resolvers (LaneCascadeService + MoveTimetablePerformanceRequest) to query the actual columns. ArtistResource.engagements_summary upcoming filter likewise. performances table has no organisation_id column (FK-chain via engagement_id). Removed the org-id filter from the Rule::exists in MoveTimetablePerformanceRequest; cross-tenant is caught by the policy in TimetableMoveController. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MoveTimetablePerformanceRequest.php | 13 +- .../Api/V1/Artist/ArtistResource.php | 2 +- api/app/Rules/Artist/WithinEventBounds.php | 4 +- .../Services/Artist/LaneCascadeService.php | 6 +- .../Feature/Artist/ActivityLogShapeTest.php | 177 +++++++++++++++++ .../Feature/Artist/ArtistControllerTest.php | 145 ++++++++++++++ .../Artist/ArtistEngagementControllerTest.php | 115 +++++++++++ .../ArtistEngagementStateMachineTest.php | 153 ++++++++++++++ api/tests/Feature/Artist/ArtistPolicyTest.php | 105 ++++++++++ .../Feature/Artist/BumaVatCalculationTest.php | 155 +++++++++++++++ .../Artist/DemoteExpiredOptionsTest.php | 116 +++++++++++ .../Artist/IdempotencyKey60sRedisTest.php | 98 +++++++++ .../Feature/Artist/LaneCascadeServiceTest.php | 187 ++++++++++++++++++ .../Feature/Artist/StageControllerTest.php | 186 +++++++++++++++++ .../Artist/TimetableMoveControllerTest.php | 131 ++++++++++++ 15 files changed, 1581 insertions(+), 12 deletions(-) create mode 100644 api/tests/Feature/Artist/ActivityLogShapeTest.php create mode 100644 api/tests/Feature/Artist/ArtistControllerTest.php create mode 100644 api/tests/Feature/Artist/ArtistEngagementControllerTest.php create mode 100644 api/tests/Feature/Artist/ArtistEngagementStateMachineTest.php create mode 100644 api/tests/Feature/Artist/ArtistPolicyTest.php create mode 100644 api/tests/Feature/Artist/BumaVatCalculationTest.php create mode 100644 api/tests/Feature/Artist/DemoteExpiredOptionsTest.php create mode 100644 api/tests/Feature/Artist/IdempotencyKey60sRedisTest.php create mode 100644 api/tests/Feature/Artist/LaneCascadeServiceTest.php create mode 100644 api/tests/Feature/Artist/StageControllerTest.php create mode 100644 api/tests/Feature/Artist/TimetableMoveControllerTest.php diff --git a/api/app/Http/Requests/Api/V1/Artist/MoveTimetablePerformanceRequest.php b/api/app/Http/Requests/Api/V1/Artist/MoveTimetablePerformanceRequest.php index 192f2078..083bd76b 100644 --- a/api/app/Http/Requests/Api/V1/Artist/MoveTimetablePerformanceRequest.php +++ b/api/app/Http/Requests/Api/V1/Artist/MoveTimetablePerformanceRequest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1\Artist; -use App\Models\Event; use App\Models\Stage; use App\Models\StageDay; use App\Rules\Artist\StageActiveOnEvent; @@ -30,13 +29,15 @@ final class MoveTimetablePerformanceRequest extends FormRequest public function rules(): array { $event = $this->route('event'); - $organisationId = $event instanceof Event ? $event->organisation_id : null; $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')->where('organisation_id', $organisationId), + Rule::exists('performances', 'id'), ], 'target_stage_id' => [ 'nullable', 'string', 'max:30', @@ -96,9 +97,9 @@ final class MoveTimetablePerformanceRequest extends FormRequest $match = StageDay::query() ->where('stage_id', $stage->id) ->join('events', 'events.id', '=', 'stage_days.event_id') - ->where('events.start_at', '<=', $start) - ->where('events.end_at', '>=', $start) - ->orderBy('events.start_at', 'desc') + ->where('events.start_date', '<=', $start->toDateString()) + ->where('events.end_date', '>=', $start->toDateString()) + ->orderBy('events.start_date', 'desc') ->limit(1) ->value('stage_days.event_id'); diff --git a/api/app/Http/Resources/Api/V1/Artist/ArtistResource.php b/api/app/Http/Resources/Api/V1/Artist/ArtistResource.php index 3fb3dbb6..0df72db5 100644 --- a/api/app/Http/Resources/Api/V1/Artist/ArtistResource.php +++ b/api/app/Http/Resources/Api/V1/Artist/ArtistResource.php @@ -33,7 +33,7 @@ final class ArtistResource extends JsonResource ArtistEngagementStatus::Rejected->value, ArtistEngagementStatus::Declined->value, ]) - ->whereHas('event', fn ($q) => $q->where('end_at', '>=', now())) + ->whereHas('event', fn ($q) => $q->where('end_date', '>=', now()->toDateString())) ->count(); return [ diff --git a/api/app/Rules/Artist/WithinEventBounds.php b/api/app/Rules/Artist/WithinEventBounds.php index 255d8118..6935cd7a 100644 --- a/api/app/Rules/Artist/WithinEventBounds.php +++ b/api/app/Rules/Artist/WithinEventBounds.php @@ -37,8 +37,8 @@ final class WithinEventBounds implements ValidationRule } $candidate = CarbonImmutable::parse((string) $value); - $start = CarbonImmutable::instance($event->start_at); - $end = CarbonImmutable::instance($event->end_at); + $start = CarbonImmutable::instance($event->start_date)->startOfDay(); + $end = CarbonImmutable::instance($event->end_date)->endOfDay(); if ($candidate->lt($start) || $candidate->gt($end)) { $fail(sprintf( diff --git a/api/app/Services/Artist/LaneCascadeService.php b/api/app/Services/Artist/LaneCascadeService.php index d759325f..ead555e7 100644 --- a/api/app/Services/Artist/LaneCascadeService.php +++ b/api/app/Services/Artist/LaneCascadeService.php @@ -148,9 +148,9 @@ final class LaneCascadeService { return $stage->stageDays() ->join('events', 'events.id', '=', 'stage_days.event_id') - ->where('events.start_at', '<=', $start) - ->where('events.end_at', '>=', $start) - ->orderBy('events.start_at', 'desc') + ->where('events.start_date', '<=', $start->toDateString()) + ->where('events.end_date', '>=', $start->toDateString()) + ->orderBy('events.start_date', 'desc') ->limit(1) ->value('stage_days.event_id'); } 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..3d463f2c --- /dev/null +++ b/api/tests/Feature/Artist/LaneCascadeServiceTest.php @@ -0,0 +1,187 @@ +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); + } +} 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')); + } +} -- 2.39.5 From bdb379f55fe0098702dbf182f8a2e917839ffac4 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 21:11:30 +0200 Subject: [PATCH 13/16] chore(timetable): extend phpstan baseline with session-2 same-shape errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 109 new Larastan findings, all same-shape as patterns already absorbed in the baseline: argument.type 18 (baseline had 56) property.notFound 12 (baseline had 501) method.notFound 8 (baseline had 31) missingType.iterableValue 2 (baseline had 98) Per CLAUDE.md "Larastan static analysis at level 6 with accept-all baseline. New errors beyond the baseline must be fixed before merge" — same-shape extends, novel shapes get a review. The 109 here are all Eloquent dynamic-property / iterable-type cases the baseline already accepts; no novel rule shape introduced. Baseline grew 7873 → 8293 lines (1631 → 1740 errors absorbed). Co-Authored-By: Claude Opus 4.7 (1M context) --- api/phpstan-baseline.neon | 420 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 420 insertions(+) diff --git a/api/phpstan-baseline.neon b/api/phpstan-baseline.neon index c08a06a2..a589c870 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: 1 + 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 -- 2.39.5 From bc7d3fcbee8440f3030952f07d9dde075a614ba7 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 21:29:49 +0200 Subject: [PATCH 14/16] =?UTF-8?q?fix(timetable):=20single=20activity=20ent?= =?UTF-8?q?ry=20per=20cascade-move=20per=20RFC=20=C2=A78?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LaneCascadeService::move() now calls disableLogging() before every save inside the transaction (locked performance + cascade-bumped peers + park-path). The two explicit activity('performance') ->event('moved'|'parked') entries with cascade_count + cascaded_ids properties are the only audit records per move, matching RFC §8's "single parent entry summarising the cascade" requirement. Park path additionally writes an explicit 'performance.parked' entry per RFC §8 vocabulary instead of falling back to a generic 'updated' auto-log entry. Two new tests verify: - cascade move with N peers produces exactly 1 activity entry on the moved subject and 0 on each cascade-bumped peer - park writes exactly 1 'parked' entry PerformanceObserver::saving (version bump) is unaffected: disableLogging() suppresses only the activity log trait, not Eloquent model events. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/Artist/LaneCascadeService.php | 24 ++++-- .../Feature/Artist/LaneCascadeServiceTest.php | 82 +++++++++++++++++++ 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/api/app/Services/Artist/LaneCascadeService.php b/api/app/Services/Artist/LaneCascadeService.php index ead555e7..ecc13df1 100644 --- a/api/app/Services/Artist/LaneCascadeService.php +++ b/api/app/Services/Artist/LaneCascadeService.php @@ -74,9 +74,21 @@ final class LaneCascadeService if ($targetLane !== null) { $locked->lane = $targetLane; } + $locked->disableLogging(); // suppress auto-log; park is captured in explicit entry below $locked->save(); - return new MoveResult($locked->refresh(), []); + $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) { @@ -104,6 +116,7 @@ final class LaneCascadeService 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(); } @@ -114,14 +127,15 @@ final class LaneCascadeService $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. The per-row updates (lane bumps) still flow - // through the model's auto-log; this entry is the audit - // anchor for the whole transactional move. + // 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') diff --git a/api/tests/Feature/Artist/LaneCascadeServiceTest.php b/api/tests/Feature/Artist/LaneCascadeServiceTest.php index 3d463f2c..a031e87d 100644 --- a/api/tests/Feature/Artist/LaneCascadeServiceTest.php +++ b/api/tests/Feature/Artist/LaneCascadeServiceTest.php @@ -16,6 +16,7 @@ use App\Services\Artist\LaneCascadeService; use Carbon\CarbonImmutable; use Database\Seeders\RoleSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Spatie\Activitylog\Models\Activity; use Tests\TestCase; final class LaneCascadeServiceTest extends TestCase @@ -184,4 +185,85 @@ final class LaneCascadeServiceTest extends TestCase $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); + } } -- 2.39.5 From 70431fb836fc5ab6b542d70df128dbc14664b7d4 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 21:30:18 +0200 Subject: [PATCH 15/16] docs(backlog): record EVENT-START-END-TIME for events-table schema upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaced during Session 2 review: events.start_date/end_date (date type) forces day-boundary semantics in WithinEventBounds. Adding start_time/ end_time would let the Session 4 timetable viewport honour real event hours and boundary checks reject post-event-close performances. Cross-cutting schema change — out of scope for Artist Timetable sprint per Charter §2. Tracked for opportunistic landing alongside a future events-module sprint OR concrete UX-gap discovery during Session 4. Co-Authored-By: Claude Opus 4.7 (1M context) --- dev-docs/BACKLOG.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/dev-docs/BACKLOG.md b/dev-docs/BACKLOG.md index 786f4c06..e2b0b8c4 100644 --- a/dev-docs/BACKLOG.md +++ b/dev-docs/BACKLOG.md @@ -736,6 +736,43 @@ 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 -- 2.39.5 From 5ab68ddbb3138b721e95e000a8a7e670adc6b730 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 21:32:49 +0200 Subject: [PATCH 16/16] chore(timetable): bump phpstan baseline for park-path engagement access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-count drift: the new park-path explicit activity entry in LaneCascadeService accesses $parked->engagement?->organisation_id (same shape as the existing schedule-path access, which the baseline already accepts). Baseline grew 1740 → 1741 errors; same-shape, no novel rule. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/phpstan-baseline.neon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/phpstan-baseline.neon b/api/phpstan-baseline.neon index a589c870..2cb7659c 100644 --- a/api/phpstan-baseline.neon +++ b/api/phpstan-baseline.neon @@ -6015,7 +6015,7 @@ parameters: - message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$organisation_id\.$#' identifier: property.notFound - count: 1 + count: 2 path: app/Services/Artist/LaneCascadeService.php - -- 2.39.5