style updates
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'default_provider' => env('AI_DEFAULT_PROVIDER'),
|
||||
'timeout' => (int) env('AI_TIMEOUT', 60),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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}].")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+61
-23
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
+44
-18
@@ -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.'); }
|
||||
}
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user