RFC-TIMETABLE v0.2 Session 2 — Backend API + business logic #16
24
api/app/Exceptions/Artist/DuplicateArtistException.php
Normal file
24
api/app/Exceptions/Artist/DuplicateArtistException.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
api/app/Exceptions/Artist/GenreInUseException.php
Normal file
27
api/app/Exceptions/Artist/GenreInUseException.php
Normal 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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
27
api/app/Exceptions/Artist/VersionMismatchException.php
Normal file
27
api/app/Exceptions/Artist/VersionMismatchException.php
Normal 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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
214
api/app/Services/Artist/ArtistEngagementService.php
Normal file
214
api/app/Services/Artist/ArtistEngagementService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
api/app/Services/Artist/ArtistService.php
Normal file
79
api/app/Services/Artist/ArtistService.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
59
api/app/Services/Artist/GenreService.php
Normal file
59
api/app/Services/Artist/GenreService.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
150
api/app/Services/Artist/LaneCascadeService.php
Normal file
150
api/app/Services/Artist/LaneCascadeService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
api/app/Services/Artist/MoveResult.php
Normal file
28
api/app/Services/Artist/MoveResult.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
109
api/app/Services/Artist/PerformanceService.php
Normal file
109
api/app/Services/Artist/PerformanceService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
api/app/Services/Artist/StageDayService.php
Normal file
73
api/app/Services/Artist/StageDayService.php
Normal 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];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
84
api/app/Services/Artist/StageService.php
Normal file
84
api/app/Services/Artist/StageService.php
Normal 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]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user