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]); + } + }); + } +}