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>
168 lines
6.1 KiB
PHP
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;
|
|
}
|
|
}
|