Files
crewli/api/app/Services/Artist/LaneCascadeService.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

168 lines
6.1 KiB
PHP

<?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();
$moved = $locked->refresh();
// RFC §8 — single parent activity entry summarising the
// cascade. The per-row updates (lane bumps) still flow
// through the model's auto-log; this entry is the audit
// anchor for the whole transactional move.
activity('performance')
->performedOn($moved)
->event('moved')
->withProperties([
'cascade_count' => count($cascaded),
'cascaded_ids' => array_map(fn ($p): string => (string) $p->id, $cascaded),
'event_id' => $moved->event_id,
'organisation_id' => $moved->engagement?->organisation_id,
])
->log('moved');
return new MoveResult($moved, $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;
}
}