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) <noreply@anthropic.com>
This commit is contained in:
79
api/app/Console/Commands/Artist/DemoteExpiredOptions.php
Normal file
79
api/app/Console/Commands/Artist/DemoteExpiredOptions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user