feat(timetable): activity log integration per RFC §8
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user