LaneCascadeService::move() now calls disableLogging() before every
save inside the transaction (locked performance + cascade-bumped
peers + park-path). The two explicit activity('performance')
->event('moved'|'parked') entries with cascade_count + cascaded_ids
properties are the only audit records per move, matching RFC §8's
"single parent entry summarising the cascade" requirement.
Park path additionally writes an explicit 'performance.parked'
entry per RFC §8 vocabulary instead of falling back to a generic
'updated' auto-log entry.
Two new tests verify:
- cascade move with N peers produces exactly 1 activity entry on
the moved subject and 0 on each cascade-bumped peer
- park writes exactly 1 'parked' entry
PerformanceObserver::saving (version bump) is unaffected:
disableLogging() suppresses only the activity log trait, not
Eloquent model events.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
182 lines
6.8 KiB
PHP
182 lines
6.8 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->disableLogging(); // suppress auto-log; park is captured in explicit entry below
|
|
$locked->save();
|
|
|
|
$parked = $locked->refresh();
|
|
|
|
activity('performance')
|
|
->performedOn($parked)
|
|
->event('parked')
|
|
->withProperties([
|
|
'event_id' => $parked->event_id,
|
|
'organisation_id' => $parked->engagement?->organisation_id,
|
|
])
|
|
->log('parked');
|
|
|
|
return new MoveResult($parked, []);
|
|
}
|
|
|
|
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->disableLogging(); // suppress auto-log; cascade is captured in parent entry
|
|
$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->disableLogging(); // suppress auto-log; move is captured in explicit entry below
|
|
$locked->save();
|
|
|
|
$moved = $locked->refresh();
|
|
|
|
// RFC §8 — single parent activity entry summarising the cascade.
|
|
// All saves inside this transaction call disableLogging() so the
|
|
// auto-log trait does not write per-row 'updated' entries; this
|
|
// explicit entry is the only audit record for the 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_date', '<=', $start->toDateString())
|
|
->where('events.end_date', '>=', $start->toDateString())
|
|
->orderBy('events.start_date', '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;
|
|
}
|
|
}
|