RFC-TIMETABLE v0.2 Session 2 — Backend API + business logic #16

Merged
bert.hausmans merged 16 commits from feat/timetable-session-2 into main 2026-05-08 21:57:00 +02:00
2 changed files with 101 additions and 5 deletions
Showing only changes of commit bc7d3fcbee - Show all commits

View File

@@ -74,9 +74,21 @@ final class LaneCascadeService
if ($targetLane !== null) {
$locked->lane = $targetLane;
}
$locked->disableLogging(); // suppress auto-log; park is captured in explicit entry below
$locked->save();
return new MoveResult($locked->refresh(), []);
$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) {
@@ -104,6 +116,7 @@ final class LaneCascadeService
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();
}
@@ -114,14 +127,15 @@ final class LaneCascadeService
$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. 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.
// 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')

View File

@@ -16,6 +16,7 @@ use App\Services\Artist\LaneCascadeService;
use Carbon\CarbonImmutable;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;
final class LaneCascadeServiceTest extends TestCase
@@ -184,4 +185,85 @@ final class LaneCascadeServiceTest extends TestCase
$this->assertSame((string) $this->stage->id, (string) $result->moved->stage_id);
}
public function test_move_with_cascade_writes_exactly_one_activity_entry_on_moved_subject_and_zero_on_peers(): void
{
$start = CarbonImmutable::now()->addDays(5)->setTime(20, 0);
$p2 = Performance::factory()->create([
'engagement_id' => $this->engagement->id,
'event_id' => $this->event->id,
'stage_id' => $this->stage->id,
'lane' => 0,
'start_at' => $start,
'end_at' => $start->addHour(),
'version' => 0,
]);
$p1 = Performance::factory()->create([
'engagement_id' => $this->engagement->id,
'event_id' => $this->event->id,
'stage_id' => $this->stage->id,
'lane' => 1,
'start_at' => $start,
'end_at' => $start->addHour(),
'version' => 0,
]);
// Clear setup-time activity rows so we measure only the move.
Activity::query()->delete();
$this->service->move(
performance: $p1,
targetStage: $this->stage,
start: $start,
end: $start->addHour(),
targetLane: 0,
clientVersion: 0,
);
$movedEntries = Activity::query()
->where('subject_type', $p1->getMorphClass())
->where('subject_id', $p1->id)
->get();
$this->assertCount(1, $movedEntries, 'Expected exactly one activity entry for moved performance');
$this->assertSame('moved', $movedEntries->first()->event);
$this->assertSame(1, $movedEntries->first()->properties['cascade_count']);
$this->assertContains((string) $p2->id, $movedEntries->first()->properties['cascaded_ids']);
$cascadedEntries = Activity::query()
->where('subject_type', $p2->getMorphClass())
->where('subject_id', $p2->id)
->get();
$this->assertCount(0, $cascadedEntries, 'Expected zero activity entries on cascade-bumped peer');
}
public function test_park_writes_single_parked_activity_entry(): void
{
$perf = Performance::factory()->create([
'engagement_id' => $this->engagement->id,
'event_id' => $this->event->id,
'stage_id' => $this->stage->id,
'lane' => 0,
'version' => 0,
]);
Activity::query()->delete();
$this->service->move(
performance: $perf,
targetStage: null,
start: null,
end: null,
targetLane: null,
clientVersion: 0,
);
$entries = Activity::query()
->where('subject_type', $perf->getMorphClass())
->where('subject_id', $perf->id)
->get();
$this->assertCount(1, $entries, 'Expected exactly one parked entry');
$this->assertSame('parked', $entries->first()->event);
}
}