From 85ad45c7e971b63a89f0a1274823139f436d0b25 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 18:01:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(timetable):=20observers=20=E2=80=94=20enga?= =?UTF-8?q?gement=20denorm/guard=20+=20performance=20version=20bump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Artist/CrossTenantEngagementException.php | 29 +++++++++ .../Observers/ArtistEngagementObserver.php | 61 +++++++++++++++++++ api/app/Observers/PerformanceObserver.php | 25 ++++++++ api/app/Providers/AppServiceProvider.php | 5 ++ 4 files changed, 120 insertions(+) create mode 100644 api/app/Exceptions/Artist/CrossTenantEngagementException.php create mode 100644 api/app/Observers/ArtistEngagementObserver.php create mode 100644 api/app/Observers/PerformanceObserver.php diff --git a/api/app/Exceptions/Artist/CrossTenantEngagementException.php b/api/app/Exceptions/Artist/CrossTenantEngagementException.php new file mode 100644 index 00000000..bf98e3ff --- /dev/null +++ b/api/app/Exceptions/Artist/CrossTenantEngagementException.php @@ -0,0 +1,29 @@ +artist_id ?? 'null', + $engagement->artist?->organisation_id ?? 'null', + $engagement->event_id ?? 'null', + $engagement->event?->organisation_id ?? 'null', + )); + } +} diff --git a/api/app/Observers/ArtistEngagementObserver.php b/api/app/Observers/ArtistEngagementObserver.php new file mode 100644 index 00000000..43a3995d --- /dev/null +++ b/api/app/Observers/ArtistEngagementObserver.php @@ -0,0 +1,61 @@ +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(); + } + } +} diff --git a/api/app/Observers/PerformanceObserver.php b/api/app/Observers/PerformanceObserver.php new file mode 100644 index 00000000..cc3249d1 --- /dev/null +++ b/api/app/Observers/PerformanceObserver.php @@ -0,0 +1,25 @@ +exists && $performance->isDirty()) { + $performance->version = (int) $performance->version + 1; + } + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index a0718ada..0aaf16ad 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -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):