move into sonardesk

This commit is contained in:
Mark Gregory
2026-10-12 14:50:13 +01:00
parent 5f0d2ceb72
commit 38b4a7406f
879 changed files with 24021 additions and 1 deletions
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'queue' => env('AUTOMATION_QUEUE', 'default'),
'max_steps' => (int) env('AUTOMATION_MAX_STEPS', 50),
'webhook_timeout' => (int) env('AUTOMATION_WEBHOOK_TIMEOUT', 10),
'retain_runs_days' => (int) env('AUTOMATION_RETAIN_RUNS_DAYS', 90),
];
@@ -0,0 +1,30 @@
<?php
namespace Modules\Automation\Console;
use Illuminate\Console\Command;
use Modules\Automation\Models\Automation;
use Modules\Automation\Services\AutomationRunner;
final class DispatchScheduledAutomations extends Command
{
protected $signature = 'automation:dispatch-scheduled';
protected $description = 'Dispatch active automations whose next run time is due.';
public function handle(AutomationRunner $runner): int
{
$count = 0;
Automation::query()->where('status', 'active')->where('trigger_type', 'schedule')->whereNotNull('next_run_at')->where('next_run_at', '<=', now())->chunkById(100, function ($items) use ($runner, &$count) {
foreach ($items as $automation) {
$runner->dispatch($automation, ['scheduled_at' => now()->toIso8601String()], 'schedule');
$interval = max(1, (int) ($automation->trigger_config['interval_minutes'] ?? 60));
$automation->update(['next_run_at' => now()->addMinutes($interval)]);
$count++;
}
});
$this->info("Dispatched {$count} scheduled automation(s).");
return self::SUCCESS;
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Modules\Automation\Contracts;
use Modules\Automation\Models\AutomationStep;
interface ActionRegistry
{
public function all(): array;
public function execute(AutomationStep $step, array $payload): array;
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Modules\Automation\Contracts;
interface TriggerRegistry
{
public function all(): array;
public function has(string $type): bool;
}
@@ -0,0 +1,67 @@
<?php
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('automations', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->string('trigger_type')->index();
$table->json('trigger_config')->nullable();
$table->string('status', 20)->default('draft')->index();
$table->boolean('stop_on_failure')->default(true);
$table->unsignedInteger('run_count')->default(0);
$table->timestamp('last_ran_at')->nullable();
$table->timestamp('next_run_at')->nullable()->index();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
});
Schema::create('automation_steps', function (Blueprint $table) {
$table->id();
$table->foreignId('automation_id')->constrained('automations')->cascadeOnDelete();
$table->unsignedSmallInteger('position')->default(1);
$table->string('action_type');
$table->string('name')->nullable();
$table->json('configuration')->nullable();
$table->boolean('is_enabled')->default(true);
$table->timestamps();
$table->unique(['automation_id', 'position']);
});
Schema::create('automation_runs', function (Blueprint $table) {
$table->id();
$table->foreignId('automation_id')->constrained('automations')->cascadeOnDelete();
$table->uuid('execution_id')->unique();
$table->string('trigger_type');
$table->json('payload')->nullable();
$table->string('status', 20)->default('queued')->index();
$table->unsignedSmallInteger('current_step')->default(0);
$table->text('error')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('finished_at')->nullable();
$table->timestamps();
});
Schema::create('automation_run_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('automation_run_id')->constrained('automation_runs')->cascadeOnDelete();
$table->foreignId('automation_step_id')->nullable()->constrained('automation_steps')->nullOnDelete();
$table->string('level', 20)->default('info');
$table->string('message');
$table->json('context')->nullable();
$table->timestamp('created_at')->useCurrent();
});
}
public function down(): void
{
Schema::dropIfExists('automation_run_logs');
Schema::dropIfExists('automation_runs');
Schema::dropIfExists('automation_steps');
Schema::dropIfExists('automations');
}
};
@@ -0,0 +1,24 @@
<?php
namespace Modules\Automation\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Automation\Models\Automation;
use Modules\Automation\Services\AutomationRunner;
final class AutomationApiController extends Controller
{
public function trigger(Request $request, Automation $automation, AutomationRunner $runner): JsonResponse
{
abort_unless($automation->trigger_type === 'webhook', 404);
$secret = (string) ($automation->trigger_config['secret'] ?? '');
if ($secret !== '') {
abort_unless(hash_equals($secret, (string) $request->header('X-Automation-Secret')), 401, 'Invalid automation secret.');
}
$run = $runner->dispatch($automation, $request->all(), 'webhook');
return response()->json(['data' => ['execution_id' => $run->execution_id, 'status' => $run->status]], 202);
}
}
@@ -0,0 +1,63 @@
<?php
namespace Modules\Automation\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Modules\Automation\Contracts\ActionRegistry;
use Modules\Automation\Contracts\TriggerRegistry;
use Modules\Automation\Models\Automation;
use Modules\Automation\Models\AutomationRun;
use Modules\Automation\Services\AutomationRunner;
final class AutomationController extends Controller
{
public function index(TriggerRegistry $triggers, ActionRegistry $actions): View
{
return view('automation::admin.index', ['automations' => Automation::withCount(['steps', 'runs'])->latest()->paginate(20), 'recentRuns' => AutomationRun::with('automation')->latest()->limit(15)->get(), 'triggers' => $triggers->all(), 'actions' => $actions->all()]);
}
public function store(Request $request, TriggerRegistry $triggers): RedirectResponse
{
$data = $request->validate(['name' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string'], 'trigger_type' => ['required', 'string'], 'status' => ['required', 'in:draft,active,paused'], 'next_run_at' => ['nullable', 'date'], 'trigger_config' => ['nullable', 'json']]);
abort_unless($triggers->has($data['trigger_type']), 422, 'Unknown trigger type.');
$data['trigger_config'] = filled($data['trigger_config'] ?? null) ? json_decode($data['trigger_config'], true, 512, JSON_THROW_ON_ERROR) : [];
$data['stop_on_failure'] = $request->boolean('stop_on_failure');
$data['created_by'] = $request->user()->id;
Automation::create($data);
return back()->with('status', 'Automation created.');
}
public function addStep(Request $request, Automation $automation, ActionRegistry $actions): RedirectResponse
{
$data = $request->validate(['action_type' => ['required', 'string'], 'name' => ['nullable', 'string', 'max:255'], 'configuration' => ['nullable', 'json']]);
abort_unless(array_key_exists($data['action_type'], $actions->all()), 422, 'Unknown action type.');
$automation->steps()->create(['position' => (int) $automation->steps()->max('position') + 1, 'action_type' => $data['action_type'], 'name' => $data['name'] ?? null, 'configuration' => filled($data['configuration'] ?? null) ? json_decode($data['configuration'], true, 512, JSON_THROW_ON_ERROR) : [], 'is_enabled' => true]);
return back()->with('status', 'Automation step added.');
}
public function run(Automation $automation, AutomationRunner $runner): RedirectResponse
{
$run = $runner->dispatch($automation, ['initiated_by' => request()->user()->id], 'manual');
return back()->with('status', "Automation queued as {$run->execution_id}.");
}
public function toggle(Automation $automation): RedirectResponse
{
$automation->update(['status' => $automation->status === 'active' ? 'paused' : 'active']);
return back()->with('status', 'Automation status updated.');
}
public function destroy(Automation $automation): RedirectResponse
{
$automation->delete();
return back()->with('status','Automation deleted.');
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Modules\Automation\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Modules\Automation\Contracts\ActionRegistry;
use Modules\Automation\Models\AutomationRun;
final class ExecuteAutomation implements ShouldQueue
{
use Dispatchable,InteractsWithQueue,Queueable,SerializesModels;
public function __construct(public readonly int $runId) {}
public function handle(ActionRegistry $actions): void
{
$run = AutomationRun::with('automation.steps')->findOrFail($this->runId);
$automation = $run->automation;
$run->update(['status' => 'running', 'started_at' => now()]);
try {
foreach ($automation->steps->where('is_enabled', true) as $step) {
$run->update(['current_step' => $step->position]);
try {
$result = $actions->execute($step, $run->payload ?? []);
$run->logs()->create(['automation_step_id' => $step->id, 'level' => 'info', 'message' => $step->name ?: $step->action_type.' completed', 'context' => $result, 'created_at' => now()]);
} catch (\Throwable $e) {
$run->logs()->create(['automation_step_id' => $step->id, 'level' => 'error', 'message' => $e->getMessage(), 'created_at' => now()]);
if ($automation->stop_on_failure) {
throw $e;
}
}
}
$run->update(['status' => 'completed', 'finished_at' => now()]);
} catch (\Throwable $e) {
$run->update(['status' => 'failed', 'error' => $e->getMessage(), 'finished_at' => now()]);
throw $e;
} finally {
$automation->increment('run_count');
$automation->update(['last_ran_at' => now()]);
}
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\Automation\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Automation extends Model
{
protected $fillable = ['name', 'description', 'trigger_type', 'trigger_config', 'status', 'stop_on_failure', 'run_count', 'last_ran_at', 'next_run_at', 'created_by'];
protected $casts = ['trigger_config' => 'array', 'stop_on_failure' => 'boolean', 'last_ran_at' => 'datetime', 'next_run_at' => 'datetime'];
public function steps(): HasMany
{
return $this->hasMany(AutomationStep::class)->orderBy('position');
}
public function runs(): HasMany
{
return $this->hasMany(AutomationRun::class);
}
public function isActive(): bool
{
return $this->status === 'active';
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Modules\Automation\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class AutomationRun extends Model
{
protected $fillable = ['automation_id', 'execution_id', 'trigger_type', 'payload', 'status', 'current_step', 'error', 'started_at', 'finished_at'];
protected $casts = ['payload' => 'array', 'started_at' => 'datetime', 'finished_at' => 'datetime'];
public function automation(): BelongsTo
{
return $this->belongsTo(Automation::class);
}
public function logs(): HasMany
{
return $this->hasMany(AutomationRunLog::class);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Modules\Automation\Models;
use Illuminate\Database\Eloquent\Model;
final class AutomationRunLog extends Model
{
public $timestamps = false;
protected $fillable = ['automation_run_id', 'automation_step_id', 'level', 'message', 'context', 'created_at'];
protected $casts = ['context' => 'array', 'created_at' => 'datetime'];
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Automation\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class AutomationStep extends Model
{
protected $fillable = ['automation_id', 'position', 'action_type', 'name', 'configuration', 'is_enabled'];
protected $casts = ['configuration' => 'array', 'is_enabled' => 'boolean'];
public function automation(): BelongsTo
{
return $this->belongsTo(Automation::class);
}
}
@@ -0,0 +1,30 @@
<?php
namespace Modules\Automation\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Automation\Console\DispatchScheduledAutomations;
use Modules\Automation\Contracts\ActionRegistry;
use Modules\Automation\Contracts\TriggerRegistry;
use Modules\Automation\Services\AutomationActionRegistry;
use Modules\Automation\Services\AutomationTriggerRegistry;
final class AutomationServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Automation', 'Config/config.php'), 'automation');
$this->app->singleton(ActionRegistry::class, AutomationActionRegistry::class);
$this->app->singleton(TriggerRegistry::class, AutomationTriggerRegistry::class);
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Automation', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Automation', 'Resources/views'), 'automation');
if ($this->app->runningInConsole()) {
$this->commands([DispatchScheduledAutomations::class]);
}
}
}
@@ -0,0 +1,15 @@
<?php
namespace Modules\Automation\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
Route::middleware('web')->group(module_path('Automation', 'Routes/web.php'));
Route::middleware('api')->prefix('api/v1')->group(module_path('Automation', 'Routes/api.php'));
}
}
@@ -0,0 +1,26 @@
<x-layouts.app :title="__('Automation')">
<div class="flex h-full w-full flex-1 flex-col gap-6 rounded-xl">
<div><flux:heading size="xl">Automation Platform</flux:heading><flux:text>Create event-driven, scheduled and webhook automations.</flux:text></div>
@if(session('status'))<flux:callout variant="success" icon="check-circle">{{ session('status') }}</flux:callout>@endif
<div class="grid gap-4 md:grid-cols-4">
<flux:card><flux:text>Automations</flux:text><flux:heading size="xl">{{ $automations->total() }}</flux:heading></flux:card>
<flux:card><flux:text>Active</flux:text><flux:heading size="xl">{{ \Modules\Automation\Models\Automation::where('status','active')->count() }}</flux:heading></flux:card>
<flux:card><flux:text>Completed runs</flux:text><flux:heading size="xl">{{ \Modules\Automation\Models\AutomationRun::where('status','completed')->count() }}</flux:heading></flux:card>
<flux:card><flux:text>Failed runs</flux:text><flux:heading size="xl">{{ \Modules\Automation\Models\AutomationRun::where('status','failed')->count() }}</flux:heading></flux:card>
</div>
@can('automation.manage')
<flux:card><flux:heading>Create automation</flux:heading><form method="POST" action="{{ route('administration.automation.store') }}" class="mt-4 grid gap-4 md:grid-cols-2">@csrf
<flux:input name="name" label="Name" required/><flux:select name="trigger_type" label="Trigger">@foreach($triggers as $key=>$trigger)<option value="{{ $key }}">{{ $trigger['label'] }}</option>@endforeach</flux:select>
<flux:textarea name="description" label="Description"/><flux:textarea name="trigger_config" label="Trigger configuration (JSON)" placeholder='{"interval_minutes":60}'/>
<flux:select name="status" label="Status"><option value="draft">Draft</option><option value="active">Active</option><option value="paused">Paused</option></flux:select><flux:input type="datetime-local" name="next_run_at" label="Next scheduled run"/>
<flux:checkbox name="stop_on_failure" value="1" checked label="Stop when a step fails"/><div><flux:button type="submit" variant="primary">Create automation</flux:button></div>
</form></flux:card>
@endcan
<div class="grid gap-6 xl:grid-cols-3"><div class="xl:col-span-2 space-y-4">
@forelse($automations as $automation)<flux:card><div class="flex items-start justify-between gap-4"><div><flux:heading>{{ $automation->name }}</flux:heading><flux:text>{{ $automation->trigger_type }} · {{ $automation->steps_count }} steps · {{ $automation->runs_count }} runs</flux:text></div><flux:badge color="{{ $automation->status==='active'?'green':($automation->status==='paused'?'amber':'zinc') }}">{{ ucfirst($automation->status) }}</flux:badge></div>
@if($automation->description)<flux:text class="mt-3">{{ $automation->description }}</flux:text>@endif
<div class="mt-4 flex flex-wrap gap-2">@can('automation.execute')<form method="POST" action="{{ route('administration.automation.run',$automation) }}">@csrf<flux:button type="submit" size="sm" icon="play">Run</flux:button></form>@endcan @can('automation.manage')<form method="POST" action="{{ route('administration.automation.toggle',$automation) }}">@csrf @method('PATCH')<flux:button type="submit" size="sm">{{ $automation->status==='active'?'Pause':'Activate' }}</flux:button></form><form method="POST" action="{{ route('administration.automation.destroy',$automation) }}">@csrf @method('DELETE')<flux:button type="submit" size="sm" variant="danger">Delete</flux:button></form>@endcan</div>
@can('automation.manage')<form method="POST" action="{{ route('administration.automation.steps.store',$automation) }}" class="mt-4 grid gap-3 md:grid-cols-3">@csrf<flux:select name="action_type" label="Add action">@foreach($actions as $key=>$action)<option value="{{ $key }}">{{ $action['label'] }}</option>@endforeach</flux:select><flux:input name="name" label="Step name"/><flux:textarea name="configuration" label="Configuration (JSON)" placeholder='{"message":"Done"}'/><div><flux:button type="submit" size="sm">Add step</flux:button></div></form>@endcan
</flux:card>@empty<flux:card><flux:text>No automations have been created.</flux:text></flux:card>@endforelse
{{ $automations->links() }}</div><div><flux:card><flux:heading>Recent runs</flux:heading><div class="mt-4 space-y-3">@forelse($recentRuns as $run)<div class="border-b border-zinc-200 pb-3 dark:border-zinc-700"><div class="flex justify-between gap-2"><flux:text class="font-medium">{{ $run->automation?->name }}</flux:text><flux:badge size="sm">{{ $run->status }}</flux:badge></div><flux:text size="sm">{{ $run->execution_id }}</flux:text></div>@empty<flux:text>No runs yet.</flux:text>@endforelse</div></flux:card></div></div>
</div></x-layouts.app>
+6
View File
@@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Automation\Http\Controllers\AutomationApiController;
Route::post('/automations/{automation}/trigger', [AutomationApiController::class, 'trigger'])->middleware('throttle:60,1')->name('api.automations.trigger');
+13
View File
@@ -0,0 +1,13 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Automation\Http\Controllers\AutomationController;
Route::prefix('administration/automation')->name('administration.automation.')->middleware(['auth', 'permission:automation.view'])->group(function () {
Route::get('/', [AutomationController::class, 'index'])->name('index');
Route::post('/', [AutomationController::class, 'store'])->middleware('permission:automation.manage')->name('store');
Route::post('/{automation}/steps', [AutomationController::class, 'addStep'])->middleware('permission:automation.manage')->name('steps.store');
Route::post('/{automation}/run', [AutomationController::class, 'run'])->middleware('permission:automation.execute')->name('run');
Route::patch('/{automation}/toggle', [AutomationController::class, 'toggle'])->middleware('permission:automation.manage')->name('toggle');
Route::delete('/{automation}', [AutomationController::class, 'destroy'])->middleware('permission:automation.manage')->name('destroy');
});
@@ -0,0 +1,79 @@
<?php
namespace Modules\Automation\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Modules\Automation\Contracts\ActionRegistry;
use Modules\Automation\Models\AutomationStep;
final class AutomationActionRegistry implements ActionRegistry
{
public function all(): array
{
return [
'log' => ['label' => 'Write log', 'fields' => ['message']],
'webhook' => ['label' => 'Send webhook', 'fields' => ['url', 'method']],
'email' => ['label' => 'Send email', 'fields' => ['to', 'subject', 'body']],
'delay' => ['label' => 'Delay', 'fields' => ['seconds']],
];
}
public function execute(AutomationStep $step, array $payload): array
{
$config = $step->configuration ?? [];
return match ($step->action_type) {
'log' => $this->log($config, $payload),
'webhook' => $this->webhook($config, $payload),
'email' => $this->email($config, $payload),
'delay' => $this->delay($config),
default => throw new \InvalidArgumentException("Unknown automation action [{$step->action_type}]."),
};
}
private function log(array $config, array $payload): array
{
$message = (string) ($config['message'] ?? 'Automation executed');
Log::info($message, $payload);
return ['message' => $message];
}
private function webhook(array $config, array $payload): array
{
$url = (string) ($config['url'] ?? '');
if ($url === '') {
throw new \InvalidArgumentException('Webhook URL is required.');
}
$method = strtolower((string) ($config['method'] ?? 'post'));
$response = Http::timeout(config('automation.webhook_timeout', 10))->send($method, $url, ['json' => $payload]);
$response->throw();
return ['status' => $response->status()];
}
private function email(array $config, array $payload): array
{
$to = (string) ($config['to'] ?? '');
if ($to === '') {
throw new \InvalidArgumentException('Email recipient is required.');
}
$subject = (string) ($config['subject'] ?? 'Automation notification');
$body = (string) ($config['body'] ?? json_encode($payload, JSON_PRETTY_PRINT));
Mail::raw($body, fn ($message) => $message->to($to)->subject($subject));
return ['to' => $to];
}
private function delay(array $config): array
{
$seconds = max(0, min(30, (int) ($config['seconds'] ?? 1)));
if ($seconds > 0) {
sleep($seconds);
}
return ['seconds' => $seconds];
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Modules\Automation\Services;
use Illuminate\Support\Str;
use Modules\Automation\Jobs\ExecuteAutomation;
use Modules\Automation\Models\Automation;
use Modules\Automation\Models\AutomationRun;
final class AutomationRunner
{
public function dispatch(Automation $automation, array $payload = [], ?string $triggerType = null): AutomationRun
{
abort_unless($automation->isActive(), 422, 'Automation is not active.');
$run = $automation->runs()->create(['execution_id' => (string) Str::uuid(), 'trigger_type' => $triggerType ?? $automation->trigger_type, 'payload' => $payload, 'status' => 'queued']);
ExecuteAutomation::dispatch($run->id)->onQueue(config('automation.queue', 'default'));
return $run;
}
public function dispatchMatching(string $triggerType, array $payload = [], array $constraints = []): int
{
$query = Automation::query()->where('status', 'active')->where('trigger_type', $triggerType);
$count = 0;
foreach ($query->get() as $automation) {
if ($this->matches($automation, $constraints)) {
$this->dispatch($automation, $payload, $triggerType);
$count++;
}
}
return $count;
}
private function matches(Automation $automation, array $constraints): bool
{
$config = $automation->trigger_config ?? [];
foreach ($constraints as $key => $value) {
if (isset($config[$key]) && (string) $config[$key] !== (string) $value) {
return false;
}
}
return true;
}
}
@@ -0,0 +1,23 @@
<?php
namespace Modules\Automation\Services;
use Modules\Automation\Contracts\TriggerRegistry;
final class AutomationTriggerRegistry implements TriggerRegistry
{
public function all(): array
{
return [
'manual' => ['label' => 'Manual', 'description' => 'Run from the administration area or API.'],
'schedule' => ['label' => 'Schedule', 'description' => 'Run when next_run_at is due.'],
'webhook' => ['label' => 'Incoming webhook', 'description' => 'Run from a signed API request.'],
'domain_event' => ['label' => 'Domain event', 'description' => 'Run when an application event is dispatched.'],
];
}
public function has(string $type): bool
{
return array_key_exists($type, $this->all());
}
}
@@ -0,0 +1,16 @@
<?php
namespace Modules\Automation\Tests\Feature;
use Modules\Automation\Contracts\ActionRegistry;
use Modules\Automation\Providers\AutomationServiceProvider;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_automation_module_is_registered(): void
{
$this->assertTrue(class_exists(AutomationServiceProvider::class));
$this->assertTrue(app()->bound(ActionRegistry::class));
}
}
+1
View File
@@ -0,0 +1 @@
{"name":"Automation","alias":"automation","version":"1.0.0","description":"Event-driven and scheduled automations with reusable triggers, actions, runs and execution logs.","keywords":["automation","events","triggers","actions","scheduler"],"priority":130,"providers":["Modules\\Automation\\Providers\\AutomationServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Audit","Api"]}