RFC-TIMETABLE v0.2 Session 1 — Artist Timetable foundation #15
29
api/app/Exceptions/Artist/CrossTenantEngagementException.php
Normal file
29
api/app/Exceptions/Artist/CrossTenantEngagementException.php
Normal 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',
|
||||
));
|
||||
}
|
||||
}
|
||||
61
api/app/Observers/ArtistEngagementObserver.php
Normal file
61
api/app/Observers/ArtistEngagementObserver.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
25
api/app/Observers/PerformanceObserver.php
Normal file
25
api/app/Observers/PerformanceObserver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user