style updates

This commit is contained in:
Mark Gregory
2026-07-24 14:59:44 +01:00
parent 24ab771cd3
commit dd23205d0b
482 changed files with 7173 additions and 1435 deletions
+1
View File
@@ -1,4 +1,5 @@
<?php
return [
'default_provider' => env('AI_DEFAULT_PROVIDER'),
'timeout' => (int) env('AI_TIMEOUT', 60),
+15 -2
View File
@@ -1,9 +1,22 @@
<?php
namespace Modules\AI\Console;
use Illuminate\Console\Command;
use Modules\AI\Models\AIExecution;
final class PruneAIExecutions extends Command
{
protected $signature='ai:prune {--days=}'; protected $description='Remove old AI execution records.';
public function handle(): int { $days=(int)($this->option('days')?:config('ai.retain_executions_days',90)); $count=AIExecution::where('created_at','<',now()->subDays($days))->delete(); $this->info("Deleted {$count} AI executions."); return self::SUCCESS; }
protected $signature = 'ai:prune {--days=}';
protected $description = 'Remove old AI execution records.';
public function handle(): int
{
$days = (int) ($this->option('days') ?: config('ai.retain_executions_days', 90));
$count = AIExecution::where('created_at', '<', now()->subDays($days))->delete();
$this->info("Deleted {$count} AI executions.");
return self::SUCCESS;
}
}
+14 -2
View File
@@ -1,9 +1,21 @@
<?php
namespace Modules\AI\Console;
use Illuminate\Console\Command;
use Modules\AI\Contracts\AIManager;
final class TestAIProvider extends Command
{
protected $signature='ai:test {provider}'; protected $description='Test an AI provider connection.';
public function handle(AIManager $manager): int { $result=$manager->testProvider((int)$this->argument('provider')); $this->{$result['ok']?'info':'error'}($result['message']); return $result['ok']?self::SUCCESS:self::FAILURE; }
protected $signature = 'ai:test {provider}';
protected $description = 'Test an AI provider connection.';
public function handle(AIManager $manager): int
{
$result = $manager->testProvider((int) $this->argument('provider'));
$this->{$result['ok'] ? 'info' : 'error'}($result['message']);
return $result['ok'] ? self::SUCCESS : self::FAILURE;
}
}
+5 -1
View File
@@ -1,9 +1,13 @@
<?php
namespace Modules\AI\Contracts;
use Modules\AI\Models\AIExecution;
use Modules\AI\Models\AIPrompt;
interface AIManager
{
public function execute(AIPrompt|string $prompt, array $variables=[], array $options=[]): AIExecution;
public function execute(AIPrompt|string $prompt, array $variables = [], array $options = []): AIExecution;
public function testProvider(int $providerId): array;
}
+5 -1
View File
@@ -1,8 +1,12 @@
<?php
namespace Modules\AI\Contracts;
use Modules\AI\Models\AIProvider;
interface AIProviderDriver
{
public function generate(AIProvider $provider, array $messages, array $options=[]): array;
public function generate(AIProvider $provider, array $messages, array $options = []): array;
public function test(AIProvider $provider): array;
}
@@ -1,46 +1,90 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
return new class extends Migration
{
public function up(): void
{
Schema::create('ai_providers', function (Blueprint $table) {
$table->id(); $table->string('name'); $table->string('driver', 40)->default('openai_compatible');
$table->string('base_url')->nullable(); $table->text('api_key')->nullable(); $table->string('default_model')->nullable();
$table->json('models')->nullable(); $table->json('options')->nullable(); $table->boolean('is_active')->default(true)->index();
$table->boolean('is_default')->default(false)->index(); $table->timestamp('last_tested_at')->nullable();
$table->string('last_test_status', 20)->nullable(); $table->text('last_test_message')->nullable(); $table->timestamps();
$table->id();
$table->string('name');
$table->string('driver', 40)->default('openai_compatible');
$table->string('base_url')->nullable();
$table->text('api_key')->nullable();
$table->string('default_model')->nullable();
$table->json('models')->nullable();
$table->json('options')->nullable();
$table->boolean('is_active')->default(true)->index();
$table->boolean('is_default')->default(false)->index();
$table->timestamp('last_tested_at')->nullable();
$table->string('last_test_status', 20)->nullable();
$table->text('last_test_message')->nullable();
$table->timestamps();
});
Schema::create('ai_prompts', function (Blueprint $table) {
$table->id(); $table->string('name'); $table->string('slug')->unique(); $table->string('purpose')->nullable()->index();
$table->text('system_prompt')->nullable(); $table->longText('user_template'); $table->json('variables')->nullable();
$table->unsignedInteger('version')->default(1); $table->boolean('is_active')->default(true)->index();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); $table->timestamps();
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('purpose')->nullable()->index();
$table->text('system_prompt')->nullable();
$table->longText('user_template');
$table->json('variables')->nullable();
$table->unsignedInteger('version')->default(1);
$table->boolean('is_active')->default(true)->index();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
});
Schema::create('ai_executions', function (Blueprint $table) {
$table->id(); $table->uuid('execution_id')->unique(); $table->foreignId('ai_provider_id')->nullable()->constrained('ai_providers')->nullOnDelete();
$table->foreignId('ai_prompt_id')->nullable()->constrained('ai_prompts')->nullOnDelete(); $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('model')->nullable(); $table->string('purpose')->nullable()->index(); $table->longText('input')->nullable();
$table->longText('output')->nullable(); $table->json('context')->nullable(); $table->json('usage')->nullable();
$table->string('status', 20)->default('pending')->index(); $table->text('error')->nullable();
$table->unsignedInteger('duration_ms')->nullable(); $table->timestamp('completed_at')->nullable(); $table->timestamps();
$table->id();
$table->uuid('execution_id')->unique();
$table->foreignId('ai_provider_id')->nullable()->constrained('ai_providers')->nullOnDelete();
$table->foreignId('ai_prompt_id')->nullable()->constrained('ai_prompts')->nullOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('model')->nullable();
$table->string('purpose')->nullable()->index();
$table->longText('input')->nullable();
$table->longText('output')->nullable();
$table->json('context')->nullable();
$table->json('usage')->nullable();
$table->string('status', 20)->default('pending')->index();
$table->text('error')->nullable();
$table->unsignedInteger('duration_ms')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
});
Schema::create('ai_knowledge_sources', function (Blueprint $table) {
$table->id(); $table->string('name'); $table->string('source_kind', 40)->default('manual')->index();
$table->nullableMorphs('source'); $table->longText('content')->nullable(); $table->string('checksum', 64)->nullable()->index();
$table->json('metadata')->nullable(); $table->string('status', 20)->default('pending')->index();
$table->unsignedInteger('chunk_count')->default(0); $table->timestamp('indexed_at')->nullable(); $table->timestamps();
$table->id();
$table->string('name');
$table->string('source_kind', 40)->default('manual')->index();
$table->nullableMorphs('source');
$table->longText('content')->nullable();
$table->string('checksum', 64)->nullable()->index();
$table->json('metadata')->nullable();
$table->string('status', 20)->default('pending')->index();
$table->unsignedInteger('chunk_count')->default(0);
$table->timestamp('indexed_at')->nullable();
$table->timestamps();
});
Schema::create('ai_knowledge_chunks', function (Blueprint $table) {
$table->id(); $table->foreignId('ai_knowledge_source_id')->constrained('ai_knowledge_sources')->cascadeOnDelete();
$table->unsignedInteger('position'); $table->longText('content'); $table->json('metadata')->nullable(); $table->timestamps();
$table->unique(['ai_knowledge_source_id','position']);
$table->id();
$table->foreignId('ai_knowledge_source_id')->constrained('ai_knowledge_sources')->cascadeOnDelete();
$table->unsignedInteger('position');
$table->longText('content');
$table->json('metadata')->nullable();
$table->timestamps();
$table->unique(['ai_knowledge_source_id', 'position']);
});
}
public function down(): void
{
Schema::dropIfExists('ai_knowledge_chunks'); Schema::dropIfExists('ai_knowledge_sources');
Schema::dropIfExists('ai_executions'); Schema::dropIfExists('ai_prompts'); Schema::dropIfExists('ai_providers');
Schema::dropIfExists('ai_knowledge_chunks');
Schema::dropIfExists('ai_knowledge_sources');
Schema::dropIfExists('ai_executions');
Schema::dropIfExists('ai_prompts');
Schema::dropIfExists('ai_providers');
}
};
+18 -9
View File
@@ -1,27 +1,36 @@
<?php
namespace Modules\AI\Drivers;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Modules\AI\Contracts\AIProviderDriver;
use Modules\AI\Models\AIProvider;
final class OpenAICompatibleDriver implements AIProviderDriver
{
private function client(AIProvider $provider): PendingRequest
{
$client=Http::acceptJson()->timeout((int)config('ai.timeout',60));
$client = Http::acceptJson()->timeout((int) config('ai.timeout', 60));
return filled($provider->api_key) ? $client->withToken($provider->api_key) : $client;
}
public function generate(AIProvider $provider,array $messages,array $options=[]): array
public function generate(AIProvider $provider, array $messages, array $options = []): array
{
$base=rtrim((string)($provider->base_url ?: 'https://api.openai.com/v1'),'/');
$payload=['model'=>$options['model']??$provider->default_model,'messages'=>$messages,'temperature'=>$options['temperature']??config('ai.temperature',0.3),'max_tokens'=>$options['max_tokens']??config('ai.max_output_tokens',2048)];
$response=$this->client($provider)->post($base.'/chat/completions',array_filter($payload,fn($v)=>$v!==null));
$response->throw(); $json=$response->json();
return ['text'=>(string)data_get($json,'choices.0.message.content',''),'usage'=>(array)data_get($json,'usage',[]),'raw'=>$json];
$base = rtrim((string) ($provider->base_url ?: 'https://api.openai.com/v1'), '/');
$payload = ['model' => $options['model'] ?? $provider->default_model, 'messages' => $messages, 'temperature' => $options['temperature'] ?? config('ai.temperature', 0.3), 'max_tokens' => $options['max_tokens'] ?? config('ai.max_output_tokens', 2048)];
$response = $this->client($provider)->post($base.'/chat/completions', array_filter($payload, fn ($v) => $v !== null));
$response->throw();
$json = $response->json();
return ['text' => (string) data_get($json, 'choices.0.message.content', ''), 'usage' => (array) data_get($json, 'usage', []), 'raw' => $json];
}
public function test(AIProvider $provider): array
{
$result=$this->generate($provider,[['role'=>'user','content'=>'Reply with OK only.']],['max_tokens'=>8,'temperature'=>0]);
return ['ok'=>true,'message'=>trim($result['text']) ?: 'Provider responded successfully.'];
$result = $this->generate($provider, [['role' => 'user', 'content' => 'Reply with OK only.']], ['max_tokens' => 8, 'temperature' => 0]);
return ['ok' => true, 'message' => trim($result['text']) ?: 'Provider responded successfully.'];
}
}
@@ -1,16 +1,20 @@
<?php
namespace Modules\AI\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\AI\Contracts\AIManager;
use Modules\AI\Models\AIPrompt;
final class AIApiController extends Controller
{
public function execute(Request $request,AIPrompt $prompt,AIManager $manager): JsonResponse
public function execute(Request $request, AIPrompt $prompt, AIManager $manager): JsonResponse
{
$data=$request->validate(['variables'=>'nullable|array','provider_id'=>'nullable|integer','model'=>'nullable|string|max:150']);
$execution=$manager->execute($prompt,$data['variables']??[],array_filter(['provider_id'=>$data['provider_id']??null,'model'=>$data['model']??null]));
return response()->json(['data'=>['execution_id'=>$execution->execution_id,'status'=>$execution->status,'output'=>$execution->output,'usage'=>$execution->usage,'error'=>$execution->error]],$execution->status==='completed'?200:422);
$data = $request->validate(['variables' => 'nullable|array', 'provider_id' => 'nullable|integer', 'model' => 'nullable|string|max:150']);
$execution = $manager->execute($prompt, $data['variables'] ?? [], array_filter(['provider_id' => $data['provider_id'] ?? null, 'model' => $data['model'] ?? null]));
return response()->json(['data' => ['execution_id' => $execution->execution_id, 'status' => $execution->status, 'output' => $execution->output, 'usage' => $execution->usage, 'error' => $execution->error]], $execution->status === 'completed' ? 200 : 422);
}
}
+38 -15
View File
@@ -1,5 +1,7 @@
<?php
namespace Modules\AI\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
@@ -11,36 +13,57 @@ use Modules\AI\Models\AIKnowledgeSource;
use Modules\AI\Models\AIPrompt;
use Modules\AI\Models\AIProvider;
use Modules\AI\Services\KnowledgeIndexer;
final class AIController extends Controller
{
public function index(): View
{
return view('ai::admin.index',['providers'=>AIProvider::latest()->get(),'prompts'=>AIPrompt::latest()->paginate(15,'*','prompts'),'executions'=>AIExecution::latest()->limit(15)->get(),'sources'=>AIKnowledgeSource::latest()->limit(15)->get()]);
return view('ai::admin.index', ['providers' => AIProvider::latest()->get(), 'prompts' => AIPrompt::latest()->paginate(15, '*', 'prompts'), 'executions' => AIExecution::latest()->limit(15)->get(), 'sources' => AIKnowledgeSource::latest()->limit(15)->get()]);
}
public function storeProvider(Request $request): RedirectResponse
{
$data=$request->validate(['name'=>'required|string|max:100','driver'=>'required|in:openai_compatible,openai,lm_studio,ollama','base_url'=>'nullable|url','api_key'=>'nullable|string','default_model'=>'required|string|max:150','is_default'=>'nullable|boolean']);
if($request->boolean('is_default')) AIProvider::query()->update(['is_default'=>false]);
$data['is_default']=$request->boolean('is_default'); AIProvider::create($data); return back()->with('status','AI provider created.');
$data = $request->validate(['name' => 'required|string|max:100', 'driver' => 'required|in:openai_compatible,openai,lm_studio,ollama', 'base_url' => 'nullable|url', 'api_key' => 'nullable|string', 'default_model' => 'required|string|max:150', 'is_default' => 'nullable|boolean']);
if ($request->boolean('is_default')) {
AIProvider::query()->update(['is_default' => false]);
}
$data['is_default'] = $request->boolean('is_default');
AIProvider::create($data);
return back()->with('status', 'AI provider created.');
}
public function testProvider(AIProvider $provider,AIManager $manager): RedirectResponse
public function testProvider(AIProvider $provider, AIManager $manager): RedirectResponse
{
$result=$manager->testProvider($provider->id); return back()->with($result['ok']?'status':'error',$result['message']);
$result = $manager->testProvider($provider->id);
return back()->with($result['ok'] ? 'status' : 'error', $result['message']);
}
public function storePrompt(Request $request): RedirectResponse
{
$data=$request->validate(['name'=>'required|string|max:120','slug'=>'nullable|alpha_dash|max:140','purpose'=>'nullable|string|max:100','system_prompt'=>'nullable|string','user_template'=>'required|string']);
$data['slug']=$data['slug']?:Str::slug($data['name']); $data['created_by']=auth()->id(); AIPrompt::create($data); return back()->with('status','Prompt created.');
$data = $request->validate(['name' => 'required|string|max:120', 'slug' => 'nullable|alpha_dash|max:140', 'purpose' => 'nullable|string|max:100', 'system_prompt' => 'nullable|string', 'user_template' => 'required|string']);
$data['slug'] = $data['slug'] ?: Str::slug($data['name']);
$data['created_by'] = auth()->id();
AIPrompt::create($data);
return back()->with('status', 'Prompt created.');
}
public function execute(Request $request,AIPrompt $prompt,AIManager $manager): RedirectResponse
public function execute(Request $request, AIPrompt $prompt, AIManager $manager): RedirectResponse
{
$data=$request->validate(['input'=>'required|string|max:50000','provider_id'=>'nullable|exists:ai_providers,id']);
$execution=$manager->execute($prompt,['input'=>$data['input']],['provider_id'=>$data['provider_id']??null]);
return back()->with($execution->status==='completed'?'status':'error',$execution->status==='completed'?'AI execution completed.':$execution->error);
$data = $request->validate(['input' => 'required|string|max:50000', 'provider_id' => 'nullable|exists:ai_providers,id']);
$execution = $manager->execute($prompt, ['input' => $data['input']], ['provider_id' => $data['provider_id'] ?? null]);
return back()->with($execution->status === 'completed' ? 'status' : 'error', $execution->status === 'completed' ? 'AI execution completed.' : $execution->error);
}
public function storeSource(Request $request,KnowledgeIndexer $indexer): RedirectResponse
public function storeSource(Request $request, KnowledgeIndexer $indexer): RedirectResponse
{
$data=$request->validate(['name'=>'required|string|max:150','content'=>'required|string|max:1000000']);
$source=AIKnowledgeSource::create(['name'=>$data['name'],'source_kind'=>'manual','content'=>$data['content'],'status'=>'pending']); $indexer->index($source); return back()->with('status','Knowledge source indexed.');
$data = $request->validate(['name' => 'required|string|max:150', 'content' => 'required|string|max:1000000']);
$source = AIKnowledgeSource::create(['name' => $data['name'], 'source_kind' => 'manual', 'content' => $data['content'], 'status' => 'pending']);
$indexer->index($source);
return back()->with('status','Knowledge source indexed.');
}
}
+6 -2
View File
@@ -1,8 +1,12 @@
<?php
namespace Modules\AI\Models;
use Illuminate\Database\Eloquent\Model;
final class AIExecution extends Model
{
protected $fillable=['execution_id','ai_provider_id','ai_prompt_id','user_id','model','purpose','input','output','context','usage','status','error','duration_ms','completed_at'];
protected $casts=['context'=>'array','usage'=>'array','completed_at'=>'datetime'];
protected $fillable = ['execution_id', 'ai_provider_id', 'ai_prompt_id', 'user_id', 'model', 'purpose', 'input', 'output', 'context', 'usage', 'status', 'error', 'duration_ms', 'completed_at'];
protected $casts = ['context' => 'array', 'usage' => 'array', 'completed_at' => 'datetime'];
}
+6 -2
View File
@@ -1,8 +1,12 @@
<?php
namespace Modules\AI\Models;
use Illuminate\Database\Eloquent\Model;
final class AIKnowledgeChunk extends Model
{
protected $fillable=['ai_knowledge_source_id','position','content','metadata'];
protected $casts=['metadata'=>'array'];
protected $fillable = ['ai_knowledge_source_id', 'position', 'content', 'metadata'];
protected $casts = ['metadata' => 'array'];
}
+16 -4
View File
@@ -1,12 +1,24 @@
<?php
namespace Modules\AI\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
final class AIKnowledgeSource extends Model
{
protected $fillable=['name','source_kind','source_type','source_id','content','checksum','metadata','status','chunk_count','indexed_at'];
protected $casts=['metadata'=>'array','indexed_at'=>'datetime'];
public function source(): MorphTo { return $this->morphTo(); }
public function chunks(): HasMany { return $this->hasMany(AIKnowledgeChunk::class); }
protected $fillable = ['name', 'source_kind', 'source_type', 'source_id', 'content', 'checksum', 'metadata', 'status', 'chunk_count', 'indexed_at'];
protected $casts = ['metadata' => 'array', 'indexed_at' => 'datetime'];
public function source(): MorphTo
{
return $this->morphTo();
}
public function chunks(): HasMany
{
return $this->hasMany(AIKnowledgeChunk::class);
}
}
+8 -3
View File
@@ -1,12 +1,17 @@
<?php
namespace Modules\AI\Models;
use Illuminate\Database\Eloquent\Model;
final class AIPrompt extends Model
{
protected $fillable=['name','slug','purpose','system_prompt','user_template','variables','version','is_active','created_by'];
protected $casts=['variables'=>'array','is_active'=>'boolean'];
protected $fillable = ['name', 'slug', 'purpose', 'system_prompt', 'user_template', 'variables', 'version', 'is_active', 'created_by'];
protected $casts = ['variables' => 'array', 'is_active' => 'boolean'];
public function render(array $variables): string
{
return preg_replace_callback('/\{\{\s*([\w.]+)\s*\}\}/', fn($m)=>(string)data_get($variables,$m[1],''), $this->user_template) ?? $this->user_template;
return preg_replace_callback('/\{\{\s*([\w.]+)\s*\}\}/', fn ($m) => (string) data_get($variables, $m[1], ''), $this->user_template) ?? $this->user_template;
}
}
+8 -3
View File
@@ -1,9 +1,14 @@
<?php
namespace Modules\AI\Models;
use Illuminate\Database\Eloquent\Model;
final class AIProvider extends Model
{
protected $fillable=['name','driver','base_url','api_key','default_model','models','options','is_active','is_default','last_tested_at','last_test_status','last_test_message'];
protected $casts=['api_key'=>'encrypted','models'=>'array','options'=>'array','is_active'=>'boolean','is_default'=>'boolean','last_tested_at'=>'datetime'];
protected $hidden=['api_key'];
protected $fillable = ['name', 'driver', 'base_url', 'api_key', 'default_model', 'models', 'options', 'is_active', 'is_default', 'last_tested_at', 'last_test_status', 'last_test_message'];
protected $casts = ['api_key' => 'encrypted', 'models' => 'array', 'options' => 'array', 'is_active' => 'boolean', 'is_default' => 'boolean', 'last_tested_at' => 'datetime'];
protected $hidden = ['api_key'];
}
+7 -1
View File
@@ -1,10 +1,13 @@
<?php
namespace Modules\AI\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\AI\Console\PruneAIExecutions;
use Modules\AI\Console\TestAIProvider;
use Modules\AI\Contracts\AIManager as AIManagerContract;
use Modules\AI\Services\AIManager;
final class AIServiceProvider extends ServiceProvider
{
public function register(): void
@@ -13,10 +16,13 @@ final class AIServiceProvider extends ServiceProvider
$this->mergeConfigFrom(module_path('AI', 'Config/config.php'), 'ai');
$this->app->singleton(AIManagerContract::class, AIManager::class);
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('AI', 'Database/Migrations'));
$this->loadViewsFrom(module_path('AI', 'Resources/views'), 'ai');
if ($this->app->runningInConsole()) $this->commands([TestAIProvider::class, PruneAIExecutions::class]);
if ($this->app->runningInConsole()) {
$this->commands([TestAIProvider::class, PruneAIExecutions::class]);
}
}
}
@@ -1,7 +1,10 @@
<?php
namespace Modules\AI\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
+3 -1
View File
@@ -1,4 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\AI\Http\Controllers\AIApiController;
Route::post('/ai/prompts/{prompt:slug}/execute',[AIApiController::class,'execute'])->middleware(['auth.api','ability:ai.execute'])->name('api.ai.execute');
Route::post('/ai/prompts/{prompt:slug}/execute', [AIApiController::class, 'execute'])->middleware(['auth.api', 'ability:ai.execute'])->name('api.ai.execute');
+9 -7
View File
@@ -1,11 +1,13 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\AI\Http\Controllers\AIController;
Route::middleware(['auth','permission:ai.view'])->prefix('administration/ai')->name('administration.ai.')->group(function(){
Route::get('/',[AIController::class,'index'])->name('index');
Route::post('/providers',[AIController::class,'storeProvider'])->middleware('permission:ai.manage')->name('providers.store');
Route::post('/providers/{provider}/test',[AIController::class,'testProvider'])->middleware('permission:ai.manage')->name('providers.test');
Route::post('/prompts',[AIController::class,'storePrompt'])->middleware('permission:ai.manage')->name('prompts.store');
Route::post('/prompts/{prompt}/execute',[AIController::class,'execute'])->middleware('permission:ai.execute')->name('prompts.execute');
Route::post('/knowledge',[AIController::class,'storeSource'])->middleware('permission:ai.manage')->name('knowledge.store');
Route::middleware(['auth', 'permission:ai.view'])->prefix('administration/ai')->name('administration.ai.')->group(function () {
Route::get('/', [AIController::class, 'index'])->name('index');
Route::post('/providers', [AIController::class, 'storeProvider'])->middleware('permission:ai.manage')->name('providers.store');
Route::post('/providers/{provider}/test', [AIController::class, 'testProvider'])->middleware('permission:ai.manage')->name('providers.test');
Route::post('/prompts', [AIController::class, 'storePrompt'])->middleware('permission:ai.manage')->name('prompts.store');
Route::post('/prompts/{prompt}/execute', [AIController::class, 'execute'])->middleware('permission:ai.execute')->name('prompts.execute');
Route::post('/knowledge', [AIController::class, 'storeSource'])->middleware('permission:ai.manage')->name('knowledge.store');
});
+38 -15
View File
@@ -1,5 +1,7 @@
<?php
namespace Modules\AI\Services;
use Illuminate\Support\Str;
use Modules\AI\Contracts\AIManager as AIManagerContract;
use Modules\AI\Contracts\AIProviderDriver;
@@ -7,36 +9,57 @@ use Modules\AI\Drivers\OpenAICompatibleDriver;
use Modules\AI\Models\AIExecution;
use Modules\AI\Models\AIPrompt;
use Modules\AI\Models\AIProvider;
final class AIManager implements AIManagerContract
{
public function execute(AIPrompt|string $prompt,array $variables=[],array $options=[]): AIExecution
public function execute(AIPrompt|string $prompt, array $variables = [], array $options = []): AIExecution
{
$promptModel=is_string($prompt)?AIPrompt::where('slug',$prompt)->where('is_active',true)->firstOrFail():$prompt;
$provider=$this->provider($options['provider_id']??null); $input=$promptModel->render($variables);
$execution=AIExecution::create(['execution_id'=>(string)Str::uuid(),'ai_provider_id'=>$provider->id,'ai_prompt_id'=>$promptModel->id,'user_id'=>auth()->id(),'model'=>$options['model']??$provider->default_model,'purpose'=>$promptModel->purpose,'input'=>$input,'context'=>$variables,'status'=>'running']);
$started=microtime(true);
$promptModel = is_string($prompt) ? AIPrompt::where('slug', $prompt)->where('is_active', true)->firstOrFail() : $prompt;
$provider = $this->provider($options['provider_id'] ?? null);
$input = $promptModel->render($variables);
$execution = AIExecution::create(['execution_id' => (string) Str::uuid(), 'ai_provider_id' => $provider->id, 'ai_prompt_id' => $promptModel->id, 'user_id' => auth()->id(), 'model' => $options['model'] ?? $provider->default_model, 'purpose' => $promptModel->purpose, 'input' => $input, 'context' => $variables, 'status' => 'running']);
$started = microtime(true);
try {
$messages=[]; if (filled($promptModel->system_prompt)) $messages[]=['role'=>'system','content'=>$promptModel->system_prompt];
$messages[]=['role'=>'user','content'=>$input]; $result=$this->driver($provider)->generate($provider,$messages,$options);
$execution->update(['output'=>$result['text'],'usage'=>$result['usage'],'status'=>'completed','duration_ms'=>(int)((microtime(true)-$started)*1000),'completed_at'=>now()]);
$messages = [];
if (filled($promptModel->system_prompt)) {
$messages[] = ['role' => 'system', 'content' => $promptModel->system_prompt];
}
$messages[] = ['role' => 'user', 'content' => $input];
$result = $this->driver($provider)->generate($provider, $messages, $options);
$execution->update(['output' => $result['text'], 'usage' => $result['usage'], 'status' => 'completed', 'duration_ms' => (int) ((microtime(true) - $started) * 1000), 'completed_at' => now()]);
} catch (\Throwable $e) {
report($e); $execution->update(['status'=>'failed','error'=>$e->getMessage(),'duration_ms'=>(int)((microtime(true)-$started)*1000),'completed_at'=>now()]);
report($e);
$execution->update(['status' => 'failed', 'error' => $e->getMessage(), 'duration_ms' => (int) ((microtime(true) - $started) * 1000), 'completed_at' => now()]);
}
return $execution->refresh();
}
public function testProvider(int $providerId): array
{
$provider=AIProvider::findOrFail($providerId);
try { $result=$this->driver($provider)->test($provider); $provider->update(['last_tested_at'=>now(),'last_test_status'=>'passed','last_test_message'=>$result['message']]); return $result; }
catch (\Throwable $e) { $provider->update(['last_tested_at'=>now(),'last_test_status'=>'failed','last_test_message'=>$e->getMessage()]); return ['ok'=>false,'message'=>$e->getMessage()]; }
$provider = AIProvider::findOrFail($providerId);
try {
$result = $this->driver($provider)->test($provider);
$provider->update(['last_tested_at' => now(), 'last_test_status' => 'passed', 'last_test_message' => $result['message']]);
return $result;
} catch (\Throwable $e) {
$provider->update(['last_tested_at' => now(), 'last_test_status' => 'failed', 'last_test_message' => $e->getMessage()]);
return ['ok' => false, 'message' => $e->getMessage()];
}
}
private function provider(?int $id): AIProvider
{
return AIProvider::query()->when($id,fn($q)=>$q->whereKey($id))->when(!$id,fn($q)=>$q->where('is_default',true))->where('is_active',true)->first()
?? AIProvider::where('is_active',true)->firstOrFail();
return AIProvider::query()->when($id, fn ($q) => $q->whereKey($id))->when(! $id, fn ($q) => $q->where('is_default', true))->where('is_active', true)->first()
?? AIProvider::where('is_active', true)->firstOrFail();
}
private function driver(AIProvider $provider): AIProviderDriver
{
return match($provider->driver) { 'openai_compatible','openai','lm_studio','ollama'=>app(OpenAICompatibleDriver::class), default=>throw new \InvalidArgumentException("Unsupported AI driver [{$provider->driver}].") };
return match ($provider->driver) {
'openai_compatible','openai','lm_studio','ollama' => app(OpenAICompatibleDriver::class), default => throw new \InvalidArgumentException("Unsupported AI driver [{$provider->driver}].")
};
}
}
+28 -9
View File
@@ -1,20 +1,39 @@
<?php
namespace Modules\AI\Services;
use Modules\AI\Models\AIKnowledgeSource;
final class KnowledgeIndexer
{
public function index(AIKnowledgeSource $source,int $chunkSize=1800): AIKnowledgeSource
public function index(AIKnowledgeSource $source, int $chunkSize = 1800): AIKnowledgeSource
{
$content=trim((string)$source->content); $source->chunks()->delete();
$chunks=$content===''?[]:$this->split($content,max(500,$chunkSize));
foreach($chunks as $position=>$chunk) $source->chunks()->create(['position'=>$position+1,'content'=>$chunk]);
$source->update(['checksum'=>hash('sha256',$content),'status'=>'indexed','chunk_count'=>count($chunks),'indexed_at'=>now()]);
$content = trim((string) $source->content);
$source->chunks()->delete();
$chunks = $content === '' ? [] : $this->split($content, max(500, $chunkSize));
foreach ($chunks as $position => $chunk) {
$source->chunks()->create(['position' => $position + 1, 'content' => $chunk]);
}
$source->update(['checksum' => hash('sha256', $content), 'status' => 'indexed', 'chunk_count' => count($chunks), 'indexed_at' => now()]);
return $source->refresh();
}
private function split(string $content,int $size): array
private function split(string $content, int $size): array
{
$paragraphs=preg_split('/\R{2,}/',$content) ?: [$content]; $chunks=[]; $buffer='';
foreach($paragraphs as $paragraph){ if(strlen($buffer)+strlen($paragraph)+2>$size && $buffer!==''){ $chunks[]=trim($buffer); $buffer=''; } $buffer.=$paragraph."\n\n"; }
if(trim($buffer)!=='') $chunks[]=trim($buffer); return $chunks;
$paragraphs = preg_split('/\R{2,}/', $content) ?: [$content];
$chunks = [];
$buffer = '';
foreach ($paragraphs as $paragraph) {
if (strlen($buffer) + strlen($paragraph) + 2 > $size && $buffer !== '') {
$chunks[] = trim($buffer);
$buffer = '';
} $buffer .= $paragraph."\n\n";
}
if (trim($buffer) !== '') {
$chunks[] = trim($buffer);
}
return $chunks;
}
}
+7 -2
View File
@@ -1,11 +1,16 @@
<?php
namespace Modules\AI\Tests\Feature;
use Modules\AI\Contracts\AIManager;
use Modules\AI\Providers\AIServiceProvider;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_ai_module_boots(): void
{
$this->assertTrue(class_exists(\Modules\AI\Providers\AIServiceProvider::class));
$this->assertTrue(app()->bound(\Modules\AI\Contracts\AIManager::class));
$this->assertTrue(class_exists(AIServiceProvider::class));
$this->assertTrue(app()->bound(AIManager::class));
}
}
@@ -12,6 +12,7 @@ final class ModuleController extends Controller
$modules = collect(glob(base_path('Modules/*/module.json')) ?: [])->map(function (string $manifest) use ($statuses): array {
$data = json_decode((string) file_get_contents($manifest), true) ?: [];
$name = $data['name'] ?? basename(dirname($manifest));
return [
'name' => $name,
'description' => $data['description'] ?? 'Laravel application module.',
@@ -4,6 +4,13 @@ namespace Modules\Administration\Support;
final class ModuleInfo
{
public static function name(): string { return 'Administration'; }
public static function nameLower(): string { return 'administration'; }
public static function name(): string
{
return 'Administration';
}
public static function nameLower(): string
{
return 'administration';
}
}
+1
View File
@@ -1,4 +1,5 @@
<?php
return [
'enabled' => (bool) env('ANALYTICS_ENABLED', true),
'respect_dnt' => (bool) env('ANALYTICS_RESPECT_DNT', true),
@@ -1,9 +1,20 @@
<?php
namespace Modules\Analytics\Console;
use Illuminate\Console\Command;
use Modules\Analytics\Services\AnalyticsAggregator;
final class AggregateAnalytics extends Command
{
protected $signature='analytics:aggregate {--date=}'; protected $description='Aggregate raw analytics events into daily metrics.';
public function handle(AnalyticsAggregator $aggregator): int { $this->info('Metrics written: '.$aggregator->aggregate($this->option('date'))); return self::SUCCESS; }
protected $signature = 'analytics:aggregate {--date=}';
protected $description = 'Aggregate raw analytics events into daily metrics.';
public function handle(AnalyticsAggregator $aggregator): int
{
$this->info('Metrics written: '.$aggregator->aggregate($this->option('date')));
return self::SUCCESS;
}
}
+14 -2
View File
@@ -1,9 +1,21 @@
<?php
namespace Modules\Analytics\Console;
use Illuminate\Console\Command;
use Modules\Analytics\Models\AnalyticsEvent;
final class PruneAnalytics extends Command
{
protected $signature='analytics:prune'; protected $description='Remove analytics events older than the configured retention period.';
public function handle(): int { $count=AnalyticsEvent::where('occurred_at','<',now()->subDays(config('analytics.retention_days',365)))->delete(); $this->info("Deleted {$count} events."); return self::SUCCESS; }
protected $signature = 'analytics:prune';
protected $description = 'Remove analytics events older than the configured retention period.';
public function handle(): int
{
$count = AnalyticsEvent::where('occurred_at', '<', now()->subDays(config('analytics.retention_days', 365)))->delete();
$this->info("Deleted {$count} events.");
return self::SUCCESS;
}
}
@@ -1,13 +1,21 @@
<?php
namespace Modules\Analytics\Console;
use Illuminate\Console\Command;
use Modules\Analytics\Models\AnalyticsReport;
final class RunScheduledReports extends Command
{
protected $signature='analytics:reports'; protected $description='Queue analytics reports that are due.';
protected $signature = 'analytics:reports';
protected $description = 'Queue analytics reports that are due.';
public function handle(): int
{
$count=AnalyticsReport::where('is_active',true)->whereNotNull('next_run_at')->where('next_run_at','<=',now())->update(['last_run_at'=>now(),'next_run_at'=>now()->addDay()]);
$this->info("Processed {$count} scheduled reports."); return self::SUCCESS;
$count = AnalyticsReport::where('is_active', true)->whereNotNull('next_run_at')->where('next_run_at', '<=', now())->update(['last_run_at' => now(), 'next_run_at' => now()->addDay()]);
$this->info("Processed {$count} scheduled reports.");
return self::SUCCESS;
}
}
@@ -1,42 +1,80 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
return new class extends Migration
{
public function up(): void
{
Schema::create('analytics_events', function (Blueprint $table) {
$table->id(); $table->uuid('event_id')->unique(); $table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();
$table->string('event_type', 40)->index(); $table->string('path', 2048)->nullable(); $table->string('title')->nullable();
$table->string('referrer', 2048)->nullable(); $table->string('visitor_hash', 64)->nullable()->index();
$table->string('session_hash', 64)->nullable()->index(); $table->string('ip_hash', 64)->nullable();
$table->string('country_code', 2)->nullable()->index(); $table->string('device_type', 20)->nullable()->index();
$table->string('browser', 40)->nullable(); $table->string('operating_system', 40)->nullable();
$table->nullableMorphs('subject'); $table->json('properties')->nullable(); $table->timestamp('occurred_at')->index(); $table->timestamps();
$table->index(['site_id','event_type','occurred_at']);
$table->id();
$table->uuid('event_id')->unique();
$table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();
$table->string('event_type', 40)->index();
$table->string('path', 2048)->nullable();
$table->string('title')->nullable();
$table->string('referrer', 2048)->nullable();
$table->string('visitor_hash', 64)->nullable()->index();
$table->string('session_hash', 64)->nullable()->index();
$table->string('ip_hash', 64)->nullable();
$table->string('country_code', 2)->nullable()->index();
$table->string('device_type', 20)->nullable()->index();
$table->string('browser', 40)->nullable();
$table->string('operating_system', 40)->nullable();
$table->nullableMorphs('subject');
$table->json('properties')->nullable();
$table->timestamp('occurred_at')->index();
$table->timestamps();
$table->index(['site_id', 'event_type', 'occurred_at']);
});
Schema::create('analytics_daily_metrics', function (Blueprint $table) {
$table->id(); $table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete(); $table->date('metric_date')->index();
$table->string('metric', 60); $table->string('dimension', 120)->default('all'); $table->unsignedBigInteger('value')->default(0);
$table->json('metadata')->nullable(); $table->timestamps(); $table->unique(['site_id','metric_date','metric','dimension'], 'analytics_daily_unique');
$table->id();
$table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();
$table->date('metric_date')->index();
$table->string('metric', 60);
$table->string('dimension', 120)->default('all');
$table->unsignedBigInteger('value')->default(0);
$table->json('metadata')->nullable();
$table->timestamps();
$table->unique(['site_id', 'metric_date', 'metric', 'dimension'], 'analytics_daily_unique');
});
Schema::create('analytics_reports', function (Blueprint $table) {
$table->id(); $table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete(); $table->string('name');
$table->string('report_type', 40)->default('overview'); $table->json('filters')->nullable(); $table->json('columns')->nullable();
$table->string('schedule')->nullable(); $table->string('format', 10)->default('csv'); $table->json('recipients')->nullable();
$table->boolean('is_active')->default(true)->index(); $table->timestamp('last_run_at')->nullable(); $table->timestamp('next_run_at')->nullable()->index();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); $table->timestamps();
$table->id();
$table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();
$table->string('name');
$table->string('report_type', 40)->default('overview');
$table->json('filters')->nullable();
$table->json('columns')->nullable();
$table->string('schedule')->nullable();
$table->string('format', 10)->default('csv');
$table->json('recipients')->nullable();
$table->boolean('is_active')->default(true)->index();
$table->timestamp('last_run_at')->nullable();
$table->timestamp('next_run_at')->nullable()->index();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
});
Schema::create('analytics_report_runs', function (Blueprint $table) {
$table->id(); $table->foreignId('analytics_report_id')->constrained('analytics_reports')->cascadeOnDelete();
$table->string('status', 20)->default('pending')->index(); $table->string('disk')->nullable(); $table->string('path')->nullable();
$table->unsignedBigInteger('row_count')->default(0); $table->text('error')->nullable(); $table->timestamp('started_at')->nullable();
$table->timestamp('completed_at')->nullable(); $table->timestamps();
$table->id();
$table->foreignId('analytics_report_id')->constrained('analytics_reports')->cascadeOnDelete();
$table->string('status', 20)->default('pending')->index();
$table->string('disk')->nullable();
$table->string('path')->nullable();
$table->unsignedBigInteger('row_count')->default(0);
$table->text('error')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('analytics_report_runs'); Schema::dropIfExists('analytics_reports');
Schema::dropIfExists('analytics_daily_metrics'); Schema::dropIfExists('analytics_events');
Schema::dropIfExists('analytics_report_runs');
Schema::dropIfExists('analytics_reports');
Schema::dropIfExists('analytics_daily_metrics');
Schema::dropIfExists('analytics_events');
}
};
@@ -1,30 +1,43 @@
<?php
namespace Modules\Analytics\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\View\View;
use Modules\Analytics\Models\AnalyticsEvent;
use Modules\Analytics\Models\AnalyticsReport;
final class AnalyticsController extends Controller
{
public function index(Request $request): View
{
$days=max(1,min(365,(int)$request->integer('days',30))); $from=now()->subDays($days-1)->startOfDay();
$events=AnalyticsEvent::query()->where('occurred_at','>=',$from);
return view('analytics::index',[
'days'=>$days,'pageViews'=>$events->clone()->where('event_type','page_view')->count(),
'visitors'=>$events->clone()->whereNotNull('visitor_hash')->distinct('visitor_hash')->count('visitor_hash'),
'conversions'=>$events->clone()->where('event_type','conversion')->count(),
'searches'=>$events->clone()->where('event_type','search')->count(),
'topPages'=>$events->clone()->where('event_type','page_view')->selectRaw('path, count(*) as total')->groupBy('path')->orderByDesc('total')->limit(10)->get(),
'recentEvents'=>$events->clone()->latest('occurred_at')->limit(20)->get(), 'reports'=>AnalyticsReport::latest()->limit(20)->get(),
$days = max(1, min(365, (int) $request->integer('days', 30)));
$from = now()->subDays($days - 1)->startOfDay();
$events = AnalyticsEvent::query()->where('occurred_at', '>=', $from);
return view('analytics::index', [
'days' => $days, 'pageViews' => $events->clone()->where('event_type', 'page_view')->count(),
'visitors' => $events->clone()->whereNotNull('visitor_hash')->distinct('visitor_hash')->count('visitor_hash'),
'conversions' => $events->clone()->where('event_type', 'conversion')->count(),
'searches' => $events->clone()->where('event_type', 'search')->count(),
'topPages' => $events->clone()->where('event_type', 'page_view')->selectRaw('path, count(*) as total')->groupBy('path')->orderByDesc('total')->limit(10)->get(),
'recentEvents' => $events->clone()->latest('occurred_at')->limit(20)->get(), 'reports' => AnalyticsReport::latest()->limit(20)->get(),
]);
}
public function storeReport(Request $request): RedirectResponse
{
AnalyticsReport::create($request->validate(['name'=>'required|string|max:255','report_type'=>'required|in:overview,content,search,conversion,commerce','schedule'=>'nullable|string|max:50','format'=>'required|in:csv,json','is_active'=>'nullable|boolean']) + ['created_by'=>$request->user()->id,'is_active'=>$request->boolean('is_active',true)]);
return back()->with('status','Report created.');
AnalyticsReport::create($request->validate(['name' => 'required|string|max:255', 'report_type' => 'required|in:overview,content,search,conversion,commerce', 'schedule' => 'nullable|string|max:50', 'format' => 'required|in:csv,json', 'is_active' => 'nullable|boolean']) + ['created_by' => $request->user()->id, 'is_active' => $request->boolean('is_active', true)]);
return back()->with('status', 'Report created.');
}
public function destroyReport(AnalyticsReport $report): RedirectResponse
{
$report->delete();
return back()->with('status','Report deleted.');
}
public function destroyReport(AnalyticsReport $report): RedirectResponse { $report->delete(); return back()->with('status','Report deleted.'); }
}
@@ -1,21 +1,25 @@
<?php
namespace Modules\Analytics\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\Rule;
use Modules\Analytics\Services\AnalyticsTracker;
final class TrackingController extends Controller
{
public function store(Request $request, AnalyticsTracker $tracker): JsonResponse
{
$data=$request->validate([
'event_type'=>['required',Rule::in(['page_view','search','conversion','content_view','form_submit','commerce'])],
'path'=>['nullable','string','max:2048'],'title'=>['nullable','string','max:255'],'referrer'=>['nullable','string','max:2048'],
'visitor_id'=>['nullable','string','max:255'],'session_id'=>['nullable','string','max:255'],
'subject_type'=>['nullable','string','max:255'],'subject_id'=>['nullable','integer'],'properties'=>['nullable','array'],
$data = $request->validate([
'event_type' => ['required', Rule::in(['page_view', 'search', 'conversion', 'content_view', 'form_submit', 'commerce'])],
'path' => ['nullable', 'string', 'max:2048'], 'title' => ['nullable', 'string', 'max:255'], 'referrer' => ['nullable', 'string', 'max:2048'],
'visitor_id' => ['nullable', 'string', 'max:255'], 'session_id' => ['nullable', 'string', 'max:255'],
'subject_type' => ['nullable', 'string', 'max:255'], 'subject_id' => ['nullable', 'integer'], 'properties' => ['nullable', 'array'],
]);
$event=$tracker->track($request,$data);
return response()->json(['recorded'=>(bool)$event],$event?201:202);
$event = $tracker->track($request, $data);
return response()->json(['recorded' => (bool) $event], $event ? 201 : 202);
}
}
+6 -2
View File
@@ -1,8 +1,12 @@
<?php
namespace Modules\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
final class AnalyticsEvent extends Model
{
protected $fillable=['event_id','site_id','event_type','path','title','referrer','visitor_hash','session_hash','ip_hash','country_code','device_type','browser','operating_system','subject_type','subject_id','properties','occurred_at'];
protected $casts=['properties'=>'array','occurred_at'=>'datetime'];
protected $fillable = ['event_id', 'site_id', 'event_type', 'path', 'title', 'referrer', 'visitor_hash', 'session_hash', 'ip_hash', 'country_code', 'device_type', 'browser', 'operating_system', 'subject_type', 'subject_id', 'properties', 'occurred_at'];
protected $casts = ['properties' => 'array', 'occurred_at' => 'datetime'];
}
+6 -2
View File
@@ -1,8 +1,12 @@
<?php
namespace Modules\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
final class AnalyticsReport extends Model
{
protected $fillable=['site_id','name','report_type','filters','columns','schedule','format','recipients','is_active','last_run_at','next_run_at','created_by'];
protected $casts=['filters'=>'array','columns'=>'array','recipients'=>'array','is_active'=>'boolean','last_run_at'=>'datetime','next_run_at'=>'datetime'];
protected $fillable = ['site_id', 'name', 'report_type', 'filters', 'columns', 'schedule', 'format', 'recipients', 'is_active', 'last_run_at', 'next_run_at', 'created_by'];
protected $casts = ['filters' => 'array', 'columns' => 'array', 'recipients' => 'array', 'is_active' => 'boolean', 'last_run_at' => 'datetime', 'next_run_at' => 'datetime'];
}
@@ -1,8 +1,12 @@
<?php
namespace Modules\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
final class AnalyticsReportRun extends Model
{
protected $fillable=['analytics_report_id','status','disk','path','row_count','error','started_at','completed_at'];
protected $casts=['started_at'=>'datetime','completed_at'=>'datetime'];
protected $fillable = ['analytics_report_id', 'status', 'disk', 'path', 'row_count', 'error', 'started_at', 'completed_at'];
protected $casts = ['started_at' => 'datetime', 'completed_at' => 'datetime'];
}
+8 -3
View File
@@ -1,9 +1,14 @@
<?php
namespace Modules\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
final class DailyMetric extends Model
{
protected $table='analytics_daily_metrics';
protected $fillable=['site_id','metric_date','metric','dimension','value','metadata'];
protected $casts=['metric_date'=>'date','metadata'=>'array'];
protected $table = 'analytics_daily_metrics';
protected $fillable = ['site_id', 'metric_date', 'metric', 'dimension', 'value', 'metadata'];
protected $casts = ['metric_date' => 'date', 'metadata' => 'array'];
}
@@ -1,10 +1,13 @@
<?php
namespace Modules\Analytics\Providers;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\ServiceProvider;
use Modules\Analytics\Console\AggregateAnalytics;
use Modules\Analytics\Console\PruneAnalytics;
use Modules\Analytics\Console\RunScheduledReports;
final class AnalyticsServiceProvider extends ServiceProvider
{
public function register(): void
@@ -12,6 +15,7 @@ final class AnalyticsServiceProvider extends ServiceProvider
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Analytics', 'Config/config.php'), 'analytics');
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Analytics', 'Database/Migrations'));
@@ -1,7 +1,10 @@
<?php
namespace Modules\Analytics\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
+3 -1
View File
@@ -1,4 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Analytics\Http\Controllers\TrackingController;
Route::post('/analytics/events',[TrackingController::class,'store'])->middleware('throttle:60,1')->name('api.analytics.events.store');
Route::post('/analytics/events', [TrackingController::class, 'store'])->middleware('throttle:60,1')->name('api.analytics.events.store');
+6 -4
View File
@@ -1,8 +1,10 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Analytics\Http\Controllers\AnalyticsController;
Route::middleware(['auth','permission:analytics.view'])->prefix('administration/analytics')->name('administration.analytics.')->group(function () {
Route::get('/',[AnalyticsController::class,'index'])->name('index');
Route::post('/reports',[AnalyticsController::class,'storeReport'])->middleware('permission:analytics.manage')->name('reports.store');
Route::delete('/reports/{report}',[AnalyticsController::class,'destroyReport'])->middleware('permission:analytics.manage')->name('reports.destroy');
Route::middleware(['auth', 'permission:analytics.view'])->prefix('administration/analytics')->name('administration.analytics.')->group(function () {
Route::get('/', [AnalyticsController::class, 'index'])->name('index');
Route::post('/reports', [AnalyticsController::class, 'storeReport'])->middleware('permission:analytics.manage')->name('reports.store');
Route::delete('/reports/{report}', [AnalyticsController::class, 'destroyReport'])->middleware('permission:analytics.manage')->name('reports.destroy');
});
@@ -1,27 +1,32 @@
<?php
namespace Modules\Analytics\Services;
use Illuminate\Support\Facades\DB;
use Modules\Analytics\Models\AnalyticsEvent;
use Modules\Analytics\Models\DailyMetric;
final class AnalyticsAggregator
{
public function aggregate(?string $date=null): int
public function aggregate(?string $date = null): int
{
$date=$date ?: now()->subHour()->toDateString(); $written=0;
$base=AnalyticsEvent::query()->whereDate('occurred_at',$date);
$date = $date ?: now()->subHour()->toDateString();
$written = 0;
$base = AnalyticsEvent::query()->whereDate('occurred_at', $date);
foreach ($base->clone()->select('site_id')->distinct()->pluck('site_id') as $siteId) {
$query=$base->clone()->where('site_id',$siteId);
$metrics=[
['page_views','all',$query->clone()->where('event_type','page_view')->count()],
['visitors','all',$query->clone()->whereNotNull('visitor_hash')->distinct('visitor_hash')->count('visitor_hash')],
['sessions','all',$query->clone()->whereNotNull('session_hash')->distinct('session_hash')->count('session_hash')],
['conversions','all',$query->clone()->where('event_type','conversion')->count()],
['searches','all',$query->clone()->where('event_type','search')->count()],
$query = $base->clone()->where('site_id', $siteId);
$metrics = [
['page_views', 'all', $query->clone()->where('event_type', 'page_view')->count()],
['visitors', 'all', $query->clone()->whereNotNull('visitor_hash')->distinct('visitor_hash')->count('visitor_hash')],
['sessions', 'all', $query->clone()->whereNotNull('session_hash')->distinct('session_hash')->count('session_hash')],
['conversions', 'all', $query->clone()->where('event_type', 'conversion')->count()],
['searches', 'all', $query->clone()->where('event_type', 'search')->count()],
];
foreach ($metrics as [$metric,$dimension,$value]) {
DailyMetric::updateOrCreate(['site_id'=>$siteId,'metric_date'=>$date,'metric'=>$metric,'dimension'=>$dimension],['value'=>$value]); $written++;
DailyMetric::updateOrCreate(['site_id' => $siteId, 'metric_date' => $date, 'metric' => $metric, 'dimension' => $dimension], ['value' => $value]);
$written++;
}
}
return $written;
}
}
@@ -1,13 +1,17 @@
<?php
namespace Modules\Analytics\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class AnalyticsModuleTest extends TestCase
{
use RefreshDatabase;
public function test_tracking_endpoint_records_page_view(): void
{
$this->postJson('/api/v1/analytics/events',['event_type'=>'page_view','path'=>'/about'])->assertCreated()->assertJson(['recorded'=>true]);
$this->assertDatabaseHas('analytics_events',['event_type'=>'page_view','path'=>'/about']);
$this->postJson('/api/v1/analytics/events', ['event_type' => 'page_view', 'path' => '/about'])->assertCreated()->assertJson(['recorded' => true]);
$this->assertDatabaseHas('analytics_events', ['event_type' => 'page_view', 'path' => '/about']);
}
}
+2 -1
View File
@@ -1,2 +1,3 @@
<?php
return ['prefix'=>'api/v1','token_prefix'=>'cms_','pagination'=>20,'webhook_timeout'=>10,'openapi'=>['title'=>env('APP_NAME','Content Manager').' API','version'=>'1.0.0']];
return ['prefix' => 'api/v1', 'token_prefix' => 'cms_', 'pagination' => 20, 'webhook_timeout' => 10, 'openapi' => ['title' => env('APP_NAME', 'Content Manager').' API', 'version' => '1.0.0']];
@@ -1,3 +1,27 @@
<?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('api_tokens',function(Blueprint $t): void {$t->id();$t->foreignId('user_id')->constrained()->cascadeOnDelete();$t->string('name');$t->string('token',64)->unique();$t->json('abilities')->nullable();$t->timestamp('last_used_at')->nullable();$t->timestamp('expires_at')->nullable();$t->timestamps();}); } public function down(): void {Schema::dropIfExists('api_tokens');} };
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('api_tokens', function (Blueprint $t): void {
$t->id();
$t->foreignId('user_id')->constrained()->cascadeOnDelete();
$t->string('name');
$t->string('token', 64)->unique();
$t->json('abilities')->nullable();
$t->timestamp('last_used_at')->nullable();
$t->timestamp('expires_at')->nullable();
$t->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('api_tokens');
}
};
@@ -1,3 +1,43 @@
<?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('webhooks',function(Blueprint $t): void {$t->id();$t->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();$t->string('name');$t->string('url');$t->string('secret');$t->json('events');$t->boolean('is_active')->default(true);$t->unsignedSmallInteger('failure_count')->default(0);$t->timestamp('last_triggered_at')->nullable();$t->timestamps();}); Schema::create('webhook_deliveries',function(Blueprint $t): void {$t->id();$t->foreignId('webhook_id')->constrained()->cascadeOnDelete();$t->uuid('uuid')->unique();$t->string('event');$t->json('payload');$t->unsignedSmallInteger('response_status')->nullable();$t->text('response_body')->nullable();$t->unsignedSmallInteger('attempts')->default(0);$t->timestamp('delivered_at')->nullable();$t->timestamp('failed_at')->nullable();$t->timestamps();}); } public function down(): void {Schema::dropIfExists('webhook_deliveries');Schema::dropIfExists('webhooks');} };
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('webhooks', function (Blueprint $t): void {
$t->id();
$t->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();
$t->string('name');
$t->string('url');
$t->string('secret');
$t->json('events');
$t->boolean('is_active')->default(true);
$t->unsignedSmallInteger('failure_count')->default(0);
$t->timestamp('last_triggered_at')->nullable();
$t->timestamps();
});
Schema::create('webhook_deliveries', function (Blueprint $t): void {
$t->id();
$t->foreignId('webhook_id')->constrained()->cascadeOnDelete();
$t->uuid('uuid')->unique();
$t->string('event');
$t->json('payload');
$t->unsignedSmallInteger('response_status')->nullable();
$t->text('response_body')->nullable();
$t->unsignedSmallInteger('attempts')->default(0);
$t->timestamp('delivered_at')->nullable();
$t->timestamp('failed_at')->nullable();
$t->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('webhook_deliveries');
Schema::dropIfExists('webhooks');
}
};
+47 -2
View File
@@ -1,4 +1,49 @@
<?php
namespace Modules\Api\Http\Controllers;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Str; use Illuminate\View\View; use Modules\Api\Models\ApiToken; use Modules\Api\Models\Webhook;
final class ApiController {public function index(): View{return view('api::admin.index',['tokens'=>ApiToken::with('user')->latest()->paginate(20,'*','tokens'),'webhooks'=>Webhook::latest()->paginate(20,'*','webhooks')]);}public function storeToken(Request $r): RedirectResponse{$d=$r->validate(['name'=>'required|string|max:255','abilities'=>'nullable|string','expires_at'=>'nullable|date|after:now']);$abilities=array_values(array_filter(array_map('trim',explode(',',$d['abilities']?:'*'))));[,$plain]=ApiToken::issue($r->user(),$d['name'],$abilities,isset($d['expires_at'])?new \DateTimeImmutable($d['expires_at']):null);return back()->with('api_token',$plain)->with('status','API token created.');}public function destroyToken(ApiToken $token): RedirectResponse{$token->delete();return back()->with('status','API token revoked.');}public function storeWebhook(Request $r): RedirectResponse{$d=$r->validate(['name'=>'required|string|max:255','url'=>'required|url|max:2048','events'=>'required|string','site_id'=>'nullable|integer|exists:sites,id']);Webhook::create(['name'=>$d['name'],'url'=>$d['url'],'events'=>array_values(array_filter(array_map('trim',explode(',',$d['events'])))),'site_id'=>$d['site_id']??null,'secret'=>Str::random(48),'is_active'=>true]);return back()->with('status','Webhook created.');}public function destroyWebhook(Webhook $webhook): RedirectResponse{$webhook->delete();return back()->with('status','Webhook deleted.');}}
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Modules\Api\Models\ApiToken;
use Modules\Api\Models\Webhook;
final class ApiController
{
public function index(): View
{
return view('api::admin.index', ['tokens' => ApiToken::with('user')->latest()->paginate(20, '*', 'tokens'), 'webhooks' => Webhook::latest()->paginate(20, '*', 'webhooks')]);
}
public function storeToken(Request $r): RedirectResponse
{
$d = $r->validate(['name' => 'required|string|max:255', 'abilities' => 'nullable|string', 'expires_at' => 'nullable|date|after:now']);
$abilities = array_values(array_filter(array_map('trim', explode(',', $d['abilities'] ?: '*'))));
[,$plain] = ApiToken::issue($r->user(), $d['name'], $abilities, isset($d['expires_at']) ? new \DateTimeImmutable($d['expires_at']) : null);
return back()->with('api_token', $plain)->with('status', 'API token created.');
}
public function destroyToken(ApiToken $token): RedirectResponse
{
$token->delete();
return back()->with('status', 'API token revoked.');
}
public function storeWebhook(Request $r): RedirectResponse
{
$d = $r->validate(['name' => 'required|string|max:255', 'url' => 'required|url|max:2048', 'events' => 'required|string', 'site_id' => 'nullable|integer|exists:sites,id']);
Webhook::create(['name' => $d['name'], 'url' => $d['url'], 'events' => array_values(array_filter(array_map('trim', explode(',', $d['events'])))), 'site_id' => $d['site_id'] ?? null, 'secret' => Str::random(48), 'is_active' => true]);
return back()->with('status', 'Webhook created.');
}
public function destroyWebhook(Webhook $webhook): RedirectResponse
{
$webhook->delete();
return back()->with('status','Webhook deleted.');
}
}
@@ -1,4 +1,54 @@
<?php
namespace Modules\Api\Http\Controllers;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Modules\Api\Http\Resources\ContentResource; use Modules\Blog\Models\Blog; use Modules\News\Models\News; use Modules\Pages\Models\Page;
final class ContentController {public function pages(): AnonymousResourceCollection{return $this->many(Page::query()->where('status','published'));}public function blog(): AnonymousResourceCollection{return $this->many(Blog::query()->published());}public function news(): AnonymousResourceCollection{return $this->many(News::query()->published());}public function page(Page $page): ContentResource{abort_unless($page->status==='published',404);return new ContentResource($page);}public function blogPost(Blog $blog): ContentResource{abort_unless(Blog::query()->published()->whereKey($blog)->exists(),404);return new ContentResource($blog);}public function newsArticle(News $news): ContentResource{abort_unless(News::query()->published()->whereKey($news)->exists(),404);return new ContentResource($news);}private function many(Builder $q): AnonymousResourceCollection{return ContentResource::collection($q->latest('updated_at')->paginate((int)config('api.pagination',20)));}}
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Modules\Api\Http\Resources\ContentResource;
use Modules\Blog\Models\Blog;
use Modules\News\Models\News;
use Modules\Pages\Models\Page;
final class ContentController
{
public function pages(): AnonymousResourceCollection
{
return $this->many(Page::query()->where('status', 'published'));
}
public function blog(): AnonymousResourceCollection
{
return $this->many(Blog::query()->published());
}
public function news(): AnonymousResourceCollection
{
return $this->many(News::query()->published());
}
public function page(Page $page): ContentResource
{
abort_unless($page->status === 'published', 404);
return new ContentResource($page);
}
public function blogPost(Blog $blog): ContentResource
{
abort_unless(Blog::query()->published()->whereKey($blog)->exists(), 404);
return new ContentResource($blog);
}
public function newsArticle(News $news): ContentResource
{
abort_unless(News::query()->published()->whereKey($news)->exists(), 404);
return new ContentResource($news);
}
private function many(Builder $q): AnonymousResourceCollection
{
return ContentResource::collection($q->latest('updated_at')->paginate((int) config('api.pagination', 20)));
}
}
@@ -1,4 +1,13 @@
<?php
namespace Modules\Api\Http\Controllers;
use Illuminate\Http\JsonResponse;
final class OpenApiController {public function __invoke(): JsonResponse{return response()->json(['openapi'=>'3.1.0','info'=>config('api.openapi'),'servers'=>[['url'=>url('/'.config('api.prefix','api/v1'))]],'components'=>['securitySchemes'=>['bearerAuth'=>['type'=>'http','scheme'=>'bearer']]],'paths'=>['/pages'=>['get'=>['summary'=>'List published pages','responses'=>['200'=>['description'=>'OK']]]],'/blog'=>['get'=>['summary'=>'List published blog posts','responses'=>['200'=>['description'=>'OK']]]],'/news'=>['get'=>['summary'=>'List published news','responses'=>['200'=>['description'=>'OK']]]],'/user'=>['get'=>['summary'=>'Authenticated user','security'=>[['bearerAuth'=>[]]],'responses'=>['200'=>['description'=>'OK'],'401'=>['description'=>'Unauthenticated']]]]]]);}}
final class OpenApiController
{
public function __invoke(): JsonResponse
{
return response()->json(['openapi' => '3.1.0', 'info' => config('api.openapi'), 'servers' => [['url' => url('/'.config('api.prefix', 'api/v1'))]], 'components' => ['securitySchemes' => ['bearerAuth' => ['type' => 'http', 'scheme' => 'bearer']]], 'paths' => ['/pages' => ['get' => ['summary' => 'List published pages', 'responses' => ['200' => ['description' => 'OK']]]], '/blog' => ['get' => ['summary' => 'List published blog posts', 'responses' => ['200' => ['description' => 'OK']]]], '/news' => ['get' => ['summary' => 'List published news', 'responses' => ['200' => ['description' => 'OK']]]], '/user' => ['get' => ['summary' => 'Authenticated user', 'security' => [['bearerAuth' => []]], 'responses' => ['200' => ['description' => 'OK'], '401' => ['description' => 'Unauthenticated']]]]]]);
}
}
@@ -1,4 +1,28 @@
<?php
namespace Modules\Api\Http\Middleware;
use Closure; use Illuminate\Http\Request; use Modules\Api\Models\ApiToken; use Symfony\Component\HttpFoundation\Response;
final class AuthenticateApiToken {public function handle(Request $r,Closure $next,?string $ability=null): Response{$plain=$r->bearerToken();if(!$plain)return response()->json(['message'=>'Unauthenticated.'],401);$token=ApiToken::with('user')->where('token',hash('sha256',$plain))->first();if(!$token||($token->expires_at&&$token->expires_at->isPast())||!$token->user)return response()->json(['message'=>'Invalid or expired API token.'],401);if($ability&&!$token->allows($ability))return response()->json(['message'=>'Insufficient token ability.'],403);$token->update(['last_used_at'=>now()]);auth()->setUser($token->user);$r->attributes->set('apiToken',$token);return $next($r);}}
use Closure;
use Illuminate\Http\Request;
use Modules\Api\Models\ApiToken;
use Symfony\Component\HttpFoundation\Response;
final class AuthenticateApiToken
{
public function handle(Request $r, Closure $next, ?string $ability = null): Response
{
$plain = $r->bearerToken();
if (! $plain) {
return response()->json(['message' => 'Unauthenticated.'], 401);
}$token = ApiToken::with('user')->where('token', hash('sha256', $plain))->first();
if (! $token || ($token->expires_at && $token->expires_at->isPast()) || ! $token->user) {
return response()->json(['message' => 'Invalid or expired API token.'], 401);
}if ($ability && ! $token->allows($ability)) {
return response()->json(['message' => 'Insufficient token ability.'], 403);
}$token->update(['last_used_at' => now()]);
auth()->setUser($token->user);
$r->attributes->set('apiToken', $token);
return $next($r);
}
}
+12 -2
View File
@@ -1,4 +1,14 @@
<?php
namespace Modules\Api\Http\Resources;
use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource;
final class ContentResource extends JsonResource {public function toArray(Request $r): array{return ['id'=>$this->id,'type'=>$this->resource->getTable(),'title'=>$this->title,'slug'=>$this->slug,'excerpt'=>$this->excerpt??null,'content'=>$this->content??null,'status'=>$this->status??null,'featured_image'=>$this->featured_image??null,'published_at'=>$this->published_at??null,'updated_at'=>$this->updated_at];}}
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class ContentResource extends JsonResource
{
public function toArray(Request $r): array
{
return ['id' => $this->id, 'type' => $this->resource->getTable(), 'title' => $this->title, 'slug' => $this->slug, 'excerpt' => $this->excerpt ?? null, 'content' => $this->content ?? null, 'status' => $this->status ?? null, 'featured_image' => $this->featured_image ?? null, 'published_at' => $this->published_at ?? null, 'updated_at' => $this->updated_at];
}
}
+38 -2
View File
@@ -1,4 +1,40 @@
<?php
namespace Modules\Api\Models;
use App\Models\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Str;
final class ApiToken extends Model { protected $fillable=['user_id','name','token','abilities','last_used_at','expires_at']; protected $hidden=['token']; protected function casts(): array{return ['abilities'=>'array','last_used_at'=>'datetime','expires_at'=>'datetime'];} public function user(): BelongsTo{return $this->belongsTo(User::class);} public static function issue(User $u,string $name,array $abilities=['*'],?\DateTimeInterface $expires=null): array {$plain=config('api.token_prefix','cms_').Str::random(64);$record=self::query()->create(['user_id'=>$u->id,'name'=>$name,'token'=>hash('sha256',$plain),'abilities'=>$abilities,'expires_at'=>$expires]);return [$record,$plain];} public function allows(string $ability): bool{$a=$this->abilities?:[];return in_array('*',$a,true)||in_array($ability,$a,true);} }
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
final class ApiToken extends Model
{
protected $fillable = ['user_id', 'name', 'token', 'abilities', 'last_used_at', 'expires_at'];
protected $hidden = ['token'];
protected function casts(): array
{
return ['abilities' => 'array', 'last_used_at' => 'datetime', 'expires_at' => 'datetime'];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public static function issue(User $u, string $name, array $abilities = ['*'], ?\DateTimeInterface $expires = null): array
{
$plain = config('api.token_prefix', 'cms_').Str::random(64);
$record = self::query()->create(['user_id' => $u->id, 'name' => $name, 'token' => hash('sha256', $plain), 'abilities' => $abilities, 'expires_at' => $expires]);
return [$record, $plain];
}
public function allows(string $ability): bool
{
$a = $this->abilities ?: [];
return in_array('*', $a, true) || in_array($ability, $a, true);
}
}
+26 -2
View File
@@ -1,4 +1,28 @@
<?php
namespace Modules\Api\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany;
final class Webhook extends Model {protected $fillable=['site_id','name','url','secret','events','is_active','failure_count','last_triggered_at'];protected $hidden=['secret'];protected function casts(): array{return ['events'=>'array','is_active'=>'boolean','last_triggered_at'=>'datetime'];}public function deliveries(): HasMany{return $this->hasMany(WebhookDelivery::class);}public function subscribesTo(string $event): bool{return in_array('*',$this->events?:[],true)||in_array($event,$this->events?:[],true);}}
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Webhook extends Model
{
protected $fillable = ['site_id', 'name', 'url', 'secret', 'events', 'is_active', 'failure_count', 'last_triggered_at'];
protected $hidden = ['secret'];
protected function casts(): array
{
return ['events' => 'array', 'is_active' => 'boolean', 'last_triggered_at' => 'datetime'];
}
public function deliveries(): HasMany
{
return $this->hasMany(WebhookDelivery::class);
}
public function subscribesTo(string $event): bool
{
return in_array('*', $this->events ?: [], true) || in_array($event, $this->events ?: [], true);
}
}
+19 -2
View File
@@ -1,4 +1,21 @@
<?php
namespace Modules\Api\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class WebhookDelivery extends Model {protected $fillable=['webhook_id','uuid','event','payload','response_status','response_body','attempts','delivered_at','failed_at'];protected function casts(): array{return ['payload'=>'array','delivered_at'=>'datetime','failed_at'=>'datetime'];}public function webhook(): BelongsTo{return $this->belongsTo(Webhook::class);}}
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class WebhookDelivery extends Model
{
protected $fillable = ['webhook_id', 'uuid', 'event', 'payload', 'response_status', 'response_body', 'attempts', 'delivered_at', 'failed_at'];
protected function casts(): array
{
return ['payload' => 'array', 'delivered_at' => 'datetime', 'failed_at' => 'datetime'];
}
public function webhook(): BelongsTo
{
return $this->belongsTo(Webhook::class);
}
}
+18 -1
View File
@@ -1,5 +1,22 @@
<?php
namespace Modules\Api\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Api\Services\WebhookDispatcher;
final class ApiServiceProvider extends ServiceProvider { public function register(): void { $this->app->register(RouteServiceProvider::class); $this->mergeConfigFrom(module_path('Api','Config/config.php'),'api'); $this->app->singleton(WebhookDispatcher::class); } public function boot(): void { $this->loadMigrationsFrom(module_path('Api','Database/Migrations')); $this->loadViewsFrom(module_path('Api','Resources/views'),'api'); } }
final class ApiServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Api', 'Config/config.php'), 'api');
$this->app->singleton(WebhookDispatcher::class);
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Api', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Api', 'Resources/views'), 'api');
}
}
+11 -1
View File
@@ -1,5 +1,15 @@
<?php
namespace Modules\Api\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('api')->prefix(config('api.prefix','api/v1'))->name('api.v1.')->group(module_path('Api','Routes/api.php')); Route::middleware('web')->group(module_path('Api','Routes/web.php')); } }
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
Route::middleware('api')->prefix(config('api.prefix', 'api/v1'))->name('api.v1.')->group(module_path('Api', 'Routes/api.php'));
Route::middleware('web')->group(module_path('Api', 'Routes/web.php'));
}
}
+17 -2
View File
@@ -1,3 +1,18 @@
<?php
use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; use Modules\Api\Http\Controllers\ContentController; use Modules\Api\Http\Controllers\OpenApiController; use Modules\Api\Http\Middleware\AuthenticateApiToken;
Route::get('/openapi.json',OpenApiController::class)->name('openapi');Route::controller(ContentController::class)->group(function():void{Route::get('/pages','pages');Route::get('/pages/{page}','page');Route::get('/blog','blog');Route::get('/blog/{blog}','blogPost');Route::get('/news','news');Route::get('/news/{news}','newsArticle');});Route::middleware(AuthenticateApiToken::class)->get('/user',fn(Request $r)=>$r->user());
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Modules\Api\Http\Controllers\ContentController;
use Modules\Api\Http\Controllers\OpenApiController;
use Modules\Api\Http\Middleware\AuthenticateApiToken;
Route::get('/openapi.json', OpenApiController::class)->name('openapi');
Route::controller(ContentController::class)->group(function (): void {
Route::get('/pages', 'pages');
Route::get('/pages/{page}', 'page');
Route::get('/blog', 'blog');
Route::get('/blog/{blog}', 'blogPost');
Route::get('/news', 'news');
Route::get('/news/{news}', 'newsArticle');
});
Route::middleware(AuthenticateApiToken::class)->get('/user', fn (Request $r) => $r->user());
+11 -2
View File
@@ -1,3 +1,12 @@
<?php
use Illuminate\Support\Facades\Route; use Modules\Api\Http\Controllers\ApiController;
Route::middleware(['web','auth','permission:api.manage'])->prefix('administration/api')->name('administration.api.')->controller(ApiController::class)->group(function():void{Route::get('/','index')->name('index');Route::post('/tokens','storeToken')->name('tokens.store');Route::delete('/tokens/{token}','destroyToken')->name('tokens.destroy');Route::post('/webhooks','storeWebhook')->name('webhooks.store');Route::delete('/webhooks/{webhook}','destroyWebhook')->name('webhooks.destroy');});
use Illuminate\Support\Facades\Route;
use Modules\Api\Http\Controllers\ApiController;
Route::middleware(['web', 'auth', 'permission:api.manage'])->prefix('administration/api')->name('administration.api.')->controller(ApiController::class)->group(function (): void {
Route::get('/', 'index')->name('index');
Route::post('/tokens', 'storeToken')->name('tokens.store');
Route::delete('/tokens/{token}', 'destroyToken')->name('tokens.destroy');
Route::post('/webhooks', 'storeWebhook')->name('webhooks.store');
Route::delete('/webhooks/{webhook}', 'destroyWebhook')->name('webhooks.destroy');
});
+26 -2
View File
@@ -1,4 +1,28 @@
<?php
namespace Modules\Api\Services;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use Modules\Api\Models\Webhook; use Modules\Api\Models\WebhookDelivery;
final class WebhookDispatcher {public function dispatch(string $event,array $payload,?int $siteId=null): void{Webhook::where('is_active',true)->get()->filter(fn(Webhook $h)=>($h->site_id===null||$h->site_id===$siteId)&&$h->subscribesTo($event))->each(function(Webhook $h)use($event,$payload):void{$uuid=(string)Str::uuid();$json=json_encode($payload,JSON_UNESCAPED_SLASHES)?:'{}';$delivery=WebhookDelivery::create(['webhook_id'=>$h->id,'uuid'=>$uuid,'event'=>$event,'payload'=>$payload,'attempts'=>1]);try{$res=Http::timeout((int)config('api.webhook_timeout',10))->withHeaders(['X-CMS-Event'=>$event,'X-CMS-Delivery'=>$uuid,'X-CMS-Signature'=>'sha256='.hash_hmac('sha256',$json,$h->getRawOriginal('secret'))])->withBody($json,'application/json')->post($h->url);$delivery->update(['response_status'=>$res->status(),'response_body'=>Str::limit($res->body(),10000),'delivered_at'=>$res->successful()?now():null,'failed_at'=>$res->successful()?null:now()]);$h->update(['last_triggered_at'=>now(),'failure_count'=>$res->successful()?0:$h->failure_count+1]);}catch(\Throwable $e){$delivery->update(['response_body'=>$e->getMessage(),'failed_at'=>now()]);$h->increment('failure_count');}});}}
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Modules\Api\Models\Webhook;
use Modules\Api\Models\WebhookDelivery;
final class WebhookDispatcher
{
public function dispatch(string $event, array $payload, ?int $siteId = null): void
{
Webhook::where('is_active', true)->get()->filter(fn (Webhook $h) => ($h->site_id === null || $h->site_id === $siteId) && $h->subscribesTo($event))->each(function (Webhook $h) use ($event, $payload): void {
$uuid = (string) Str::uuid();
$json = json_encode($payload, JSON_UNESCAPED_SLASHES) ?: '{}';
$delivery = WebhookDelivery::create(['webhook_id' => $h->id, 'uuid' => $uuid, 'event' => $event, 'payload' => $payload, 'attempts' => 1]);
try {
$res = Http::timeout((int) config('api.webhook_timeout', 10))->withHeaders(['X-CMS-Event' => $event, 'X-CMS-Delivery' => $uuid, 'X-CMS-Signature' => 'sha256='.hash_hmac('sha256', $json, $h->getRawOriginal('secret'))])->withBody($json, 'application/json')->post($h->url);
$delivery->update(['response_status' => $res->status(), 'response_body' => Str::limit($res->body(), 10000), 'delivered_at' => $res->successful() ? now() : null, 'failed_at' => $res->successful() ? null : now()]);
$h->update(['last_triggered_at' => now(), 'failure_count' => $res->successful() ? 0 : $h->failure_count + 1]);
} catch (\Throwable $e) {
$delivery->update(['response_body' => $e->getMessage(), 'failed_at' => now()]);
$h->increment('failure_count');
}
});
}
}
@@ -1,3 +1,13 @@
<?php
use App\Models\User;
use Modules\Api\Models\ApiToken;
it('boots the api module',fn()=>expect(app()->providerIsLoaded(Modules\Api\Providers\ApiServiceProvider::class))->toBeTrue());it('hashes issued api tokens',function():void{$u=App\Models\User::factory()->create();[$t,$plain]=ApiToken::issue($u,'Test',['content.read']);expect($t->getRawOriginal('token'))->toBe(hash('sha256',$plain))->not->toBe($plain);});it('publishes openapi documentation',fn()=>$this->getJson('/api/v1/openapi.json')->assertOk()->assertJsonPath('openapi','3.1.0'));
use Modules\Api\Providers\ApiServiceProvider;
it('boots the api module', fn () => expect(app()->providerIsLoaded(ApiServiceProvider::class))->toBeTrue());
it('hashes issued api tokens', function (): void {
$u = User::factory()->create();
[$t,$plain] = ApiToken::issue($u, 'Test', ['content.read']);
expect($t->getRawOriginal('token'))->toBe(hash('sha256', $plain))->not->toBe($plain);
});
it('publishes openapi documentation', fn () => $this->getJson('/api/v1/openapi.json')->assertOk()->assertJsonPath('openapi', '3.1.0'));
@@ -1,3 +1,28 @@
<?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('audit_logs', function(Blueprint $table): void { $table->id(); $table->nullableMorphs('actor'); $table->string('event')->index(); $table->nullableMorphs('auditable'); $table->json('old_values')->nullable(); $table->json('new_values')->nullable(); $table->string('ip_address',45)->nullable(); $table->text('user_agent')->nullable(); $table->timestamp('created_at')->useCurrent(); }); } public function down(): void { Schema::dropIfExists('audit_logs'); } };
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('audit_logs', function (Blueprint $table): void {
$table->id();
$table->nullableMorphs('actor');
$table->string('event')->index();
$table->nullableMorphs('auditable');
$table->json('old_values')->nullable();
$table->json('new_values')->nullable();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->timestamp('created_at')->useCurrent();
});
}
public function down(): void
{
Schema::dropIfExists('audit_logs');
}
};
@@ -1,4 +1,14 @@
<?php
namespace Modules\Audit\Http\Controllers;
use App\Http\Controllers\Controller; use Modules\Audit\Models\AuditLog;
final class AuditController extends Controller { public function index(){ return view('audit::index',['logs'=>AuditLog::query()->latest('created_at')->paginate(50)]); } }
use App\Http\Controllers\Controller;
use Modules\Audit\Models\AuditLog;
final class AuditController extends Controller
{
public function index()
{
return view('audit::index', ['logs' => AuditLog::query()->latest('created_at')->paginate(50)]);
}
}
+16 -2
View File
@@ -1,3 +1,17 @@
<?php
namespace Modules\Audit\Models; use Illuminate\Database\Eloquent\Model;
final class AuditLog extends Model { public $timestamps=false; protected $guarded=[]; protected function casts(): array { return ['old_values'=>'array','new_values'=>'array','created_at'=>'datetime']; } }
namespace Modules\Audit\Models;
use Illuminate\Database\Eloquent\Model;
final class AuditLog extends Model
{
public $timestamps = false;
protected $guarded = [];
protected function casts(): array
{
return ['old_values' => 'array', 'new_values' => 'array', 'created_at' => 'datetime'];
}
}
+5 -2
View File
@@ -1,3 +1,6 @@
<?php
use Illuminate\Support\Facades\Route; use Modules\Audit\Http\Controllers\AuditController;
Route::middleware(['auth','verified'])->get('administration/audit',[AuditController::class,'index'])->name('administration.audit.index');
use Illuminate\Support\Facades\Route;
use Modules\Audit\Http\Controllers\AuditController;
Route::middleware(['auth', 'verified'])->get('administration/audit', [AuditController::class, 'index'])->name('administration.audit.index');
+9 -2
View File
@@ -4,6 +4,13 @@ namespace Modules\Audit\Support;
final class ModuleInfo
{
public static function name(): string { return 'Audit'; }
public static function nameLower(): string { return 'audit'; }
public static function name(): string
{
return 'Audit';
}
public static function nameLower(): string
{
return 'audit';
}
}
+1
View File
@@ -1,2 +1,3 @@
<?php
return ['name' => 'Authors'];
@@ -1,3 +1,31 @@
<?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('authors', function(Blueprint $table): void { $table->id(); $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); $table->string('name'); $table->string('slug')->unique(); $table->string('job_title')->nullable(); $table->text('biography')->nullable(); $table->string('avatar_path')->nullable(); $table->json('social_links')->nullable(); $table->string('meta_title')->nullable(); $table->text('meta_description')->nullable(); $table->boolean('is_active')->default(true); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('authors'); } };
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('authors', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('name');
$table->string('slug')->unique();
$table->string('job_title')->nullable();
$table->text('biography')->nullable();
$table->string('avatar_path')->nullable();
$table->json('social_links')->nullable();
$table->string('meta_title')->nullable();
$table->text('meta_description')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('authors');
}
};
@@ -1,4 +1,57 @@
<?php
namespace Modules\Authors\Http\Controllers;
use App\Http\Controllers\Controller; use App\Models\User; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; use Modules\Authors\Models\Author;
final class AuthorController extends Controller { public function index(): View { return view('authors::admin.index',['records'=>Author::with('user')->latest()->paginate(25)]); } public function create(): View { return view('authors::admin.form',['users'=>User::orderBy('name')->get()]); } public function store(Request $r): RedirectResponse { Author::create($this->data($r)); return redirect()->route('administration.authors.index')->with('status','Author created.'); } public function edit(Author $author): View { return view('authors::admin.form',['record'=>$author,'users'=>User::orderBy('name')->get()]); } public function update(Request $r, Author $author): RedirectResponse { $author->update($this->data($r)); return redirect()->route('administration.authors.index')->with('status','Author updated.'); } public function destroy(Author $author): RedirectResponse { $author->delete(); return back()->with('status','Author deleted.'); } private function data(Request $r): array { $d=$r->validate(['user_id'=>['nullable','exists:users,id'],'name'=>['required','string','max:255'],'slug'=>['required','string','max:255'],'job_title'=>['nullable','string','max:255'],'biography'=>['nullable','string'],'avatar_path'=>['nullable','string','max:255'],'meta_title'=>['nullable','string','max:255'],'meta_description'=>['nullable','string'],'is_active'=>['sometimes','boolean']]); $d['is_active']=$r->boolean('is_active'); return $d; } }
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Modules\Authors\Models\Author;
final class AuthorController extends Controller
{
public function index(): View
{
return view('authors::admin.index', ['records' => Author::with('user')->latest()->paginate(25)]);
}
public function create(): View
{
return view('authors::admin.form', ['users' => User::orderBy('name')->get()]);
}
public function store(Request $r): RedirectResponse
{
Author::create($this->data($r));
return redirect()->route('administration.authors.index')->with('status', 'Author created.');
}
public function edit(Author $author): View
{
return view('authors::admin.form', ['record' => $author, 'users' => User::orderBy('name')->get()]);
}
public function update(Request $r, Author $author): RedirectResponse
{
$author->update($this->data($r));
return redirect()->route('administration.authors.index')->with('status', 'Author updated.');
}
public function destroy(Author $author): RedirectResponse
{
$author->delete();
return back()->with('status', 'Author deleted.');
}
private function data(Request $r): array
{
$d = $r->validate(['user_id' => ['nullable', 'exists:users,id'], 'name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255'], 'job_title' => ['nullable', 'string', 'max:255'], 'biography' => ['nullable', 'string'], 'avatar_path' => ['nullable', 'string', 'max:255'], 'meta_title' => ['nullable', 'string', 'max:255'], 'meta_description' => ['nullable', 'string'], 'is_active' => ['sometimes', 'boolean']]);
$d['is_active'] = $r->boolean('is_active');
return $d;
}
}
+21 -2
View File
@@ -1,3 +1,22 @@
<?php
namespace Modules\Authors\Models; use App\Models\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class Author extends Model { protected $fillable=['user_id','name','slug','job_title','biography','avatar_path','social_links','meta_title','meta_description','is_active']; protected function casts(): array { return ['social_links'=>'array','is_active'=>'boolean']; } public function user(): BelongsTo { return $this->belongsTo(User::class); } }
namespace Modules\Authors\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class Author extends Model
{
protected $fillable = ['user_id', 'name', 'slug', 'job_title', 'biography', 'avatar_path', 'social_links', 'meta_title', 'meta_description', 'is_active'];
protected function casts(): array
{
return ['social_links' => 'array', 'is_active' => 'boolean'];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
@@ -1,6 +1,9 @@
<?php
namespace Modules\Authors\Providers;
use Illuminate\Support\ServiceProvider;
final class AuthorsServiceProvider extends ServiceProvider
{
public function register(): void
@@ -8,6 +11,7 @@ final class AuthorsServiceProvider extends ServiceProvider
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Authors', 'Config/config.php'), 'authors');
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Authors', 'Database/Migrations'));
@@ -1,7 +1,10 @@
<?php
namespace Modules\Authors\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
+7 -2
View File
@@ -1,3 +1,8 @@
<?php
use Illuminate\Support\Facades\Route; use Modules\Authors\Http\Controllers\AuthorController;
Route::middleware(['auth','verified'])->prefix('administration/authors')->name('administration.authors.')->group(function (): void { Route::resource('/', AuthorController::class)->parameters([''=>'author'])->except('show'); });
use Illuminate\Support\Facades\Route;
use Modules\Authors\Http\Controllers\AuthorController;
Route::middleware(['auth', 'verified'])->prefix('administration/authors')->name('administration.authors.')->group(function (): void {
Route::resource('/', AuthorController::class)->parameters(['' => 'author'])->except('show');
});
@@ -1,6 +1,9 @@
<?php
namespace Modules\Authors\Tests\Feature;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_module_configuration_is_available(): void
+1
View File
@@ -1,4 +1,5 @@
<?php
return [
'queue' => env('AUTOMATION_QUEUE', 'default'),
'max_steps' => (int) env('AUTOMATION_MAX_STEPS', 50),
@@ -1,15 +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.';
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;
$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;
}
}
@@ -1,8 +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;
}
@@ -1,7 +1,10 @@
<?php
namespace Modules\Automation\Contracts;
interface TriggerRegistry
{
public function all(): array;
public function has(string $type): bool;
}
@@ -1,41 +1,67 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
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->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']);
$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->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->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->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');
Schema::dropIfExists('automation_run_logs');
Schema::dropIfExists('automation_runs');
Schema::dropIfExists('automation_steps');
Schema::dropIfExists('automations');
}
};
@@ -1,16 +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
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);
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);
}
}
@@ -1,5 +1,7 @@
<?php
namespace Modules\Automation\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -9,27 +11,53 @@ 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
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()]);
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
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.');
$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
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.');
$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.');
}
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.'); }
}
+27 -9
View File
@@ -1,5 +1,7 @@
<?php
namespace Modules\Automation\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -7,22 +9,38 @@ 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()]);
$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; }
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()]); }
$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()]);
}
}
}
+21 -5
View File
@@ -1,12 +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'; }
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';
}
}
+16 -4
View File
@@ -1,12 +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); }
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);
}
}
@@ -1,9 +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'];
public $timestamps = false;
protected $fillable = ['automation_run_id', 'automation_step_id', 'level', 'message', 'context', 'created_at'];
protected $casts = ['context' => 'array', 'created_at' => 'datetime'];
}
+11 -3
View File
@@ -1,10 +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); }
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);
}
}
@@ -1,11 +1,14 @@
<?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
@@ -15,10 +18,13 @@ final class AutomationServiceProvider extends ServiceProvider
$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]);
if ($this->app->runningInConsole()) {
$this->commands([DispatchScheduledAutomations::class]);
}
}
}
@@ -1,7 +1,10 @@
<?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
+3 -1
View File
@@ -1,4 +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');
Route::post('/automations/{automation}/trigger', [AutomationApiController::class, 'trigger'])->middleware('throttle:60,1')->name('api.automations.trigger');
+9 -7
View File
@@ -1,11 +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');
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');
});
@@ -1,50 +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']],
'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}]."),
$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
private function log(array $config, array $payload): array
{
$message=(string)($config['message']??'Automation executed'); Log::info($message,$payload); return ['message'=>$message];
$message = (string) ($config['message'] ?? 'Automation executed');
Log::info($message, $payload);
return ['message' => $message];
}
private function webhook(array $config,array $payload): array
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()];
$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
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];
$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];
$seconds = max(0, min(30, (int) ($config['seconds'] ?? 1)));
if ($seconds > 0) {
sleep($seconds);
}
return ['seconds' => $seconds];
}
}
@@ -1,28 +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
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'));
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
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++; } }
$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
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;
$config = $automation->trigger_config ?? [];
foreach ($constraints as $key => $value) {
if (isset($config[$key]) && (string) $config[$key] !== (string) $value) {
return false;
}
}
return true;
}
}
@@ -1,16 +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.'],
'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()); }
public function has(string $type): bool
{
return array_key_exists($type, $this->all());
}
}
@@ -1,11 +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(\Modules\Automation\Providers\AutomationServiceProvider::class));
$this->assertTrue(app()->bound(\Modules\Automation\Contracts\ActionRegistry::class));
$this->assertTrue(class_exists(AutomationServiceProvider::class));
$this->assertTrue(app()->bound(ActionRegistry::class));
}
}
@@ -10,18 +10,51 @@ use Modules\Banners\Models\Banner;
final class BannerController extends Controller
{
public function index(): View { return view('banners::admin.index', ['records' => Banner::query()->latest()->paginate(20)]); }
public function create(): View { return view('banners::admin.form'); }
public function store(Request $request): RedirectResponse { $data = $request->validate(['title' => ['required', 'string', 'max:255'],
public function index(): View
{
return view('banners::admin.index', ['records' => Banner::query()->latest()->paginate(20)]);
}
public function create(): View
{
return view('banners::admin.form');
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate(['title' => ['required', 'string', 'max:255'],
'content' => ['nullable', 'string'],
'link_url' => ['nullable', 'string'],
'position' => ['required', 'string', 'max:255'],
'enabled' => ['sometimes', 'boolean']]); $data['enabled'] = $request->boolean('enabled'); Banner::create($data); return redirect()->route('banners.index')->with('status', 'Banner created.'); }
public function edit(Banner $banner): View { return view('banners::admin.form', ['record' => $banner]); }
public function update(Request $request, Banner $banner): RedirectResponse { $data = $request->validate(['title' => ['required', 'string', 'max:255'],
'enabled' => ['sometimes', 'boolean']]);
$data['enabled'] = $request->boolean('enabled');
Banner::create($data);
return redirect()->route('banners.index')->with('status', 'Banner created.');
}
public function edit(Banner $banner): View
{
return view('banners::admin.form', ['record' => $banner]);
}
public function update(Request $request, Banner $banner): RedirectResponse
{
$data = $request->validate(['title' => ['required', 'string', 'max:255'],
'content' => ['nullable', 'string'],
'link_url' => ['nullable', 'string'],
'position' => ['required', 'string', 'max:255'],
'enabled' => ['sometimes', 'boolean']]); $data['enabled'] = $request->boolean('enabled'); $banner->update($data); return redirect()->route('banners.index')->with('status', 'Banner updated.'); }
public function destroy(Banner $banner): RedirectResponse { $banner->delete(); return back()->with('status', 'Banner deleted.'); }
'enabled' => ['sometimes', 'boolean']]);
$data['enabled'] = $request->boolean('enabled');
$banner->update($data);
return redirect()->route('banners.index')->with('status', 'Banner updated.');
}
public function destroy(Banner $banner): RedirectResponse
{
$banner->delete();
return back()->with('status', 'Banner deleted.');
}
}
+10 -6
View File
@@ -9,11 +9,15 @@ use Illuminate\Database\Eloquent\SoftDeletes;
final class Banner extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'banners';
protected $fillable = ['title', 'content', 'link_url', 'position', 'enabled'];
protected function casts(): array { return [
'enabled' => 'boolean'
]; }
protected $table = 'banners';
protected $fillable = ['title', 'content', 'link_url', 'position', 'enabled'];
protected function casts(): array
{
return [
'enabled' => 'boolean',
];
}
}
+12 -1
View File
@@ -2,4 +2,15 @@
namespace Modules\Banners\Support;
final class ModuleInfo { public static function name(): string { return 'Banners'; } public static function nameLower(): string { return 'banners'; } }
final class ModuleInfo
{
public static function name(): string
{
return 'Banners';
}
public static function nameLower(): string
{
return 'banners';
}
}
@@ -2,4 +2,6 @@
use Illuminate\Support\Facades\Route;
it('registers the Banners administration route', function (): void { expect(Route::has('administration.banners.index'))->toBeTrue(); });
it('registers the Banners administration route', function (): void {
expect(Route::has('administration.banners.index'))->toBeTrue();
});
+1
View File
@@ -1,2 +1,3 @@
<?php
return ['name' => 'Blog'];
@@ -1,3 +1,40 @@
<?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('blog_posts', function(Blueprint $table): void { $table->id(); $table->foreignId('author_id')->nullable()->constrained('authors')->nullOnDelete(); $table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete(); $table->string('title'); $table->string('slug')->unique(); $table->text('excerpt')->nullable(); $table->longText('content')->nullable(); $table->string('featured_image')->nullable(); $table->string('status')->default('draft')->index(); $table->boolean('is_featured')->default(false); $table->timestamp('published_at')->nullable()->index(); $table->timestamp('expires_at')->nullable(); $table->string('meta_title')->nullable(); $table->text('meta_description')->nullable(); $table->timestamps(); }); Schema::create('blog_post_tag', function(Blueprint $table): void { $table->foreignId('blog_post_id')->constrained('blog_posts')->cascadeOnDelete(); $table->foreignId('tag_id')->constrained('tags')->cascadeOnDelete(); $table->primary(['blog_post_id','tag_id']); }); } public function down(): void { Schema::dropIfExists('blog_post_tag'); Schema::dropIfExists('blog_posts'); } };
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('blog_posts', function (Blueprint $table): void {
$table->id();
$table->foreignId('author_id')->nullable()->constrained('authors')->nullOnDelete();
$table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete();
$table->string('title');
$table->string('slug')->unique();
$table->text('excerpt')->nullable();
$table->longText('content')->nullable();
$table->string('featured_image')->nullable();
$table->string('status')->default('draft')->index();
$table->boolean('is_featured')->default(false);
$table->timestamp('published_at')->nullable()->index();
$table->timestamp('expires_at')->nullable();
$table->string('meta_title')->nullable();
$table->text('meta_description')->nullable();
$table->timestamps();
});
Schema::create('blog_post_tag', function (Blueprint $table): void {
$table->foreignId('blog_post_id')->constrained('blog_posts')->cascadeOnDelete();
$table->foreignId('tag_id')->constrained('tags')->cascadeOnDelete();
$table->primary(['blog_post_id', 'tag_id']);
});
}
public function down(): void
{
Schema::dropIfExists('blog_post_tag');
Schema::dropIfExists('blog_posts');
}
};
@@ -1,4 +1,66 @@
<?php
namespace Modules\Blog\Http\Controllers;
use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; use Modules\Authors\Models\Author; use Modules\Categories\Models\Category; use Modules\Tags\Models\Tag; use Modules\Blog\Models\Blog;
final class BlogController extends Controller { public function index(): View { return view('blog::admin.index',['records'=>Blog::with(['author','category'])->latest()->paginate(25)]); } public function create(): View { return view('blog::admin.form',$this->lookups()); } public function store(Request $r): RedirectResponse { $record=Blog::create($this->data($r)); $record->tags()->sync($r->input('tag_ids',[])); return redirect()->route('administration.blog.index')->with('status','Blog created.'); } public function edit(Blog $blog_post): View { return view('blog::admin.form',array_merge($this->lookups(),['record'=>$blog_post])); } public function update(Request $r, Blog $blog_post): RedirectResponse { $blog_post->update($this->data($r)); $blog_post->tags()->sync($r->input('tag_ids',[])); return redirect()->route('administration.blog.index')->with('status','Blog updated.'); } public function destroy(Blog $blog_post): RedirectResponse { $blog_post->delete(); return back()->with('status','Blog deleted.'); } private function lookups(): array { return ['authors'=>Author::where('is_active',true)->orderBy('name')->get(),'categories'=>Category::where('is_active',true)->orderBy('name')->get(),'tags'=>Tag::orderBy('name')->get()]; } private function data(Request $r): array { $d=$r->validate(['author_id'=>['nullable','exists:authors,id'],'category_id'=>['nullable','exists:categories,id'],'title'=>['required','string','max:255'],'slug'=>['required','string','max:255'],'excerpt'=>['nullable','string'],'content'=>['nullable','string'],'featured_image'=>['nullable','string','max:255'],'status'=>['required','in:draft,review,published,archived'],'is_featured'=>['sometimes','boolean'],'published_at'=>['nullable','date'],'expires_at'=>['nullable','date','after:published_at'],'meta_title'=>['nullable','string','max:255'],'meta_description'=>['nullable','string']]); $d['is_featured']=$r->boolean('is_featured'); return $d; } }
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Modules\Authors\Models\Author;
use Modules\Blog\Models\Blog;
use Modules\Categories\Models\Category;
use Modules\Tags\Models\Tag;
final class BlogController extends Controller
{
public function index(): View
{
return view('blog::admin.index', ['records' => Blog::with(['author', 'category'])->latest()->paginate(25)]);
}
public function create(): View
{
return view('blog::admin.form', $this->lookups());
}
public function store(Request $r): RedirectResponse
{
$record = Blog::create($this->data($r));
$record->tags()->sync($r->input('tag_ids', []));
return redirect()->route('administration.blog.index')->with('status', 'Blog created.');
}
public function edit(Blog $blog_post): View
{
return view('blog::admin.form', array_merge($this->lookups(), ['record' => $blog_post]));
}
public function update(Request $r, Blog $blog_post): RedirectResponse
{
$blog_post->update($this->data($r));
$blog_post->tags()->sync($r->input('tag_ids', []));
return redirect()->route('administration.blog.index')->with('status', 'Blog updated.');
}
public function destroy(Blog $blog_post): RedirectResponse
{
$blog_post->delete();
return back()->with('status', 'Blog deleted.');
}
private function lookups(): array
{
return ['authors' => Author::where('is_active', true)->orderBy('name')->get(), 'categories' => Category::where('is_active', true)->orderBy('name')->get(), 'tags' => Tag::orderBy('name')->get()];
}
private function data(Request $r): array
{
$d = $r->validate(['author_id' => ['nullable', 'exists:authors,id'], 'category_id' => ['nullable', 'exists:categories,id'], 'title' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255'], 'excerpt' => ['nullable', 'string'], 'content' => ['nullable', 'string'], 'featured_image' => ['nullable', 'string', 'max:255'], 'status' => ['required', 'in:draft,review,published,archived'], 'is_featured' => ['sometimes', 'boolean'], 'published_at' => ['nullable', 'date'], 'expires_at' => ['nullable', 'date', 'after:published_at'], 'meta_title' => ['nullable', 'string', 'max:255'], 'meta_description' => ['nullable', 'string']]);
$d['is_featured'] = $r->boolean('is_featured');
return $d;
}
}
@@ -1,3 +1,20 @@
<?php
namespace Modules\Blog\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\View\View; use Modules\Blog\Models\Blog;
final class PublicBlogController extends Controller { public function index(): View { return view('blog::public.index',['records'=>Blog::published()->with(['author','category'])->latest('published_at')->paginate(12)]); } public function show(string $slug): View { return view('blog::public.show',['record'=>Blog::published()->with(['author','category','tags'])->where('slug',$slug)->firstOrFail()]); } }
namespace Modules\Blog\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\View\View;
use Modules\Blog\Models\Blog;
final class PublicBlogController extends Controller
{
public function index(): View
{
return view('blog::public.index', ['records' => Blog::published()->with(['author', 'category'])->latest('published_at')->paginate(12)]);
}
public function show(string $slug): View
{
return view('blog::public.show', ['record' => Blog::published()->with(['author', 'category', 'tags'])->where('slug', $slug)->firstOrFail()]);
}
}
+45 -2
View File
@@ -1,5 +1,48 @@
<?php
namespace Modules\Blog\Models;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Modules\Authors\Models\Author; use Modules\Categories\Models\Category; use Modules\Tags\Models\Tag; use Modules\Workflow\Concerns\HasWorkflow; use Modules\Localization\Concerns\HasTranslations;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Modules\Authors\Models\Author;
use Modules\Categories\Models\Category;
use Modules\Localization\Concerns\HasTranslations;
use Modules\Sites\Concerns\BelongsToSites;
final class Blog extends Model { use BelongsToSites, HasTranslations, HasWorkflow; protected $table='blog_posts'; protected $fillable=['author_id','category_id','title','slug','excerpt','content','featured_image','status','is_featured','published_at','expires_at','meta_title','meta_description']; protected function casts(): array { return ['is_featured'=>'boolean','published_at'=>'datetime','expires_at'=>'datetime']; } public function author(): BelongsTo { return $this->belongsTo(Author::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class, 'blog_post_tag'); } public function scopePublished(Builder $query): Builder { return $query->where('status','published')->where(fn(Builder $q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn(Builder $q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now())); } }
use Modules\Tags\Models\Tag;
use Modules\Workflow\Concerns\HasWorkflow;
final class Blog extends Model
{
use BelongsToSites, HasTranslations, HasWorkflow;
protected $table = 'blog_posts';
protected $fillable = ['author_id', 'category_id', 'title', 'slug', 'excerpt', 'content', 'featured_image', 'status', 'is_featured', 'published_at', 'expires_at', 'meta_title', 'meta_description'];
protected function casts(): array
{
return ['is_featured' => 'boolean', 'published_at' => 'datetime', 'expires_at' => 'datetime'];
}
public function author(): BelongsTo
{
return $this->belongsTo(Author::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class, 'blog_post_tag');
}
public function scopePublished(Builder $query): Builder
{
return $query->where('status', 'published')->where(fn (Builder $q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))->where(fn (Builder $q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
}
}
@@ -1,6 +1,9 @@
<?php
namespace Modules\Blog\Providers;
use Illuminate\Support\ServiceProvider;
final class BlogServiceProvider extends ServiceProvider
{
public function register(): void
@@ -8,6 +11,7 @@ final class BlogServiceProvider extends ServiceProvider
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Blog', 'Config/config.php'), 'blog');
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Blog', 'Database/Migrations'));

Some files were not shown because too many files have changed in this diff Show More