Files
crewli/api/app/Services/Artist/ArtistEngagementService.php
bert.hausmans 0f9d0bdb4e feat(timetable): activity log integration per RFC §8
LogOptions on Artist, ArtistEngagement, Stage, Performance, Genre now
list the specific attributes the audit log captures (per §8 last
paragraph) instead of logFillable. Each model gets a distinct
log_name (artist / artist_engagement / stage / performance / genre)
so the activity-log filter can scope queries by domain.

tapActivity() on every model adds organisation_id (and event_id where
relevant) to the activity entry's properties. The audit-log filter in
the SPA can then query
`->where('properties->event_id', $event->id)` without joining through
multiple subject types.

Performance gets dontLogIfAttributesChangedOnly(['updated_at',
'version']) so the bookkeeping touch from PerformanceObserver doesn't
generate noise when nothing user-meaningful changed.

Custom activity events emitted by services for the cases where the
auto-log can't infer intent:

  performance.moved      — LaneCascadeService::move writes a single
                           parent entry with cascade_count and
                           cascaded_ids[] after the cascade-bump
                           commits. Per-row updates still flow
                           through the model trait so the audit log
                           shows both the summary and the diffs.
  stage.day_added /
  stage.day_removed     — StageDayService::replaceDays writes one
                           entry per added/removed event_id, performed
                           on the parent Stage so the log groups by
                           stage rather than by pivot row.
  stage.reordered       — StageService::reorder writes one entry on
                           the parent Event with the full new
                           stage_ids[] order.
  artist_engagement.
    status_changed /
    cancelled            — ArtistEngagementService::transitionStatus
                           emits one of these depending on the target
                           status; pairs with the auto-logged `updated`
                           row.

The remaining artist_engagement.option_expired event lands in Step 10
when the DemoteExpiredOptions command writes a system-causer entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:58:52 +02:00

234 lines
8.3 KiB
PHP

<?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);
if ($from === $to) {
return $engagement;
}
$this->validateTransition($from, $to, $engagement);
$engagement->booking_status = $to;
$engagement->save();
// RFC §8 — explicit `status_changed` audit event, separate from
// the auto-logged `updated` row that captures the diff. Pairs
// with the `cancelled` and `option_expired` events emitted by
// cancel() and the DemoteExpiredOptions command respectively.
activity('artist_engagement')
->performedOn($engagement)
->event($to === ArtistEngagementStatus::Cancelled ? 'cancelled' : 'status_changed')
->withProperties([
'from' => $from->value,
'to' => $to->value,
'organisation_id' => $engagement->organisation_id,
'event_id' => $engagement->event_id,
])
->log($to === ArtistEngagementStatus::Cancelled ? 'cancelled' : 'status_changed');
return $engagement;
}
/**
* 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);
}
}