Files
crewli/api/app/Console/Commands/Artist/DemoteExpiredOptions.php
bert.hausmans 609280d061 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>
2026-05-08 20:59:39 +02:00

80 lines
2.9 KiB
PHP

<?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;
}
}