RFC-TIMETABLE v0.2 Session 2 — Backend API + business logic #16

Merged
bert.hausmans merged 16 commits from feat/timetable-session-2 into main 2026-05-08 21:57:00 +02:00
2 changed files with 86 additions and 0 deletions
Showing only changes of commit 609280d061 - Show all commits

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Models\ArtistEngagement;
use App\Models\Scopes\OrganisationScope;
use App\Services\Artist\ArtistEngagementService;
use Illuminate\Console\Command;
/**
* RFC v0.2 daily option-expiry demotion.
*
* Finds every engagement with booking_status = Option whose
* option_expires_at has passed, transitions it to Draft via the state
* machine (which records the transition activity entry), and writes
* an additional `option_expired` activity event so the audit log can
* distinguish system-driven expiries from manual demotions.
*
* Idempotency: the state machine returns immediately when the
* engagement is no longer in Option (e.g. another run already
* demoted it), so a second run within the same minute is a no-op
* for any given engagement.
*
* Notification: notification framework lands post-Accreditation. For
* Session 2 the command writes activity log only; emailing the
* project leader is tracked under BACKLOG entry
* ART-DEMOTE-NOTIFICATION.
*/
final class DemoteExpiredOptions extends Command
{
protected $signature = 'artist:demote-expired-options';
protected $description = 'Demote ArtistEngagement rows whose option_expires_at has passed back to Draft.';
public function handle(ArtistEngagementService $service): int
{
$expired = ArtistEngagement::query()
->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;
}
}

View File

@@ -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.