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