feat(timetable): observers — engagement denorm/guard + performance version bump

ArtistEngagementObserver:
- creating: auto-fills organisation_id from parent Artist (RFC v0.2 D10
  denormalisation), asserts artist.organisation_id == event.organisation_id;
  cross-tenant linkage throws CrossTenantEngagementException (extends
  DomainException, included in this commit).
- saving: no-op marker reserved for Session 2 state-machine validation.
- deleted: cascades soft-delete to Performance children, hard-deletes
  AdvanceSection children. AdvanceSubmission rows are immutable per
  RFC §5.4 and remain attached.

PerformanceObserver:
- saving: increments version by 1 on UPDATE only (D14 optimistic lock).
  MoveTimetablePerformanceRequest in Session 2 uses this for concurrent-
  edit detection.

Both observers registered in AppServiceProvider::boot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 18:01:42 +02:00
parent 9ccf1eaceb
commit 85ad45c7e9
4 changed files with 120 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Artist;
use App\Models\ArtistEngagement;
use DomainException;
/**
* Raised when an ArtistEngagement is being created with an artist and
* event that belong to different organisations. The engagement's
* `organisation_id` is denormalised from the artist (RFC v0.2 D10);
* the event must match. Cross-tenant linkage is a hard error fail
* loud rather than silently denormalise the wrong tenant.
*/
final class CrossTenantEngagementException extends DomainException
{
public static function forEngagement(ArtistEngagement $engagement): self
{
return new self(sprintf(
'ArtistEngagement cross-tenant: artist=%s (org=%s) vs event=%s (org=%s).',
$engagement->artist_id ?? 'null',
$engagement->artist?->organisation_id ?? 'null',
$engagement->event_id ?? 'null',
$engagement->event?->organisation_id ?? 'null',
));
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Exceptions\Artist\CrossTenantEngagementException;
use App\Models\ArtistEngagement;
use App\Models\Scopes\OrganisationScope;
/**
* Observer for ArtistEngagement.
*
* On `creating`: denormalises `organisation_id` from the parent Artist
* (RFC v0.2 D10) and asserts artist.organisation_id === event.organisation_id.
*
* On `deleted` (soft delete): cascade soft-delete to child Performance
* rows; hard-delete child AdvanceSection rows (RFC §5.4 sections have
* no soft delete). Child AdvanceSubmission rows are immutable audit
* records and remain attached to their (now-deleted) section reference
* by FK this is the audit-retention compliance path.
*/
final class ArtistEngagementObserver
{
public function creating(ArtistEngagement $engagement): void
{
$artist = $engagement->artist()->withoutGlobalScope(OrganisationScope::class)->first();
$event = $engagement->event()->withoutGlobalScope(OrganisationScope::class)->first();
if ($artist === null || $event === null) {
return;
}
if ($engagement->organisation_id === null) {
$engagement->organisation_id = $artist->organisation_id;
}
if ($artist->organisation_id !== $event->organisation_id) {
$engagement->setRelation('artist', $artist);
$engagement->setRelation('event', $event);
throw CrossTenantEngagementException::forEngagement($engagement);
}
}
/**
* Reserved for Session 2 state-machine validation when
* `booking_status` transitions land. No-op for now.
*/
public function saving(ArtistEngagement $engagement): void
{
// intentionally empty — see class docblock
}
public function deleted(ArtistEngagement $engagement): void
{
if (! $engagement->isForceDeleting() && $engagement->trashed()) {
$engagement->performances()->delete();
$engagement->advanceSections()->delete();
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\Performance;
/**
* Observer for Performance.
*
* Increments the optimistic-lock `version` column by 1 on every UPDATE
* (not on INSERT). RFC v0.2 D14 `MoveTimetablePerformanceRequest`
* (Session 2) compares the client-supplied version against the stored
* value to detect concurrent edits.
*/
final class PerformanceObserver
{
public function saving(Performance $performance): void
{
if ($performance->exists && $performance->isDirty()) {
$performance->version = (int) $performance->version + 1;
}
}
}

View File

@@ -163,6 +163,11 @@ class AppServiceProvider extends ServiceProvider
FormField::observe(FormFieldChildTablesCascadeObserver::class);
FormFieldLibrary::observe(FormFieldChildTablesCascadeObserver::class);
// RFC-TIMETABLE v0.2 — engagement denorm + cross-tenant guard,
// performance optimistic-lock bump.
\App\Models\ArtistEngagement::observe(\App\Observers\ArtistEngagementObserver::class);
\App\Models\Performance::observe(\App\Observers\PerformanceObserver::class);
// RFC-WS-6 v1.3 §Q1 — FormSubmissionSubmitted listener layout.
//
// SYNC chain (single listener):