From 0f9d0bdb4e1c1661de5f54130108622f87a1c90c Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 20:58:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(timetable):=20activity=20log=20integration?= =?UTF-8?q?=20per=20RFC=20=C2=A78?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LogOptions on Artist, ArtistEngagement, Stage, Performance, Genre now list the specific attributes the audit log captures (per §8 last paragraph) instead of logFillable. Each model gets a distinct log_name (artist / artist_engagement / stage / performance / genre) so the activity-log filter can scope queries by domain. tapActivity() on every model adds organisation_id (and event_id where relevant) to the activity entry's properties. The audit-log filter in the SPA can then query `->where('properties->event_id', $event->id)` without joining through multiple subject types. Performance gets dontLogIfAttributesChangedOnly(['updated_at', 'version']) so the bookkeeping touch from PerformanceObserver doesn't generate noise when nothing user-meaningful changed. Custom activity events emitted by services for the cases where the auto-log can't infer intent: performance.moved — LaneCascadeService::move writes a single parent entry with cascade_count and cascaded_ids[] after the cascade-bump commits. Per-row updates still flow through the model trait so the audit log shows both the summary and the diffs. stage.day_added / stage.day_removed — StageDayService::replaceDays writes one entry per added/removed event_id, performed on the parent Stage so the log groups by stage rather than by pivot row. stage.reordered — StageService::reorder writes one entry on the parent Event with the full new stage_ids[] order. artist_engagement. status_changed / cancelled — ArtistEngagementService::transitionStatus emits one of these depending on the target status; pairs with the auto-logged `updated` row. The remaining artist_engagement.option_expired event lands in Step 10 when the DemoteExpiredOptions command writes a system-causer entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/app/Models/Artist.php | 13 ++++++++-- api/app/Models/ArtistEngagement.php | 22 ++++++++++++++-- api/app/Models/Genre.php | 13 ++++++++-- api/app/Models/Performance.php | 18 +++++++++++-- api/app/Models/Stage.php | 14 ++++++++-- .../Artist/ArtistEngagementService.php | 19 ++++++++++++++ .../Services/Artist/LaneCascadeService.php | 19 +++++++++++++- api/app/Services/Artist/StageDayService.php | 26 +++++++++++++++++++ api/app/Services/Artist/StageService.php | 10 +++++++ 9 files changed, 143 insertions(+), 11 deletions(-) diff --git a/api/app/Models/Artist.php b/api/app/Models/Artist.php index f16e31a2..20eb7d1f 100644 --- a/api/app/Models/Artist.php +++ b/api/app/Models/Artist.php @@ -56,8 +56,17 @@ final class Artist extends Model public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() - ->logFillable() - ->dontLogEmptyChanges(); + ->logOnly(['name', 'slug', 'default_genre_id', 'default_draw', 'agent_company_id']) + ->logOnlyDirty() + ->dontLogIfAttributesChangedOnly(['updated_at']) + ->useLogName('artist'); + } + + public function tapActivity(Activity $activity, string $eventName): void + { + $properties = $activity->properties?->toArray() ?? []; + $properties['organisation_id'] = $this->organisation_id; + $activity->properties = collect($properties); } private function generateUniqueSlug(string $name): string diff --git a/api/app/Models/ArtistEngagement.php b/api/app/Models/ArtistEngagement.php index 7849de8e..bdde42e4 100644 --- a/api/app/Models/ArtistEngagement.php +++ b/api/app/Models/ArtistEngagement.php @@ -87,8 +87,26 @@ final class ArtistEngagement extends Model public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() - ->logFillable() - ->dontLogEmptyChanges(); + ->logOnly([ + 'booking_status', + 'fee_amount', 'fee_currency', 'fee_type', + 'buma_applicable', 'buma_percentage', 'buma_handled_by', + 'vat_applicable', 'vat_percentage', + 'project_leader_id', + 'option_expires_at', + 'payment_status', + ]) + ->logOnlyDirty() + ->dontLogIfAttributesChangedOnly(['updated_at']) + ->useLogName('artist_engagement'); + } + + public function tapActivity(Activity $activity, string $eventName): void + { + $properties = $activity->properties?->toArray() ?? []; + $properties['organisation_id'] = $this->organisation_id; + $properties['event_id'] = $this->event_id; + $activity->properties = collect($properties); } public function organisation(): BelongsTo diff --git a/api/app/Models/Genre.php b/api/app/Models/Genre.php index 076b27db..2e5757d7 100644 --- a/api/app/Models/Genre.php +++ b/api/app/Models/Genre.php @@ -43,8 +43,17 @@ final class Genre extends Model public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() - ->logFillable() - ->dontLogEmptyChanges(); + ->logOnly(['name', 'color', 'is_active', 'sort_order']) + ->logOnlyDirty() + ->dontLogIfAttributesChangedOnly(['updated_at']) + ->useLogName('genre'); + } + + public function tapActivity(Activity $activity, string $eventName): void + { + $properties = $activity->properties?->toArray() ?? []; + $properties['organisation_id'] = $this->organisation_id; + $activity->properties = collect($properties); } public function organisation(): BelongsTo diff --git a/api/app/Models/Performance.php b/api/app/Models/Performance.php index 5b16927e..36ac2153 100644 --- a/api/app/Models/Performance.php +++ b/api/app/Models/Performance.php @@ -55,8 +55,22 @@ final class Performance extends Model public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() - ->logFillable() - ->dontLogEmptyChanges(); + ->logOnly(['start_at', 'end_at', 'stage_id', 'lane', 'notes']) + ->logOnlyDirty() + // Performance.version is bumped by PerformanceObserver on every + // dirty save. Skip the auto-log when *only* updated_at + version + // moved — those rows correspond to bookkeeping touches, not + // user-meaningful changes (D14). + ->dontLogIfAttributesChangedOnly(['updated_at', 'version']) + ->useLogName('performance'); + } + + public function tapActivity(Activity $activity, string $eventName): void + { + $properties = $activity->properties?->toArray() ?? []; + $properties['event_id'] = $this->event_id; + $properties['organisation_id'] = $this->engagement?->organisation_id; + $activity->properties = collect($properties); } public function engagement(): BelongsTo diff --git a/api/app/Models/Stage.php b/api/app/Models/Stage.php index 539421e8..cbf8fdb5 100644 --- a/api/app/Models/Stage.php +++ b/api/app/Models/Stage.php @@ -50,8 +50,18 @@ final class Stage extends Model public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() - ->logFillable() - ->dontLogEmptyChanges(); + ->logOnly(['name', 'color', 'capacity', 'sort_order']) + ->logOnlyDirty() + ->dontLogIfAttributesChangedOnly(['updated_at']) + ->useLogName('stage'); + } + + public function tapActivity(Activity $activity, string $eventName): void + { + $properties = $activity->properties?->toArray() ?? []; + $properties['event_id'] = $this->event_id; + $properties['organisation_id'] = $this->event?->organisation_id; + $activity->properties = collect($properties); } public function event(): BelongsTo diff --git a/api/app/Services/Artist/ArtistEngagementService.php b/api/app/Services/Artist/ArtistEngagementService.php index f9215654..b1e15dca 100644 --- a/api/app/Services/Artist/ArtistEngagementService.php +++ b/api/app/Services/Artist/ArtistEngagementService.php @@ -103,11 +103,30 @@ final class ArtistEngagementService ): ArtistEngagement { $from = $this->coerceStatus($engagement->booking_status); + if ($from === $to) { + return $engagement; + } + $this->validateTransition($from, $to, $engagement); $engagement->booking_status = $to; $engagement->save(); + // RFC §8 — explicit `status_changed` audit event, separate from + // the auto-logged `updated` row that captures the diff. Pairs + // with the `cancelled` and `option_expired` events emitted by + // cancel() and the DemoteExpiredOptions command respectively. + activity('artist_engagement') + ->performedOn($engagement) + ->event($to === ArtistEngagementStatus::Cancelled ? 'cancelled' : 'status_changed') + ->withProperties([ + 'from' => $from->value, + 'to' => $to->value, + 'organisation_id' => $engagement->organisation_id, + 'event_id' => $engagement->event_id, + ]) + ->log($to === ArtistEngagementStatus::Cancelled ? 'cancelled' : 'status_changed'); + return $engagement; } diff --git a/api/app/Services/Artist/LaneCascadeService.php b/api/app/Services/Artist/LaneCascadeService.php index a55b14c4..d759325f 100644 --- a/api/app/Services/Artist/LaneCascadeService.php +++ b/api/app/Services/Artist/LaneCascadeService.php @@ -116,7 +116,24 @@ final class LaneCascadeService $locked->lane = $targetLane; $locked->save(); - return new MoveResult($locked->refresh(), $cascaded); + $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. + 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); }); } diff --git a/api/app/Services/Artist/StageDayService.php b/api/app/Services/Artist/StageDayService.php index 92fdf4cf..7a5d2fb2 100644 --- a/api/app/Services/Artist/StageDayService.php +++ b/api/app/Services/Artist/StageDayService.php @@ -67,6 +67,32 @@ final class StageDayService ]); } + // RFC §8 — one activity entry per added/removed event_id, + // performed-on the parent stage so the audit log groups + // changes per stage rather than per pivot row. + $stage->loadMissing('event'); + $organisationId = $stage->event?->organisation_id; + foreach ($added as $eventId) { + activity('stage') + ->performedOn($stage) + ->event('day_added') + ->withProperties([ + 'event_id' => $eventId, + 'organisation_id' => $organisationId, + ]) + ->log('day_added'); + } + foreach ($removed as $eventId) { + activity('stage') + ->performedOn($stage) + ->event('day_removed') + ->withProperties([ + 'event_id' => $eventId, + 'organisation_id' => $organisationId, + ]) + ->log('day_removed'); + } + return ['added' => $added, 'removed' => $removed]; }); } diff --git a/api/app/Services/Artist/StageService.php b/api/app/Services/Artist/StageService.php index e96d8d15..f4649461 100644 --- a/api/app/Services/Artist/StageService.php +++ b/api/app/Services/Artist/StageService.php @@ -79,6 +79,16 @@ final class StageService ->where('id', $stageId) ->update(['sort_order' => $position]); } + + activity('stage') + ->on($event) + ->event('reordered') + ->withProperties([ + 'event_id' => $event->id, + 'organisation_id' => $event->organisation_id, + 'stage_ids' => $orderedStageIds, + ]) + ->log('reordered'); }); } }