feat(actions): add ScheduledAction model and migration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-12 12:24:20 +00:00
parent 8d0b2b64ec
commit ace48d57c2
3 changed files with 222 additions and 0 deletions

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('scheduled_actions', function (Blueprint $table) {
$table->id();
$table->string('action_class')->unique();
$table->string('frequency', 100);
$table->string('timezone', 50)->nullable();
$table->boolean('without_overlapping')->default(true);
$table->boolean('run_in_background')->default(true);
$table->boolean('is_enabled')->default(true);
$table->timestamp('last_run_at')->nullable();
$table->timestamp('next_run_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('scheduled_actions');
}
};

View file

@ -0,0 +1,100 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Actions;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* Represents a scheduled action persisted in the database.
*
* @property int $id
* @property string $action_class
* @property string $frequency
* @property string|null $timezone
* @property bool $without_overlapping
* @property bool $run_in_background
* @property bool $is_enabled
* @property \Illuminate\Support\Carbon|null $last_run_at
* @property \Illuminate\Support\Carbon|null $next_run_at
* @property \Illuminate\Support\Carbon $created_at
* @property \Illuminate\Support\Carbon $updated_at
*/
class ScheduledAction extends Model
{
protected $fillable = [
'action_class',
'frequency',
'timezone',
'without_overlapping',
'run_in_background',
'is_enabled',
'last_run_at',
'next_run_at',
];
protected function casts(): array
{
return [
'without_overlapping' => 'boolean',
'run_in_background' => 'boolean',
'is_enabled' => 'boolean',
'last_run_at' => 'datetime',
'next_run_at' => 'datetime',
];
}
/**
* Scope to only enabled actions.
*/
public function scopeEnabled(Builder $query): Builder
{
return $query->where('is_enabled', true);
}
/**
* Parse the frequency string and return the method name.
*
* 'dailyAt:09:00' 'dailyAt'
* 'everyMinute' 'everyMinute'
*/
public function frequencyMethod(): string
{
return explode(':', $this->frequency, 2)[0];
}
/**
* Parse the frequency string and return the arguments.
*
* 'dailyAt:09:00' ['09:00']
* 'weeklyOn:1,09:00' ['1', '09:00']
* 'everyMinute' []
*/
public function frequencyArgs(): array
{
$parts = explode(':', $this->frequency, 2);
if (! isset($parts[1])) {
return [];
}
return explode(',', $parts[1]);
}
/**
* Record that this action has just run.
*/
public function markRun(): void
{
$this->update(['last_run_at' => now()]);
}
}

View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Core\Tests\Feature;
use Core\Actions\ScheduledAction;
use Core\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ScheduledActionModelTest extends TestCase
{
use RefreshDatabase;
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__.'/../../database/migrations');
}
public function test_model_can_be_created(): void
{
$action = ScheduledAction::create([
'action_class' => 'App\\Actions\\TestAction',
'frequency' => 'dailyAt:09:00',
'timezone' => 'Europe/London',
'without_overlapping' => true,
'run_in_background' => true,
'is_enabled' => true,
]);
$this->assertDatabaseHas('scheduled_actions', [
'action_class' => 'App\\Actions\\TestAction',
'frequency' => 'dailyAt:09:00',
]);
}
public function test_enabled_scope(): void
{
ScheduledAction::create([
'action_class' => 'App\\Actions\\Enabled',
'frequency' => 'hourly',
'is_enabled' => true,
]);
ScheduledAction::create([
'action_class' => 'App\\Actions\\Disabled',
'frequency' => 'hourly',
'is_enabled' => false,
]);
$enabled = ScheduledAction::enabled()->get();
$this->assertCount(1, $enabled);
$this->assertSame('App\\Actions\\Enabled', $enabled->first()->action_class);
}
public function test_frequency_method_parses_simple_frequency(): void
{
$action = new ScheduledAction(['frequency' => 'everyMinute']);
$this->assertSame('everyMinute', $action->frequencyMethod());
$this->assertSame([], $action->frequencyArgs());
}
public function test_frequency_method_parses_frequency_with_args(): void
{
$action = new ScheduledAction(['frequency' => 'dailyAt:09:00']);
$this->assertSame('dailyAt', $action->frequencyMethod());
$this->assertSame(['09:00'], $action->frequencyArgs());
}
public function test_frequency_method_parses_multiple_args(): void
{
$action = new ScheduledAction(['frequency' => 'weeklyOn:1,09:00']);
$this->assertSame('weeklyOn', $action->frequencyMethod());
$this->assertSame(['1', '09:00'], $action->frequencyArgs());
}
public function test_mark_run_updates_last_run_at(): void
{
$action = ScheduledAction::create([
'action_class' => 'App\\Actions\\Runnable',
'frequency' => 'hourly',
'is_enabled' => true,
]);
$this->assertNull($action->last_run_at);
$action->markRun();
$action->refresh();
$this->assertNotNull($action->last_run_at);
}
}