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; } }