RFC-TIMETABLE v0.2 Session 2 — Backend API + business logic #16

Merged
bert.hausmans merged 16 commits from feat/timetable-session-2 into main 2026-05-08 21:57:00 +02:00
13 changed files with 936 additions and 0 deletions
Showing only changes of commit f7ed03237c - Show all commits

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Artist;
use App\Models\Artist;
use DomainException;
/**
* Raised by ArtistService::create when an exact-name match (case-insensitive)
* already exists in the same organisation. The handler can surface the
* existing artist's id to the UI so a "use existing or rename" choice is
* presented to the booker.
*/
final class DuplicateArtistException extends DomainException
{
public function __construct(
public readonly Artist $existing,
string $message = 'An artist with this name already exists in this organisation.',
) {
parent::__construct($message);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Artist;
use App\Models\Genre;
use DomainException;
/**
* Raised by GenreService::delete when artists still reference this genre
* via `default_genre_id`. The frontend must offer to re-bind those
* artists to a different genre (or null) before retrying.
*/
final class GenreInUseException extends DomainException
{
public function __construct(
public readonly Genre $genre,
public readonly int $referencingArtistsCount,
) {
parent::__construct(sprintf(
'Genre "%s" cannot be deleted: %d artist(s) reference it as their default genre.',
$genre->name,
$referencingArtistsCount,
));
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use DomainException;
/**
* Raised when a booking_status transition is rejected by the
* ArtistEngagement state machine (RFC v0.2 §10.1).
*/
final class InvalidStatusTransitionException extends DomainException
{
public function __construct(
public readonly ArtistEngagementStatus $from,
public readonly ArtistEngagementStatus $to,
public readonly string $reason,
) {
parent::__construct(sprintf(
'Invalid booking_status transition %s → %s: %s',
$from->value,
$to->value,
$reason,
));
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Artist;
use DomainException;
/**
* Raised by StageDayService::replaceDays when the proposed event_ids
* would remove a day that still has non-cancelled performances scheduled
* on it. The frontend re-prompts the user with a confirmation dialog
* and re-submits with `?force_orphan=true` to acknowledge the orphans.
*
* Controller maps to HTTP 409 with body
* `{conflict: 'orphaned_performances', performances_on_removed_events: [...]}`.
*/
final class StageDaysOrphanedPerformancesException extends DomainException
{
/**
* @param array<int, string> $performanceIds
* @param array<int, string> $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),
));
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Artist;
use DomainException;
/**
* Raised by LaneCascadeService::move when the client-supplied version
* does not match the row's current version (RFC v0.2 D14 optimistic
* locking on Performance). Controller maps to HTTP 409 with body
* `{conflict: 'version_mismatch', current_version: N, server_data: …}`.
*/
final class VersionMismatchException extends DomainException
{
public function __construct(
public readonly int $currentVersion,
public readonly int $clientVersion,
) {
parent::__construct(sprintf(
'Performance version mismatch: server=%d, client=%d.',
$currentVersion,
$clientVersion,
));
}
}

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Services\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Enums\Artist\BumaHandledBy;
use App\Exceptions\Artist\InvalidStatusTransitionException;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use Illuminate\Support\Facades\DB;
/**
* Heart of the artist booking domain. Owns the booking_status state
* machine, the deal-info defaults, and the cancellation cascade hook
* that the ArtistEngagementObserver relies on (the observer turns a
* soft-delete into a performances + advance_sections cascade; this
* service composes cancel + delete in one transaction).
*/
final class ArtistEngagementService
{
/**
* Terminal statuses no transitions out except an explicit
* `allowReopen` flag (currently unused by callers, reserved for
* superadmin tooling).
*/
private const TERMINAL = [
ArtistEngagementStatus::Cancelled,
ArtistEngagementStatus::Rejected,
ArtistEngagementStatus::Declined,
];
/**
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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);
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Services\Artist;
use App\Exceptions\Artist\DuplicateArtistException;
use App\Models\Artist;
use App\Models\Organisation;
use Illuminate\Support\Facades\DB;
final class ArtistService
{
/**
* Master-create. Detects exact-name duplicates within the
* organisation (case-insensitive) and raises DuplicateArtistException
* carrying the existing record so the controller can surface a "use
* existing or rename" choice. Slug uniqueness is handled by
* Artist::booted (Session 1).
*
* @param array<string, mixed> $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<string, mixed> $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();
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Services\Artist;
use App\Exceptions\Artist\GenreInUseException;
use App\Models\Artist;
use App\Models\Genre;
use App\Models\Organisation;
use Illuminate\Support\Facades\DB;
final class GenreService
{
/**
* @param array<string, mixed> $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<string, mixed> $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();
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Services\Artist;
use App\Exceptions\Artist\VersionMismatchException;
use App\Models\Performance;
use App\Models\Stage;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
/**
* RFC v0.2 D18 single transactional move algorithm.
*
* Locks the dragged Performance row (FOR UPDATE), validates the
* client's version number against the persisted one, and either parks
* the row (stage_id null) or places it on the target stage with a
* cascade-bump of any time-overlapping rows on the same lane. Cascade
* is single-level: every performance currently on
* (target_stage, target_event, target_lane) whose [start, end] window
* overlaps the new placement gets bumped to lane+1. Higher-lane chain
* collisions resolve themselves in a subsequent move; the prototype
* audit (§5) shows that single-level bumping is sufficient because
* collisions are rare and the user is editing one block at a time.
*
* The PerformanceObserver auto-bumps `version` on save so we don't
* need to set it explicitly here.
*/
final class LaneCascadeService
{
/**
* @return MoveResult moved + (other) cascaded performances
*
* @throws VersionMismatchException when client version is stale
*/
public function move(
Performance $performance,
?Stage $targetStage,
?CarbonImmutable $start,
?CarbonImmutable $end,
?int $targetLane,
int $clientVersion,
): MoveResult {
return DB::transaction(function () use (
$performance,
$targetStage,
$start,
$end,
$targetLane,
$clientVersion,
): MoveResult {
/** @var Performance $locked */
$locked = Performance::query()
->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;
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Services\Artist;
use App\Models\Performance;
/**
* Value object returned by LaneCascadeService::move.
*
* `moved` is the performance that the user is dragging.
* `cascaded` is the list of OTHER performances that the cascade-bump
* algorithm shifted to a higher lane to make room (RFC v0.2 D18).
*
* Both arrays carry already-refreshed Performance models so callers can
* map them straight to API resources without an extra DB roundtrip.
*/
final class MoveResult
{
/**
* @param array<int, Performance> $cascaded
*/
public function __construct(
public readonly Performance $moved,
public readonly array $cascaded,
) {}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Services\Artist;
use App\Models\ArtistEngagement;
use App\Models\Performance;
use App\Models\Stage;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
/**
* Performance CRUD + park/unpark. Placement edits (move-by-drag,
* cascade) route through LaneCascadeService to keep the row-locking
* and version-bump in one place; this service only handles non-
* placement state (notes) plus the binary park/unpark transitions.
*/
final class PerformanceService
{
public function __construct(
private readonly LaneCascadeService $laneCascade,
) {}
/**
* @param array<string, mixed> $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<string, mixed> $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;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Services\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Exceptions\Artist\StageDaysOrphanedPerformancesException;
use App\Models\Performance;
use App\Models\Stage;
use App\Models\StageDay;
use Illuminate\Support\Facades\DB;
/**
* RFC §10.5 atomic stage_days matrix replacement.
*
* Each event_id submitted must be either equal to `stage.event_id`
* (flat-event case) or a child of it via `parent_event_id` (festival
* sub-event case). Validation of that constraint lives in the
* FormRequest; this service trusts a vetted list and operates atomically.
*/
final class StageDayService
{
/**
* @param array<int, string> $eventIds
* @return array{added: array<int, string>, removed: array<int, string>}
*/
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];
});
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Services\Artist;
use App\Models\Event;
use App\Models\Performance;
use App\Models\Stage;
use Illuminate\Support\Facades\DB;
final class StageService
{
/**
* @param array<string, mixed> $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<string, mixed> $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<int, string> $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]);
}
});
}
}