From 609280d061b08b0676df8320f21f12e1d1a80725 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 20:59:39 +0200 Subject: [PATCH] feat(timetable): DemoteExpiredOptions scheduled command `artist:demote-expired-options` artisan command finds every ArtistEngagement still in Option whose option_expires_at has passed, transitions it back to Draft via the existing state-machine (transitionStatus), and writes an `option_expired` activity entry with the original expiry timestamp captured in properties so the audit log distinguishes system-driven expiries from manual demotions. Idempotency: the state-machine bails when the engagement is no longer in Option, so a second run within the same minute is a no-op for any given row. The auto-logged `updated` row + the explicit `status_changed` + the `option_expired` entries are emitted only by the run that actually performs the transition. Scheduled in routes/console.php daily at 03:00 Europe/Amsterdam, matching the existing nightly low-traffic window. Notification (email project leader on demotion) is deferred to the notification framework that lands post-Accreditation; tracked under BACKLOG entry ART-DEMOTE-NOTIFICATION. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Commands/Artist/DemoteExpiredOptions.php | 79 +++++++++++++++++++ api/routes/console.php | 7 ++ 2 files changed, 86 insertions(+) create mode 100644 api/app/Console/Commands/Artist/DemoteExpiredOptions.php diff --git a/api/app/Console/Commands/Artist/DemoteExpiredOptions.php b/api/app/Console/Commands/Artist/DemoteExpiredOptions.php new file mode 100644 index 00000000..a3d7f8eb --- /dev/null +++ b/api/app/Console/Commands/Artist/DemoteExpiredOptions.php @@ -0,0 +1,79 @@ +withoutGlobalScope(OrganisationScope::class) + ->where('booking_status', ArtistEngagementStatus::Option->value) + ->whereNotNull('option_expires_at') + ->where('option_expires_at', '<=', now()) + ->whereNull('deleted_at') + ->get(); + + $demotedIds = []; + foreach ($expired as $engagement) { + // Re-check status under fresh state — another worker / a + // user UI action may have already transitioned this row. + if ($engagement->booking_status !== ArtistEngagementStatus::Option) { + continue; + } + + $service->transitionStatus($engagement, ArtistEngagementStatus::Draft); + + activity('artist_engagement') + ->performedOn($engagement) + ->event('option_expired') + ->withProperties([ + 'organisation_id' => $engagement->organisation_id, + 'event_id' => $engagement->event_id, + 'option_expires_at' => optional($engagement->option_expires_at)->toIso8601String(), + ]) + ->log('option_expired'); + + $demotedIds[] = (string) $engagement->id; + } + + $count = count($demotedIds); + $this->info("Demoted {$count} option(s) on ".now()->toDateString().'.'); + if ($count > 0) { + $this->line('IDs: '.implode(', ', $demotedIds)); + } + + return self::SUCCESS; + } +} diff --git a/api/routes/console.php b/api/routes/console.php index 2713d22a..ee2ff4bf 100644 --- a/api/routes/console.php +++ b/api/routes/console.php @@ -10,6 +10,13 @@ Artisan::command('inspire', function () { Schedule::command('invitations:expire')->daily(); +// RFC-TIMETABLE v0.2 — demote engagements whose option_expires_at has +// passed back to Draft. Daily at 03:00 Europe/Amsterdam (matches the +// scheduler's nightly window for low-traffic state changes). +Schedule::command('artist:demote-expired-options') + ->dailyAt('03:00') + ->timezone('Europe/Amsterdam'); + // Telescope retention — dev-only (mirrors AppServiceProvider's // environment gate). 48h is enough for debugging without filling the // dev database.