diff --git a/Modules/AI/Config/config.php b/Modules/AI/Config/config.php
index cbd8a62..573f94d 100644
--- a/Modules/AI/Config/config.php
+++ b/Modules/AI/Config/config.php
@@ -1,4 +1,5 @@
env('AI_DEFAULT_PROVIDER'),
'timeout' => (int) env('AI_TIMEOUT', 60),
diff --git a/Modules/AI/Console/PruneAIExecutions.php b/Modules/AI/Console/PruneAIExecutions.php
index 80b1cf9..5993517 100644
--- a/Modules/AI/Console/PruneAIExecutions.php
+++ b/Modules/AI/Console/PruneAIExecutions.php
@@ -1,9 +1,22 @@
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;
+ }
}
diff --git a/Modules/AI/Console/TestAIProvider.php b/Modules/AI/Console/TestAIProvider.php
index 97c8745..7b192bd 100644
--- a/Modules/AI/Console/TestAIProvider.php
+++ b/Modules/AI/Console/TestAIProvider.php
@@ -1,9 +1,21 @@
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;
+ }
}
diff --git a/Modules/AI/Contracts/AIManager.php b/Modules/AI/Contracts/AIManager.php
index 55dda71..d7cfe3a 100644
--- a/Modules/AI/Contracts/AIManager.php
+++ b/Modules/AI/Contracts/AIManager.php
@@ -1,9 +1,13 @@
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');
}
};
diff --git a/Modules/AI/Drivers/OpenAICompatibleDriver.php b/Modules/AI/Drivers/OpenAICompatibleDriver.php
index 2e24a21..74a7d5a 100644
--- a/Modules/AI/Drivers/OpenAICompatibleDriver.php
+++ b/Modules/AI/Drivers/OpenAICompatibleDriver.php
@@ -1,27 +1,36 @@
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.'];
}
}
diff --git a/Modules/AI/Http/Controllers/AIApiController.php b/Modules/AI/Http/Controllers/AIApiController.php
index 779facc..77000a1 100644
--- a/Modules/AI/Http/Controllers/AIApiController.php
+++ b/Modules/AI/Http/Controllers/AIApiController.php
@@ -1,16 +1,20 @@
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);
}
}
diff --git a/Modules/AI/Http/Controllers/AIController.php b/Modules/AI/Http/Controllers/AIController.php
index e9dd69c..96c1f5f 100644
--- a/Modules/AI/Http/Controllers/AIController.php
+++ b/Modules/AI/Http/Controllers/AIController.php
@@ -1,5 +1,7 @@
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.');
}
}
diff --git a/Modules/AI/Models/AIExecution.php b/Modules/AI/Models/AIExecution.php
index 65a571f..62ea778 100644
--- a/Modules/AI/Models/AIExecution.php
+++ b/Modules/AI/Models/AIExecution.php
@@ -1,8 +1,12 @@
'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'];
}
diff --git a/Modules/AI/Models/AIKnowledgeChunk.php b/Modules/AI/Models/AIKnowledgeChunk.php
index 93ac068..632ed3c 100644
--- a/Modules/AI/Models/AIKnowledgeChunk.php
+++ b/Modules/AI/Models/AIKnowledgeChunk.php
@@ -1,8 +1,12 @@
'array'];
+ protected $fillable = ['ai_knowledge_source_id', 'position', 'content', 'metadata'];
+
+ protected $casts = ['metadata' => 'array'];
}
diff --git a/Modules/AI/Models/AIKnowledgeSource.php b/Modules/AI/Models/AIKnowledgeSource.php
index 18fb99e..f863a52 100644
--- a/Modules/AI/Models/AIKnowledgeSource.php
+++ b/Modules/AI/Models/AIKnowledgeSource.php
@@ -1,12 +1,24 @@
'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);
+ }
}
diff --git a/Modules/AI/Models/AIPrompt.php b/Modules/AI/Models/AIPrompt.php
index 3dbed94..5e1a23f 100644
--- a/Modules/AI/Models/AIPrompt.php
+++ b/Modules/AI/Models/AIPrompt.php
@@ -1,12 +1,17 @@
'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;
}
}
diff --git a/Modules/AI/Models/AIProvider.php b/Modules/AI/Models/AIProvider.php
index 7450926..f96e235 100644
--- a/Modules/AI/Models/AIProvider.php
+++ b/Modules/AI/Models/AIProvider.php
@@ -1,9 +1,14 @@
'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'];
}
diff --git a/Modules/AI/Providers/AIServiceProvider.php b/Modules/AI/Providers/AIServiceProvider.php
index 929b215..e7fec1b 100644
--- a/Modules/AI/Providers/AIServiceProvider.php
+++ b/Modules/AI/Providers/AIServiceProvider.php
@@ -1,10 +1,13 @@
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]);
+ }
}
}
diff --git a/Modules/AI/Providers/RouteServiceProvider.php b/Modules/AI/Providers/RouteServiceProvider.php
index 7978a18..4306ef6 100644
--- a/Modules/AI/Providers/RouteServiceProvider.php
+++ b/Modules/AI/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
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');
diff --git a/Modules/AI/Routes/web.php b/Modules/AI/Routes/web.php
index 2d28220..c2acf21 100644
--- a/Modules/AI/Routes/web.php
+++ b/Modules/AI/Routes/web.php
@@ -1,11 +1,13 @@
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');
});
diff --git a/Modules/AI/Services/AIManager.php b/Modules/AI/Services/AIManager.php
index d74fd7f..2f1c2c0 100644
--- a/Modules/AI/Services/AIManager.php
+++ b/Modules/AI/Services/AIManager.php
@@ -1,5 +1,7 @@
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}].")
+ };
}
}
diff --git a/Modules/AI/Services/KnowledgeIndexer.php b/Modules/AI/Services/KnowledgeIndexer.php
index c9eb0ff..9c21d83 100644
--- a/Modules/AI/Services/KnowledgeIndexer.php
+++ b/Modules/AI/Services/KnowledgeIndexer.php
@@ -1,20 +1,39 @@
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;
}
}
diff --git a/Modules/AI/Tests/Feature/ModuleBootTest.php b/Modules/AI/Tests/Feature/ModuleBootTest.php
index 39704ff..ebc1cac 100644
--- a/Modules/AI/Tests/Feature/ModuleBootTest.php
+++ b/Modules/AI/Tests/Feature/ModuleBootTest.php
@@ -1,11 +1,16 @@
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));
}
}
diff --git a/Modules/Administration/Http/Controllers/ModuleController.php b/Modules/Administration/Http/Controllers/ModuleController.php
index 3ad262e..e277d1c 100644
--- a/Modules/Administration/Http/Controllers/ModuleController.php
+++ b/Modules/Administration/Http/Controllers/ModuleController.php
@@ -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.',
diff --git a/Modules/Administration/Support/ModuleInfo.php b/Modules/Administration/Support/ModuleInfo.php
index 975a283..51ef6a8 100644
--- a/Modules/Administration/Support/ModuleInfo.php
+++ b/Modules/Administration/Support/ModuleInfo.php
@@ -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';
+ }
}
diff --git a/Modules/Analytics/Config/config.php b/Modules/Analytics/Config/config.php
index 4984472..b2d0b76 100644
--- a/Modules/Analytics/Config/config.php
+++ b/Modules/Analytics/Config/config.php
@@ -1,4 +1,5 @@
(bool) env('ANALYTICS_ENABLED', true),
'respect_dnt' => (bool) env('ANALYTICS_RESPECT_DNT', true),
diff --git a/Modules/Analytics/Console/AggregateAnalytics.php b/Modules/Analytics/Console/AggregateAnalytics.php
index 99c45af..ae896e5 100644
--- a/Modules/Analytics/Console/AggregateAnalytics.php
+++ b/Modules/Analytics/Console/AggregateAnalytics.php
@@ -1,9 +1,20 @@
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;
+ }
}
diff --git a/Modules/Analytics/Console/PruneAnalytics.php b/Modules/Analytics/Console/PruneAnalytics.php
index c5bb5ac..ead8b95 100644
--- a/Modules/Analytics/Console/PruneAnalytics.php
+++ b/Modules/Analytics/Console/PruneAnalytics.php
@@ -1,9 +1,21 @@
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;
+ }
}
diff --git a/Modules/Analytics/Console/RunScheduledReports.php b/Modules/Analytics/Console/RunScheduledReports.php
index c04bd1f..e169620 100644
--- a/Modules/Analytics/Console/RunScheduledReports.php
+++ b/Modules/Analytics/Console/RunScheduledReports.php
@@ -1,13 +1,21 @@
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;
}
}
diff --git a/Modules/Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php b/Modules/Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php
index 5326366..4cea18d 100644
--- a/Modules/Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php
+++ b/Modules/Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php
@@ -1,42 +1,80 @@
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');
}
};
diff --git a/Modules/Analytics/Http/Controllers/AnalyticsController.php b/Modules/Analytics/Http/Controllers/AnalyticsController.php
index d2ffee9..4552d84 100644
--- a/Modules/Analytics/Http/Controllers/AnalyticsController.php
+++ b/Modules/Analytics/Http/Controllers/AnalyticsController.php
@@ -1,30 +1,43 @@
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.'); }
}
diff --git a/Modules/Analytics/Http/Controllers/TrackingController.php b/Modules/Analytics/Http/Controllers/TrackingController.php
index 31598f4..0523987 100644
--- a/Modules/Analytics/Http/Controllers/TrackingController.php
+++ b/Modules/Analytics/Http/Controllers/TrackingController.php
@@ -1,21 +1,25 @@
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);
}
}
diff --git a/Modules/Analytics/Models/AnalyticsEvent.php b/Modules/Analytics/Models/AnalyticsEvent.php
index 578f089..389e863 100644
--- a/Modules/Analytics/Models/AnalyticsEvent.php
+++ b/Modules/Analytics/Models/AnalyticsEvent.php
@@ -1,8 +1,12 @@
'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'];
}
diff --git a/Modules/Analytics/Models/AnalyticsReport.php b/Modules/Analytics/Models/AnalyticsReport.php
index edbd14e..3d8a81c 100644
--- a/Modules/Analytics/Models/AnalyticsReport.php
+++ b/Modules/Analytics/Models/AnalyticsReport.php
@@ -1,8 +1,12 @@
'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'];
}
diff --git a/Modules/Analytics/Models/AnalyticsReportRun.php b/Modules/Analytics/Models/AnalyticsReportRun.php
index 98829d6..53930be 100644
--- a/Modules/Analytics/Models/AnalyticsReportRun.php
+++ b/Modules/Analytics/Models/AnalyticsReportRun.php
@@ -1,8 +1,12 @@
'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'];
}
diff --git a/Modules/Analytics/Models/DailyMetric.php b/Modules/Analytics/Models/DailyMetric.php
index 6f3ff47..745d269 100644
--- a/Modules/Analytics/Models/DailyMetric.php
+++ b/Modules/Analytics/Models/DailyMetric.php
@@ -1,9 +1,14 @@
'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'];
}
diff --git a/Modules/Analytics/Providers/AnalyticsServiceProvider.php b/Modules/Analytics/Providers/AnalyticsServiceProvider.php
index 1bacbfe..ce1b3d5 100644
--- a/Modules/Analytics/Providers/AnalyticsServiceProvider.php
+++ b/Modules/Analytics/Providers/AnalyticsServiceProvider.php
@@ -1,10 +1,13 @@
app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Analytics', 'Config/config.php'), 'analytics');
}
+
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Analytics', 'Database/Migrations'));
diff --git a/Modules/Analytics/Providers/RouteServiceProvider.php b/Modules/Analytics/Providers/RouteServiceProvider.php
index c1a8eb3..9d62eec 100644
--- a/Modules/Analytics/Providers/RouteServiceProvider.php
+++ b/Modules/Analytics/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
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');
diff --git a/Modules/Analytics/Routes/web.php b/Modules/Analytics/Routes/web.php
index 287b0a3..5a5437e 100644
--- a/Modules/Analytics/Routes/web.php
+++ b/Modules/Analytics/Routes/web.php
@@ -1,8 +1,10 @@
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');
});
diff --git a/Modules/Analytics/Services/AnalyticsAggregator.php b/Modules/Analytics/Services/AnalyticsAggregator.php
index e6bb035..0f08c0d 100644
--- a/Modules/Analytics/Services/AnalyticsAggregator.php
+++ b/Modules/Analytics/Services/AnalyticsAggregator.php
@@ -1,27 +1,32 @@
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;
}
}
diff --git a/Modules/Analytics/Tests/Feature/AnalyticsModuleTest.php b/Modules/Analytics/Tests/Feature/AnalyticsModuleTest.php
index c1217a4..ce99fab 100644
--- a/Modules/Analytics/Tests/Feature/AnalyticsModuleTest.php
+++ b/Modules/Analytics/Tests/Feature/AnalyticsModuleTest.php
@@ -1,13 +1,17 @@
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']);
}
}
diff --git a/Modules/Api/Config/config.php b/Modules/Api/Config/config.php
index 2df068f..b11bfae 100644
--- a/Modules/Api/Config/config.php
+++ b/Modules/Api/Config/config.php
@@ -1,2 +1,3 @@
'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']];
diff --git a/Modules/Api/Database/Migrations/2026_07_24_090000_create_api_tokens_table.php b/Modules/Api/Database/Migrations/2026_07_24_090000_create_api_tokens_table.php
index d0ee037..bb0d7b7 100644
--- a/Modules/Api/Database/Migrations/2026_07_24_090000_create_api_tokens_table.php
+++ b/Modules/Api/Database/Migrations/2026_07_24_090000_create_api_tokens_table.php
@@ -1,3 +1,27 @@
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');
+ }
+};
diff --git a/Modules/Api/Database/Migrations/2026_07_24_090100_create_webhooks_tables.php b/Modules/Api/Database/Migrations/2026_07_24_090100_create_webhooks_tables.php
index ede41c8..113840c 100644
--- a/Modules/Api/Database/Migrations/2026_07_24_090100_create_webhooks_tables.php
+++ b/Modules/Api/Database/Migrations/2026_07_24_090100_create_webhooks_tables.php
@@ -1,3 +1,43 @@
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');
+ }
+};
diff --git a/Modules/Api/Http/Controllers/ApiController.php b/Modules/Api/Http/Controllers/ApiController.php
index c9376ad..fbe22bf 100644
--- a/Modules/Api/Http/Controllers/ApiController.php
+++ b/Modules/Api/Http/Controllers/ApiController.php
@@ -1,4 +1,49 @@
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.');
+ }
+}
diff --git a/Modules/Api/Http/Controllers/ContentController.php b/Modules/Api/Http/Controllers/ContentController.php
index c3b1248..5be30a0 100644
--- a/Modules/Api/Http/Controllers/ContentController.php
+++ b/Modules/Api/Http/Controllers/ContentController.php
@@ -1,4 +1,54 @@
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)));
+ }
+}
diff --git a/Modules/Api/Http/Controllers/OpenApiController.php b/Modules/Api/Http/Controllers/OpenApiController.php
index 7ff703d..f609f27 100644
--- a/Modules/Api/Http/Controllers/OpenApiController.php
+++ b/Modules/Api/Http/Controllers/OpenApiController.php
@@ -1,4 +1,13 @@
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']]]]]]);
+ }
+}
diff --git a/Modules/Api/Http/Middleware/AuthenticateApiToken.php b/Modules/Api/Http/Middleware/AuthenticateApiToken.php
index 7554090..236b1b5 100644
--- a/Modules/Api/Http/Middleware/AuthenticateApiToken.php
+++ b/Modules/Api/Http/Middleware/AuthenticateApiToken.php
@@ -1,4 +1,28 @@
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);
+ }
+}
diff --git a/Modules/Api/Http/Resources/ContentResource.php b/Modules/Api/Http/Resources/ContentResource.php
index 9728e78..1ac7b0a 100644
--- a/Modules/Api/Http/Resources/ContentResource.php
+++ b/Modules/Api/Http/Resources/ContentResource.php
@@ -1,4 +1,14 @@
$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];
+ }
+}
diff --git a/Modules/Api/Models/ApiToken.php b/Modules/Api/Models/ApiToken.php
index e96ced2..6de3874 100644
--- a/Modules/Api/Models/ApiToken.php
+++ b/Modules/Api/Models/ApiToken.php
@@ -1,4 +1,40 @@
'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);
+ }
+}
diff --git a/Modules/Api/Models/Webhook.php b/Modules/Api/Models/Webhook.php
index 58640ca..28d08ef 100644
--- a/Modules/Api/Models/Webhook.php
+++ b/Modules/Api/Models/Webhook.php
@@ -1,4 +1,28 @@
'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);
+ }
+}
diff --git a/Modules/Api/Models/WebhookDelivery.php b/Modules/Api/Models/WebhookDelivery.php
index 5ba8a6e..a07b1a4 100644
--- a/Modules/Api/Models/WebhookDelivery.php
+++ b/Modules/Api/Models/WebhookDelivery.php
@@ -1,4 +1,21 @@
'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);
+ }
+}
diff --git a/Modules/Api/Providers/ApiServiceProvider.php b/Modules/Api/Providers/ApiServiceProvider.php
index 864d378..1f1122c 100644
--- a/Modules/Api/Providers/ApiServiceProvider.php
+++ b/Modules/Api/Providers/ApiServiceProvider.php
@@ -1,5 +1,22 @@
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');
+ }
+}
diff --git a/Modules/Api/Providers/RouteServiceProvider.php b/Modules/Api/Providers/RouteServiceProvider.php
index 8cc3f31..f6699cb 100644
--- a/Modules/Api/Providers/RouteServiceProvider.php
+++ b/Modules/Api/Providers/RouteServiceProvider.php
@@ -1,5 +1,15 @@
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'));
+ }
+}
diff --git a/Modules/Api/Routes/api.php b/Modules/Api/Routes/api.php
index 6dc5978..63c733c 100644
--- a/Modules/Api/Routes/api.php
+++ b/Modules/Api/Routes/api.php
@@ -1,3 +1,18 @@
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());
diff --git a/Modules/Api/Routes/web.php b/Modules/Api/Routes/web.php
index 75532d3..1b2d91a 100644
--- a/Modules/Api/Routes/web.php
+++ b/Modules/Api/Routes/web.php
@@ -1,3 +1,12 @@
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');
+});
diff --git a/Modules/Api/Services/WebhookDispatcher.php b/Modules/Api/Services/WebhookDispatcher.php
index 9e509c3..05da44d 100644
--- a/Modules/Api/Services/WebhookDispatcher.php
+++ b/Modules/Api/Services/WebhookDispatcher.php
@@ -1,4 +1,28 @@
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');
+ }
+ });
+ }
+}
diff --git a/Modules/Api/Tests/Feature/ApiModuleBootTest.php b/Modules/Api/Tests/Feature/ApiModuleBootTest.php
index 4ea6229..f3b4f0a 100644
--- a/Modules/Api/Tests/Feature/ApiModuleBootTest.php
+++ b/Modules/Api/Tests/Feature/ApiModuleBootTest.php
@@ -1,3 +1,13 @@
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'));
diff --git a/Modules/Audit/Database/Migrations/2026_07_23_000200_create_audit_logs_table.php b/Modules/Audit/Database/Migrations/2026_07_23_000200_create_audit_logs_table.php
index f9c167a..81771b0 100644
--- a/Modules/Audit/Database/Migrations/2026_07_23_000200_create_audit_logs_table.php
+++ b/Modules/Audit/Database/Migrations/2026_07_23_000200_create_audit_logs_table.php
@@ -1,3 +1,28 @@
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');
+ }
+};
diff --git a/Modules/Audit/Http/Controllers/AuditController.php b/Modules/Audit/Http/Controllers/AuditController.php
index 0a3944d..f28a59b 100644
--- a/Modules/Audit/Http/Controllers/AuditController.php
+++ b/Modules/Audit/Http/Controllers/AuditController.php
@@ -1,4 +1,14 @@
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)]);
+ }
+}
diff --git a/Modules/Audit/Models/AuditLog.php b/Modules/Audit/Models/AuditLog.php
index d156350..6dce2ae 100644
--- a/Modules/Audit/Models/AuditLog.php
+++ b/Modules/Audit/Models/AuditLog.php
@@ -1,3 +1,17 @@
'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'];
+ }
+}
diff --git a/Modules/Audit/Routes/web.php b/Modules/Audit/Routes/web.php
index f4a649b..f4c314e 100644
--- a/Modules/Audit/Routes/web.php
+++ b/Modules/Audit/Routes/web.php
@@ -1,3 +1,6 @@
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');
diff --git a/Modules/Audit/Support/ModuleInfo.php b/Modules/Audit/Support/ModuleInfo.php
index 632b84f..96f4251 100644
--- a/Modules/Audit/Support/ModuleInfo.php
+++ b/Modules/Audit/Support/ModuleInfo.php
@@ -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';
+ }
}
diff --git a/Modules/Authors/Config/config.php b/Modules/Authors/Config/config.php
index e09f04e..9255ff8 100644
--- a/Modules/Authors/Config/config.php
+++ b/Modules/Authors/Config/config.php
@@ -1,2 +1,3 @@
'Authors'];
diff --git a/Modules/Authors/Database/Migrations/2026_07_24_030300_create_authors_table.php b/Modules/Authors/Database/Migrations/2026_07_24_030300_create_authors_table.php
index 1566e6c..fb9ddce 100644
--- a/Modules/Authors/Database/Migrations/2026_07_24_030300_create_authors_table.php
+++ b/Modules/Authors/Database/Migrations/2026_07_24_030300_create_authors_table.php
@@ -1,3 +1,31 @@
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');
+ }
+};
diff --git a/Modules/Authors/Http/Controllers/AuthorController.php b/Modules/Authors/Http/Controllers/AuthorController.php
index e065d83..a25fa53 100644
--- a/Modules/Authors/Http/Controllers/AuthorController.php
+++ b/Modules/Authors/Http/Controllers/AuthorController.php
@@ -1,4 +1,57 @@
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;
+ }
+}
diff --git a/Modules/Authors/Models/Author.php b/Modules/Authors/Models/Author.php
index 0b51f11..7fa531e 100644
--- a/Modules/Authors/Models/Author.php
+++ b/Modules/Authors/Models/Author.php
@@ -1,3 +1,22 @@
'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);
+ }
+}
diff --git a/Modules/Authors/Providers/AuthorsServiceProvider.php b/Modules/Authors/Providers/AuthorsServiceProvider.php
index a80374d..1df491a 100644
--- a/Modules/Authors/Providers/AuthorsServiceProvider.php
+++ b/Modules/Authors/Providers/AuthorsServiceProvider.php
@@ -1,6 +1,9 @@
app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Authors', 'Config/config.php'), 'authors');
}
+
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Authors', 'Database/Migrations'));
diff --git a/Modules/Authors/Providers/RouteServiceProvider.php b/Modules/Authors/Providers/RouteServiceProvider.php
index c75b852..f3e7ff9 100644
--- a/Modules/Authors/Providers/RouteServiceProvider.php
+++ b/Modules/Authors/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
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');
+});
diff --git a/Modules/Authors/Tests/Feature/ModuleBootTest.php b/Modules/Authors/Tests/Feature/ModuleBootTest.php
index b391a0e..256a4e6 100644
--- a/Modules/Authors/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Authors/Tests/Feature/ModuleBootTest.php
@@ -1,6 +1,9 @@
env('AUTOMATION_QUEUE', 'default'),
'max_steps' => (int) env('AUTOMATION_MAX_STEPS', 50),
diff --git a/Modules/Automation/Console/DispatchScheduledAutomations.php b/Modules/Automation/Console/DispatchScheduledAutomations.php
index 80fd867..b80d5fd 100644
--- a/Modules/Automation/Console/DispatchScheduledAutomations.php
+++ b/Modules/Automation/Console/DispatchScheduledAutomations.php
@@ -1,15 +1,30 @@
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;
}
}
diff --git a/Modules/Automation/Contracts/ActionRegistry.php b/Modules/Automation/Contracts/ActionRegistry.php
index 7c7a41e..49c599c 100644
--- a/Modules/Automation/Contracts/ActionRegistry.php
+++ b/Modules/Automation/Contracts/ActionRegistry.php
@@ -1,8 +1,12 @@
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');
}
};
diff --git a/Modules/Automation/Http/Controllers/AutomationApiController.php b/Modules/Automation/Http/Controllers/AutomationApiController.php
index f361da5..72701c4 100644
--- a/Modules/Automation/Http/Controllers/AutomationApiController.php
+++ b/Modules/Automation/Http/Controllers/AutomationApiController.php
@@ -1,16 +1,24 @@
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);
}
}
diff --git a/Modules/Automation/Http/Controllers/AutomationController.php b/Modules/Automation/Http/Controllers/AutomationController.php
index bde92c5..38524c9 100644
--- a/Modules/Automation/Http/Controllers/AutomationController.php
+++ b/Modules/Automation/Http/Controllers/AutomationController.php
@@ -1,5 +1,7 @@
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.'); }
}
diff --git a/Modules/Automation/Jobs/ExecuteAutomation.php b/Modules/Automation/Jobs/ExecuteAutomation.php
index dc46549..b84d34d 100644
--- a/Modules/Automation/Jobs/ExecuteAutomation.php
+++ b/Modules/Automation/Jobs/ExecuteAutomation.php
@@ -1,5 +1,7 @@
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()]);
+ }
}
}
diff --git a/Modules/Automation/Models/Automation.php b/Modules/Automation/Models/Automation.php
index 4f8a82d..0a060ed 100644
--- a/Modules/Automation/Models/Automation.php
+++ b/Modules/Automation/Models/Automation.php
@@ -1,12 +1,28 @@
'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';
+ }
}
diff --git a/Modules/Automation/Models/AutomationRun.php b/Modules/Automation/Models/AutomationRun.php
index 62febea..38b5fb5 100644
--- a/Modules/Automation/Models/AutomationRun.php
+++ b/Modules/Automation/Models/AutomationRun.php
@@ -1,12 +1,24 @@
'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);
+ }
}
diff --git a/Modules/Automation/Models/AutomationRunLog.php b/Modules/Automation/Models/AutomationRunLog.php
index 4943c03..3d93839 100644
--- a/Modules/Automation/Models/AutomationRunLog.php
+++ b/Modules/Automation/Models/AutomationRunLog.php
@@ -1,9 +1,14 @@
'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'];
}
diff --git a/Modules/Automation/Models/AutomationStep.php b/Modules/Automation/Models/AutomationStep.php
index 620a5c8..2627289 100644
--- a/Modules/Automation/Models/AutomationStep.php
+++ b/Modules/Automation/Models/AutomationStep.php
@@ -1,10 +1,18 @@
'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);
+ }
}
diff --git a/Modules/Automation/Providers/AutomationServiceProvider.php b/Modules/Automation/Providers/AutomationServiceProvider.php
index 4a828c9..b4c3b7c 100644
--- a/Modules/Automation/Providers/AutomationServiceProvider.php
+++ b/Modules/Automation/Providers/AutomationServiceProvider.php
@@ -1,11 +1,14 @@
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]);
+ }
}
}
diff --git a/Modules/Automation/Providers/RouteServiceProvider.php b/Modules/Automation/Providers/RouteServiceProvider.php
index 8f21665..f03573a 100644
--- a/Modules/Automation/Providers/RouteServiceProvider.php
+++ b/Modules/Automation/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
middleware('throttle:60,1')->name('api.automations.trigger');
+
+Route::post('/automations/{automation}/trigger', [AutomationApiController::class, 'trigger'])->middleware('throttle:60,1')->name('api.automations.trigger');
diff --git a/Modules/Automation/Routes/web.php b/Modules/Automation/Routes/web.php
index d6e2c87..342d7c0 100644
--- a/Modules/Automation/Routes/web.php
+++ b/Modules/Automation/Routes/web.php
@@ -1,11 +1,13 @@
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');
});
diff --git a/Modules/Automation/Services/AutomationActionRegistry.php b/Modules/Automation/Services/AutomationActionRegistry.php
index 1e941a5..b8e7f2c 100644
--- a/Modules/Automation/Services/AutomationActionRegistry.php
+++ b/Modules/Automation/Services/AutomationActionRegistry.php
@@ -1,50 +1,79 @@
['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];
}
}
diff --git a/Modules/Automation/Services/AutomationRunner.php b/Modules/Automation/Services/AutomationRunner.php
index 70e7529..254ba8a 100644
--- a/Modules/Automation/Services/AutomationRunner.php
+++ b/Modules/Automation/Services/AutomationRunner.php
@@ -1,28 +1,46 @@
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;
}
}
diff --git a/Modules/Automation/Services/AutomationTriggerRegistry.php b/Modules/Automation/Services/AutomationTriggerRegistry.php
index ed6f884..c0716f4 100644
--- a/Modules/Automation/Services/AutomationTriggerRegistry.php
+++ b/Modules/Automation/Services/AutomationTriggerRegistry.php
@@ -1,16 +1,23 @@
['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());
+ }
}
diff --git a/Modules/Automation/Tests/Feature/ModuleBootTest.php b/Modules/Automation/Tests/Feature/ModuleBootTest.php
index cf6227a..984262f 100644
--- a/Modules/Automation/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Automation/Tests/Feature/ModuleBootTest.php
@@ -1,11 +1,16 @@
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));
}
}
diff --git a/Modules/Banners/Http/Controllers/BannerController.php b/Modules/Banners/Http/Controllers/BannerController.php
index 409dcbc..2650090 100644
--- a/Modules/Banners/Http/Controllers/BannerController.php
+++ b/Modules/Banners/Http/Controllers/BannerController.php
@@ -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.');
+ }
}
diff --git a/Modules/Banners/Models/Banner.php b/Modules/Banners/Models/Banner.php
index 4257112..7ba1279 100644
--- a/Modules/Banners/Models/Banner.php
+++ b/Modules/Banners/Models/Banner.php
@@ -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',
+ ];
+ }
}
diff --git a/Modules/Banners/Support/ModuleInfo.php b/Modules/Banners/Support/ModuleInfo.php
index 45c47a6..09e1bda 100644
--- a/Modules/Banners/Support/ModuleInfo.php
+++ b/Modules/Banners/Support/ModuleInfo.php
@@ -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';
+ }
+}
diff --git a/Modules/Banners/Tests/Feature/ModuleBootTest.php b/Modules/Banners/Tests/Feature/ModuleBootTest.php
index cdea519..6f720b6 100644
--- a/Modules/Banners/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Banners/Tests/Feature/ModuleBootTest.php
@@ -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();
+});
diff --git a/Modules/Blog/Config/config.php b/Modules/Blog/Config/config.php
index e68ab05..97da7a5 100644
--- a/Modules/Blog/Config/config.php
+++ b/Modules/Blog/Config/config.php
@@ -1,2 +1,3 @@
'Blog'];
diff --git a/Modules/Blog/Database/Migrations/2026_07_24_03041_create_blog_posts_table.php b/Modules/Blog/Database/Migrations/2026_07_24_03041_create_blog_posts_table.php
index 0e1305b..30684a4 100644
--- a/Modules/Blog/Database/Migrations/2026_07_24_03041_create_blog_posts_table.php
+++ b/Modules/Blog/Database/Migrations/2026_07_24_03041_create_blog_posts_table.php
@@ -1,3 +1,40 @@
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');
+ }
+};
diff --git a/Modules/Blog/Http/Controllers/BlogController.php b/Modules/Blog/Http/Controllers/BlogController.php
index c5f1de1..0e96878 100644
--- a/Modules/Blog/Http/Controllers/BlogController.php
+++ b/Modules/Blog/Http/Controllers/BlogController.php
@@ -1,4 +1,66 @@
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;
+ }
+}
diff --git a/Modules/Blog/Http/Controllers/PublicBlogController.php b/Modules/Blog/Http/Controllers/PublicBlogController.php
index 84600d4..6fb2096 100644
--- a/Modules/Blog/Http/Controllers/PublicBlogController.php
+++ b/Modules/Blog/Http/Controllers/PublicBlogController.php
@@ -1,3 +1,20 @@
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()]);
+ }
+}
diff --git a/Modules/Blog/Models/Blog.php b/Modules/Blog/Models/Blog.php
index 4b08934..6f58ea0 100644
--- a/Modules/Blog/Models/Blog.php
+++ b/Modules/Blog/Models/Blog.php
@@ -1,5 +1,48 @@
'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()));
+ }
+}
diff --git a/Modules/Blog/Providers/BlogServiceProvider.php b/Modules/Blog/Providers/BlogServiceProvider.php
index 7f1ff3d..74e8bcf 100644
--- a/Modules/Blog/Providers/BlogServiceProvider.php
+++ b/Modules/Blog/Providers/BlogServiceProvider.php
@@ -1,6 +1,9 @@
app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Blog', 'Config/config.php'), 'blog');
}
+
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Blog', 'Database/Migrations'));
diff --git a/Modules/Blog/Providers/RouteServiceProvider.php b/Modules/Blog/Providers/RouteServiceProvider.php
index 38fcab9..6c7b0c0 100644
--- a/Modules/Blog/Providers/RouteServiceProvider.php
+++ b/Modules/Blog/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
prefix('administration/blog')->name('administration.blog.')->group(function (): void { Route::resource('/', BlogController::class)->parameters([''=>'blog_post'])->except('show'); });
-Route::get('/blog',[PublicBlogController::class,'index'])->name('blog.public.index'); Route::get('/blog/{slug}',[PublicBlogController::class,'show'])->name('blog.public.show');
+
+use Illuminate\Support\Facades\Route;
+use Modules\Blog\Http\Controllers\BlogController;
+use Modules\Blog\Http\Controllers\PublicBlogController;
+
+Route::middleware(['auth', 'verified'])->prefix('administration/blog')->name('administration.blog.')->group(function (): void {
+ Route::resource('/', BlogController::class)->parameters(['' => 'blog_post'])->except('show');
+});
+Route::get('/blog', [PublicBlogController::class, 'index'])->name('blog.public.index');
+Route::get('/blog/{slug}', [PublicBlogController::class, 'show'])->name('blog.public.show');
diff --git a/Modules/Builder/Components/ContentComponent.php b/Modules/Builder/Components/ContentComponent.php
index c7380e6..63dc588 100644
--- a/Modules/Builder/Components/ContentComponent.php
+++ b/Modules/Builder/Components/ContentComponent.php
@@ -1,12 +1,38 @@
['type'=>'textarea','required'=>true]]; }
- public function defaults(): array { return ['content'=>'
Add your content here.
']; }
- public function render(array $properties, array $context=[]): string { return (string)($properties['content']??''); }
+ public function type(): string
+ {
+ return 'content';
+ }
+
+ public function label(): string
+ {
+ return 'Rich Content';
+ }
+
+ public function category(): string
+ {
+ return 'Content';
+ }
+
+ public function schema(): array
+ {
+ return ['content' => ['type' => 'textarea', 'required' => true]];
+ }
+
+ public function defaults(): array
+ {
+ return ['content' => 'Add your content here.
'];
+ }
+
+ public function render(array $properties, array $context = []): string
+ {
+ return (string) ($properties['content'] ?? '');
+ }
}
diff --git a/Modules/Builder/Components/HeadingComponent.php b/Modules/Builder/Components/HeadingComponent.php
index b1ed9b0..e619242 100644
--- a/Modules/Builder/Components/HeadingComponent.php
+++ b/Modules/Builder/Components/HeadingComponent.php
@@ -1,12 +1,40 @@
['type'=>'text','required'=>true],'level'=>['type'=>'select','options'=>[1,2,3,4,5,6]]]; }
- public function defaults(): array { return ['text'=>'Heading','level'=>2]; }
- public function render(array $properties, array $context=[]): string { $level=max(1,min(6,(int)($properties['level']??2))); return ''.e($properties['text']??'').''; }
+ public function type(): string
+ {
+ return 'heading';
+ }
+
+ public function label(): string
+ {
+ return 'Heading';
+ }
+
+ public function category(): string
+ {
+ return 'Typography';
+ }
+
+ public function schema(): array
+ {
+ return ['text' => ['type' => 'text', 'required' => true], 'level' => ['type' => 'select', 'options' => [1, 2, 3, 4, 5, 6]]];
+ }
+
+ public function defaults(): array
+ {
+ return ['text' => 'Heading', 'level' => 2];
+ }
+
+ public function render(array $properties, array $context = []): string
+ {
+ $level = max(1, min(6, (int) ($properties['level'] ?? 2)));
+
+ return ''.e($properties['text'] ?? '').'';
+ }
}
diff --git a/Modules/Builder/Components/HeroComponent.php b/Modules/Builder/Components/HeroComponent.php
index cabc7a4..f017dea 100644
--- a/Modules/Builder/Components/HeroComponent.php
+++ b/Modules/Builder/Components/HeroComponent.php
@@ -1,12 +1,38 @@
['type'=>'text','required'=>true],'content'=>['type'=>'textarea'],'button_label'=>['type'=>'text'],'button_url'=>['type'=>'text']]; }
- public function defaults(): array { return ['heading'=>'A compelling headline','content'=>'Add supporting content here.','button_label'=>'Learn more','button_url'=>'#']; }
- public function render(array $properties, array $context=[]): string { return view('builder::components.hero',['properties'=>$properties])->render(); }
+ public function type(): string
+ {
+ return 'hero';
+ }
+
+ public function label(): string
+ {
+ return 'Hero';
+ }
+
+ public function category(): string
+ {
+ return 'Content';
+ }
+
+ public function schema(): array
+ {
+ return ['heading' => ['type' => 'text', 'required' => true], 'content' => ['type' => 'textarea'], 'button_label' => ['type' => 'text'], 'button_url' => ['type' => 'text']];
+ }
+
+ public function defaults(): array
+ {
+ return ['heading' => 'A compelling headline', 'content' => 'Add supporting content here.', 'button_label' => 'Learn more', 'button_url' => '#'];
+ }
+
+ public function render(array $properties, array $context = []): string
+ {
+ return view('builder::components.hero', ['properties' => $properties])->render();
+ }
}
diff --git a/Modules/Builder/Config/config.php b/Modules/Builder/Config/config.php
index d676191..b3a8362 100644
--- a/Modules/Builder/Config/config.php
+++ b/Modules/Builder/Config/config.php
@@ -1,7 +1,8 @@
(int) env('BUILDER_MAX_DOCUMENT_SIZE_KB', 512),
'autosave_seconds' => (int) env('BUILDER_AUTOSAVE_SECONDS', 30),
'preview_route' => env('BUILDER_PREVIEW_ROUTE', 'builder.preview'),
- 'allowed_types' => ['page','form','dashboard','report','menu','email','query','workflow'],
+ 'allowed_types' => ['page', 'form', 'dashboard', 'report', 'menu', 'email', 'query', 'workflow'],
];
diff --git a/Modules/Builder/Contracts/BuilderComponent.php b/Modules/Builder/Contracts/BuilderComponent.php
index be6dfc2..e16fad7 100644
--- a/Modules/Builder/Contracts/BuilderComponent.php
+++ b/Modules/Builder/Contracts/BuilderComponent.php
@@ -1,11 +1,18 @@
id(); $table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();
- $table->string('type',30)->index(); $table->string('name'); $table->string('slug')->nullable();
- $table->string('status',20)->default('draft')->index(); $table->unsignedInteger('version')->default(1);
- $table->json('document')->nullable(); $table->json('settings')->nullable();
- $table->nullableMorphs('buildable'); $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->id();
+ $table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();
+ $table->string('type', 30)->index();
+ $table->string('name');
+ $table->string('slug')->nullable();
+ $table->string('status', 20)->default('draft')->index();
+ $table->unsignedInteger('version')->default(1);
+ $table->json('document')->nullable();
+ $table->json('settings')->nullable();
+ $table->nullableMorphs('buildable');
+ $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
- $table->timestamp('published_at')->nullable(); $table->timestamps();
- $table->unique(['site_id','type','slug'],'builder_document_site_type_slug_unique');
+ $table->timestamp('published_at')->nullable();
+ $table->timestamps();
+ $table->unique(['site_id', 'type', 'slug'], 'builder_document_site_type_slug_unique');
});
Schema::create('builder_document_versions', function (Blueprint $table) {
- $table->id(); $table->foreignId('builder_document_id')->constrained('builder_documents')->cascadeOnDelete();
- $table->unsignedInteger('version'); $table->json('document'); $table->json('settings')->nullable();
- $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); $table->timestamps();
- $table->unique(['builder_document_id','version']);
+ $table->id();
+ $table->foreignId('builder_document_id')->constrained('builder_documents')->cascadeOnDelete();
+ $table->unsignedInteger('version');
+ $table->json('document');
+ $table->json('settings')->nullable();
+ $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+ $table->unique(['builder_document_id', 'version']);
});
Schema::create('builder_templates', function (Blueprint $table) {
- $table->id(); $table->string('type',30)->index(); $table->string('name'); $table->string('slug')->unique();
- $table->text('description')->nullable(); $table->json('document'); $table->json('settings')->nullable();
- $table->boolean('is_system')->default(false); $table->boolean('is_active')->default(true)->index(); $table->timestamps();
+ $table->id();
+ $table->string('type', 30)->index();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->text('description')->nullable();
+ $table->json('document');
+ $table->json('settings')->nullable();
+ $table->boolean('is_system')->default(false);
+ $table->boolean('is_active')->default(true)->index();
+ $table->timestamps();
});
}
- public function down(): void { Schema::dropIfExists('builder_templates'); Schema::dropIfExists('builder_document_versions'); Schema::dropIfExists('builder_documents'); }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('builder_templates');
+ Schema::dropIfExists('builder_document_versions');
+ Schema::dropIfExists('builder_documents');
+ }
};
diff --git a/Modules/Builder/Http/Controllers/BuilderApiController.php b/Modules/Builder/Http/Controllers/BuilderApiController.php
index d962c79..57296aa 100644
--- a/Modules/Builder/Http/Controllers/BuilderApiController.php
+++ b/Modules/Builder/Http/Controllers/BuilderApiController.php
@@ -1,13 +1,27 @@
json(['data'=>$registry->components(),'widgets'=>$registry->widgets(),'fields'=>$registry->fields()]); }
- public function show(BuilderDocument $document): JsonResponse { return response()->json(['data'=>$document]); }
- public function preview(BuilderDocument $document, BuilderRenderer $renderer): JsonResponse { return response()->json(['html'=>$renderer->render($document->document ?? [])]); }
+ public function components(BuilderRegistry $registry): JsonResponse
+ {
+ return response()->json(['data' => $registry->components(), 'widgets' => $registry->widgets(), 'fields' => $registry->fields()]);
+ }
+
+ public function show(BuilderDocument $document): JsonResponse
+ {
+ return response()->json(['data' => $document]);
+ }
+
+ public function preview(BuilderDocument $document, BuilderRenderer $renderer): JsonResponse
+ {
+ return response()->json(['html' => $renderer->render($document->document ?? [])]);
+ }
}
diff --git a/Modules/Builder/Http/Controllers/BuilderController.php b/Modules/Builder/Http/Controllers/BuilderController.php
index 386adde..70443cc 100644
--- a/Modules/Builder/Http/Controllers/BuilderController.php
+++ b/Modules/Builder/Http/Controllers/BuilderController.php
@@ -1,5 +1,7 @@
string('type')->toString();
- $documents=BuilderDocument::query()->when($type,fn($q)=>$q->where('type',$type))->latest()->paginate(20)->withQueryString();
- return view('builder::index',compact('documents','type'));
+ $type = $request->string('type')->toString();
+ $documents = BuilderDocument::query()->when($type, fn ($q) => $q->where('type', $type))->latest()->paginate(20)->withQueryString();
+
+ return view('builder::index', compact('documents', 'type'));
}
- public function create(BuilderRegistry $registry): View { return view('builder::editor',['document'=>new BuilderDocument(['type'=>request('type','page'),'document'=>['nodes'=>[]]]),'components'=>$registry->components()]); }
- public function edit(BuilderDocument $document, BuilderRegistry $registry): View { return view('builder::editor',['document'=>$document,'components'=>$registry->components()]); }
- public function store(Request $request, BuilderDocumentService $service): RedirectResponse { $document=$service->save(new BuilderDocument(),$this->validated($request),$request->user()?->id); return redirect()->route('administration.builder.edit',$document)->with('status','Builder document created.'); }
- public function update(Request $request, BuilderDocument $document, BuilderDocumentService $service): RedirectResponse { $service->save($document,$this->validated($request),$request->user()?->id); return back()->with('status','Builder document saved.'); }
- public function destroy(BuilderDocument $document): RedirectResponse { $document->delete(); return redirect()->route('administration.builder.index')->with('status','Builder document deleted.'); }
+
+ public function create(BuilderRegistry $registry): View
+ {
+ return view('builder::editor', ['document' => new BuilderDocument(['type' => request('type', 'page'), 'document' => ['nodes' => []]]), 'components' => $registry->components()]);
+ }
+
+ public function edit(BuilderDocument $document, BuilderRegistry $registry): View
+ {
+ return view('builder::editor', ['document' => $document, 'components' => $registry->components()]);
+ }
+
+ public function store(Request $request, BuilderDocumentService $service): RedirectResponse
+ {
+ $document = $service->save(new BuilderDocument, $this->validated($request), $request->user()?->id);
+
+ return redirect()->route('administration.builder.edit', $document)->with('status', 'Builder document created.');
+ }
+
+ public function update(Request $request, BuilderDocument $document, BuilderDocumentService $service): RedirectResponse
+ {
+ $service->save($document, $this->validated($request), $request->user()?->id);
+
+ return back()->with('status', 'Builder document saved.');
+ }
+
+ public function destroy(BuilderDocument $document): RedirectResponse
+ {
+ $document->delete();
+
+ return redirect()->route('administration.builder.index')->with('status', 'Builder document deleted.');
+ }
+
private function validated(Request $request): array
{
- $data=$request->validate(['type'=>['required','in:'.implode(',',config('builder.allowed_types'))],'name'=>['required','string','max:255'],'slug'=>['nullable','string','max:255'],'status'=>['required','in:draft,published,archived'],'document_json'=>['required','json'],'settings_json'=>['nullable','json']]);
- return ['type'=>$data['type'],'name'=>$data['name'],'slug'=>$data['slug'] ?: Str::slug($data['name']),'status'=>$data['status'],'document'=>json_decode($data['document_json'],true,512,JSON_THROW_ON_ERROR),'settings'=>json_decode($data['settings_json'] ?: '{}',true,512,JSON_THROW_ON_ERROR),'published_at'=>$data['status']==='published'?now():null];
+ $data = $request->validate(['type' => ['required', 'in:'.implode(',', config('builder.allowed_types'))], 'name' => ['required', 'string', 'max:255'], 'slug' => ['nullable', 'string', 'max:255'], 'status' => ['required', 'in:draft,published,archived'], 'document_json' => ['required', 'json'], 'settings_json' => ['nullable', 'json']]);
+
+ return ['type' => $data['type'], 'name' => $data['name'], 'slug' => $data['slug'] ?: Str::slug($data['name']), 'status' => $data['status'], 'document' => json_decode($data['document_json'], true, 512, JSON_THROW_ON_ERROR), 'settings' => json_decode($data['settings_json'] ?: '{}', true, 512, JSON_THROW_ON_ERROR), 'published_at' => $data['status'] === 'published' ? now() : null];
}
}
diff --git a/Modules/Builder/Models/BuilderDocument.php b/Modules/Builder/Models/BuilderDocument.php
index d8162be..edb53ad 100644
--- a/Modules/Builder/Models/BuilderDocument.php
+++ b/Modules/Builder/Models/BuilderDocument.php
@@ -1,10 +1,18 @@
'array','settings'=>'array','published_at'=>'datetime'];
- public function versions(): HasMany { return $this->hasMany(BuilderDocumentVersion::class); }
+ protected $fillable = ['site_id', 'type', 'name', 'slug', 'status', 'version', 'document', 'settings', 'buildable_type', 'buildable_id', 'created_by', 'updated_by', 'published_at'];
+
+ protected $casts = ['document' => 'array', 'settings' => 'array', 'published_at' => 'datetime'];
+
+ public function versions(): HasMany
+ {
+ return $this->hasMany(BuilderDocumentVersion::class);
+ }
}
diff --git a/Modules/Builder/Models/BuilderDocumentVersion.php b/Modules/Builder/Models/BuilderDocumentVersion.php
index 6ff29f4..30d6a78 100644
--- a/Modules/Builder/Models/BuilderDocumentVersion.php
+++ b/Modules/Builder/Models/BuilderDocumentVersion.php
@@ -1,8 +1,12 @@
'array','settings'=>'array'];
+ protected $fillable = ['builder_document_id', 'version', 'document', 'settings', 'created_by'];
+
+ protected $casts = ['document' => 'array', 'settings' => 'array'];
}
diff --git a/Modules/Builder/Models/BuilderTemplate.php b/Modules/Builder/Models/BuilderTemplate.php
index e857e66..68520d6 100644
--- a/Modules/Builder/Models/BuilderTemplate.php
+++ b/Modules/Builder/Models/BuilderTemplate.php
@@ -1,8 +1,12 @@
'array','settings'=>'array','is_system'=>'boolean','is_active'=>'boolean'];
+ protected $fillable = ['type', 'name', 'slug', 'description', 'document', 'settings', 'is_system', 'is_active'];
+
+ protected $casts = ['document' => 'array', 'settings' => 'array', 'is_system' => 'boolean', 'is_active' => 'boolean'];
}
diff --git a/Modules/Builder/Providers/BuilderServiceProvider.php b/Modules/Builder/Providers/BuilderServiceProvider.php
index b6059c1..d1d9c21 100644
--- a/Modules/Builder/Providers/BuilderServiceProvider.php
+++ b/Modules/Builder/Providers/BuilderServiceProvider.php
@@ -1,7 +1,10 @@
app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Builder', 'Config/config.php'), 'builder');
}
+
public function boot(BuilderRegistry $registry): void
{
$this->loadMigrationsFrom(module_path('Builder', 'Database/Migrations'));
diff --git a/Modules/Builder/Providers/RouteServiceProvider.php b/Modules/Builder/Providers/RouteServiceProvider.php
index 83469a6..e63c673 100644
--- a/Modules/Builder/Providers/RouteServiceProvider.php
+++ b/Modules/Builder/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
prefix('builder')->name('api.builder.')->group(function () {
- Route::get('/components',[BuilderApiController::class,'components'])->name('components');
- Route::get('/documents/{document}',[BuilderApiController::class,'show'])->name('show');
- Route::get('/documents/{document}/preview',[BuilderApiController::class,'preview'])->name('preview');
+ Route::get('/components', [BuilderApiController::class, 'components'])->name('components');
+ Route::get('/documents/{document}', [BuilderApiController::class, 'show'])->name('show');
+ Route::get('/documents/{document}/preview', [BuilderApiController::class, 'preview'])->name('preview');
});
diff --git a/Modules/Builder/Routes/web.php b/Modules/Builder/Routes/web.php
index cd8fbe0..ed43359 100644
--- a/Modules/Builder/Routes/web.php
+++ b/Modules/Builder/Routes/web.php
@@ -1,11 +1,13 @@
prefix('administration/builder')->name('administration.builder.')->group(function () {
- Route::get('/',[BuilderController::class,'index'])->name('index');
- Route::get('/create',[BuilderController::class,'create'])->middleware('permission:builder.manage')->name('create');
- Route::post('/',[BuilderController::class,'store'])->middleware('permission:builder.manage')->name('store');
- Route::get('/{document}/edit',[BuilderController::class,'edit'])->middleware('permission:builder.manage')->name('edit');
- Route::put('/{document}',[BuilderController::class,'update'])->middleware('permission:builder.manage')->name('update');
- Route::delete('/{document}',[BuilderController::class,'destroy'])->middleware('permission:builder.manage')->name('destroy');
+
+Route::middleware(['auth', 'permission:builder.view'])->prefix('administration/builder')->name('administration.builder.')->group(function () {
+ Route::get('/', [BuilderController::class, 'index'])->name('index');
+ Route::get('/create', [BuilderController::class, 'create'])->middleware('permission:builder.manage')->name('create');
+ Route::post('/', [BuilderController::class, 'store'])->middleware('permission:builder.manage')->name('store');
+ Route::get('/{document}/edit', [BuilderController::class, 'edit'])->middleware('permission:builder.manage')->name('edit');
+ Route::put('/{document}', [BuilderController::class, 'update'])->middleware('permission:builder.manage')->name('update');
+ Route::delete('/{document}', [BuilderController::class, 'destroy'])->middleware('permission:builder.manage')->name('destroy');
});
diff --git a/Modules/Builder/Services/BuilderDocumentService.php b/Modules/Builder/Services/BuilderDocumentService.php
index 430b7ec..29f2368 100644
--- a/Modules/Builder/Services/BuilderDocumentService.php
+++ b/Modules/Builder/Services/BuilderDocumentService.php
@@ -1,16 +1,28 @@
exists && $document->isDirty()) $document->versions()->create(['version'=>$document->version,'document'=>$document->getOriginal('document') ?: [],'settings'=>$document->getOriginal('settings') ?: [],'created_by'=>$userId]);
- $document->fill($payload); $document->updated_by=$userId;
- if (!$document->exists) $document->created_by=$userId; else $document->version++;
- $document->save(); return $document->refresh();
+ return DB::transaction(function () use ($document, $payload, $userId) {
+ if ($document->exists && $document->isDirty()) {
+ $document->versions()->create(['version' => $document->version, 'document' => $document->getOriginal('document') ?: [], 'settings' => $document->getOriginal('settings') ?: [], 'created_by' => $userId]);
+ }
+ $document->fill($payload);
+ $document->updated_by = $userId;
+ if (! $document->exists) {
+ $document->created_by = $userId;
+ } else {
+ $document->version++;
+ }
+ $document->save();
+
+ return $document->refresh();
});
}
}
diff --git a/Modules/Builder/Services/BuilderRegistry.php b/Modules/Builder/Services/BuilderRegistry.php
index b561a89..0e63193 100644
--- a/Modules/Builder/Services/BuilderRegistry.php
+++ b/Modules/Builder/Services/BuilderRegistry.php
@@ -1,24 +1,56 @@
registerComponent(new $component());
- foreach (['metric','chart','table','activity','calendar'] as $widget) $this->widgets[$widget]=['type'=>$widget,'label'=>str($widget)->headline()->toString()];
- foreach (['text','textarea','email','number','select','checkbox','date','file'] as $field) $this->fields[$field]=['type'=>$field,'label'=>str($field)->headline()->toString()];
+ foreach ([HeroComponent::class, HeadingComponent::class, ContentComponent::class] as $component) {
+ $this->registerComponent(new $component);
+ }
+ foreach (['metric', 'chart', 'table', 'activity', 'calendar'] as $widget) {
+ $this->widgets[$widget] = ['type' => $widget, 'label' => str($widget)->headline()->toString()];
+ }
+ foreach (['text', 'textarea', 'email', 'number', 'select', 'checkbox', 'date', 'file'] as $field) {
+ $this->fields[$field] = ['type' => $field, 'label' => str($field)->headline()->toString()];
+ }
+ }
+
+ public function registerComponent(BuilderComponent $component): void
+ {
+ $this->components[$component->type()] = $component;
+ }
+
+ public function component(string $type): BuilderComponent
+ {
+ return $this->components[$type] ?? throw new InvalidArgumentException("Unknown builder component [$type].");
+ }
+
+ public function components(): array
+ {
+ return array_map(fn (BuilderComponent $c) => ['type' => $c->type(), 'label' => $c->label(), 'category' => $c->category(), 'schema' => $c->schema(), 'defaults' => $c->defaults()], array_values($this->components));
+ }
+
+ public function widgets(): array
+ {
+ return array_values($this->widgets);
+ }
+
+ public function fields(): array
+ {
+ return array_values($this->fields);
}
- public function registerComponent(BuilderComponent $component): void { $this->components[$component->type()]=$component; }
- public function component(string $type): BuilderComponent { return $this->components[$type] ?? throw new InvalidArgumentException("Unknown builder component [$type]."); }
- public function components(): array { return array_map(fn(BuilderComponent $c)=>['type'=>$c->type(),'label'=>$c->label(),'category'=>$c->category(),'schema'=>$c->schema(),'defaults'=>$c->defaults()],array_values($this->components)); }
- public function widgets(): array { return array_values($this->widgets); }
- public function fields(): array { return array_values($this->fields); }
}
diff --git a/Modules/Builder/Services/BuilderRenderer.php b/Modules/Builder/Services/BuilderRenderer.php
index 7b76e87..2b8a647 100644
--- a/Modules/Builder/Services/BuilderRenderer.php
+++ b/Modules/Builder/Services/BuilderRenderer.php
@@ -1,13 +1,19 @@
map(function(array $node) use ($context) {
- if (($node['kind'] ?? 'component') === 'container') return ''.$this->render($node,$context).'
';
- return $this->registry->component((string)($node['type'] ?? 'content'))->render($node['properties'] ?? [],$context);
+ return collect($document['nodes'] ?? [])->map(function (array $node) use ($context) {
+ if (($node['kind'] ?? 'component') === 'container') {
+ return ''.$this->render($node, $context).'
';
+ }
+
+ return $this->registry->component((string) ($node['type'] ?? 'content'))->render($node['properties'] ?? [], $context);
})->implode("\n");
}
}
diff --git a/Modules/Builder/Tests/Feature/BuilderModuleTest.php b/Modules/Builder/Tests/Feature/BuilderModuleTest.php
index 3ed3a78..0844567 100644
--- a/Modules/Builder/Tests/Feature/BuilderModuleTest.php
+++ b/Modules/Builder/Tests/Feature/BuilderModuleTest.php
@@ -1,13 +1,16 @@
components();
+ $components = app(BuilderRegistry::class)->components();
$this->assertNotEmpty($components);
- $this->assertContains('hero',array_column($components,'type'));
+ $this->assertContains('hero', array_column($components, 'type'));
}
}
diff --git a/Modules/CRM/Config/config.php b/Modules/CRM/Config/config.php
index f4ee058..c94cac3 100644
--- a/Modules/CRM/Config/config.php
+++ b/Modules/CRM/Config/config.php
@@ -1,2 +1,3 @@
'CRM'];
diff --git a/Modules/CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php b/Modules/CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php
index 25e7049..cf3bc64 100644
--- a/Modules/CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php
+++ b/Modules/CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php
@@ -1,9 +1,79 @@
id(); $t->string('name'); $t->string('website')->nullable(); $t->string('phone')->nullable(); $t->string('email')->nullable(); $t->string('industry')->nullable(); $t->text('address')->nullable(); $t->text('notes')->nullable(); $t->boolean('is_active')->default(true); $t->timestamps(); });
-Schema::create('crm_contacts', function(Blueprint $t): void { $t->id(); $t->foreignId('company_id')->nullable()->constrained('crm_companies')->nullOnDelete(); $t->string('first_name'); $t->string('last_name'); $t->string('email')->nullable()->index(); $t->string('phone')->nullable(); $t->string('job_title')->nullable(); $t->text('notes')->nullable(); $t->boolean('is_active')->default(true); $t->timestamps(); });
-Schema::create('crm_leads', function(Blueprint $t): void { $t->id(); $t->foreignId('contact_id')->nullable()->constrained('crm_contacts')->nullOnDelete(); $t->string('title'); $t->string('source')->nullable(); $t->string('status')->default('new')->index(); $t->decimal('estimated_value',12,2)->nullable(); $t->text('notes')->nullable(); $t->timestamp('next_action_at')->nullable(); $t->timestamps(); });
-Schema::create('crm_opportunities', function(Blueprint $t): void { $t->id(); $t->foreignId('company_id')->nullable()->constrained('crm_companies')->nullOnDelete(); $t->foreignId('contact_id')->nullable()->constrained('crm_contacts')->nullOnDelete(); $t->string('name'); $t->string('stage')->default('qualification')->index(); $t->decimal('value',12,2)->default(0); $t->unsignedTinyInteger('probability')->default(0); $t->date('expected_close_at')->nullable(); $t->text('notes')->nullable(); $t->timestamps(); });
-Schema::create('crm_activities', function(Blueprint $t): void { $t->id(); $t->nullableMorphs('subject'); $t->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); $t->string('type'); $t->string('title'); $t->text('description')->nullable(); $t->timestamp('due_at')->nullable(); $t->timestamp('completed_at')->nullable(); $t->timestamps(); });
-} public function down(): void { Schema::dropIfExists('crm_activities'); Schema::dropIfExists('crm_opportunities'); Schema::dropIfExists('crm_leads'); Schema::dropIfExists('crm_contacts'); Schema::dropIfExists('crm_companies'); } };
+
+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('crm_companies', function (Blueprint $t): void {
+ $t->id();
+ $t->string('name');
+ $t->string('website')->nullable();
+ $t->string('phone')->nullable();
+ $t->string('email')->nullable();
+ $t->string('industry')->nullable();
+ $t->text('address')->nullable();
+ $t->text('notes')->nullable();
+ $t->boolean('is_active')->default(true);
+ $t->timestamps();
+ });
+ Schema::create('crm_contacts', function (Blueprint $t): void {
+ $t->id();
+ $t->foreignId('company_id')->nullable()->constrained('crm_companies')->nullOnDelete();
+ $t->string('first_name');
+ $t->string('last_name');
+ $t->string('email')->nullable()->index();
+ $t->string('phone')->nullable();
+ $t->string('job_title')->nullable();
+ $t->text('notes')->nullable();
+ $t->boolean('is_active')->default(true);
+ $t->timestamps();
+ });
+ Schema::create('crm_leads', function (Blueprint $t): void {
+ $t->id();
+ $t->foreignId('contact_id')->nullable()->constrained('crm_contacts')->nullOnDelete();
+ $t->string('title');
+ $t->string('source')->nullable();
+ $t->string('status')->default('new')->index();
+ $t->decimal('estimated_value', 12, 2)->nullable();
+ $t->text('notes')->nullable();
+ $t->timestamp('next_action_at')->nullable();
+ $t->timestamps();
+ });
+ Schema::create('crm_opportunities', function (Blueprint $t): void {
+ $t->id();
+ $t->foreignId('company_id')->nullable()->constrained('crm_companies')->nullOnDelete();
+ $t->foreignId('contact_id')->nullable()->constrained('crm_contacts')->nullOnDelete();
+ $t->string('name');
+ $t->string('stage')->default('qualification')->index();
+ $t->decimal('value', 12, 2)->default(0);
+ $t->unsignedTinyInteger('probability')->default(0);
+ $t->date('expected_close_at')->nullable();
+ $t->text('notes')->nullable();
+ $t->timestamps();
+ });
+ Schema::create('crm_activities', function (Blueprint $t): void {
+ $t->id();
+ $t->nullableMorphs('subject');
+ $t->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
+ $t->string('type');
+ $t->string('title');
+ $t->text('description')->nullable();
+ $t->timestamp('due_at')->nullable();
+ $t->timestamp('completed_at')->nullable();
+ $t->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('crm_activities');
+ Schema::dropIfExists('crm_opportunities');
+ Schema::dropIfExists('crm_leads');
+ Schema::dropIfExists('crm_contacts');
+ Schema::dropIfExists('crm_companies');
+ }
+};
diff --git a/Modules/CRM/Http/Controllers/CRMController.php b/Modules/CRM/Http/Controllers/CRMController.php
index c3ab23b..0d85cd6 100644
--- a/Modules/CRM/Http/Controllers/CRMController.php
+++ b/Modules/CRM/Http/Controllers/CRMController.php
@@ -1,11 +1,55 @@
Company::withCount('contacts')->latest()->paginate(20),'contacts'=>Contact::with('company')->latest()->limit(10)->get(),'leads'=>Lead::with('contact')->latest()->limit(10)->get(),'opportunities'=>Opportunity::with('company')->latest()->limit(10)->get(),'pipelineValue'=>Opportunity::whereNotIn('stage',['won','lost'])->sum('value')]); }
-public function storeCompany(Request $r): RedirectResponse { Company::create($r->validate(['name'=>['required','string','max:255'],'website'=>['nullable','url','max:255'],'phone'=>['nullable','string','max:80'],'email'=>['nullable','email','max:255'],'industry'=>['nullable','string','max:120'],'address'=>['nullable','string'],'notes'=>['nullable','string']])+['is_active'=>$r->boolean('is_active',true)]); return back()->with('status','Company created.'); }
-public function storeContact(Request $r): RedirectResponse { Contact::create($r->validate(['company_id'=>['nullable','exists:crm_companies,id'],'first_name'=>['required','string','max:120'],'last_name'=>['required','string','max:120'],'email'=>['nullable','email','max:255'],'phone'=>['nullable','string','max:80'],'job_title'=>['nullable','string','max:120'],'notes'=>['nullable','string']])+['is_active'=>$r->boolean('is_active',true)]); return back()->with('status','Contact created.'); }
-public function storeLead(Request $r): RedirectResponse { Lead::create($r->validate(['contact_id'=>['nullable','exists:crm_contacts,id'],'title'=>['required','string','max:255'],'source'=>['nullable','string','max:120'],'status'=>['required','in:new,qualified,proposal,won,lost'],'estimated_value'=>['nullable','numeric','min:0'],'notes'=>['nullable','string'],'next_action_at'=>['nullable','date']])); return back()->with('status','Lead created.'); }
-public function storeOpportunity(Request $r): RedirectResponse { Opportunity::create($r->validate(['company_id'=>['nullable','exists:crm_companies,id'],'contact_id'=>['nullable','exists:crm_contacts,id'],'name'=>['required','string','max:255'],'stage'=>['required','in:qualification,proposal,negotiation,won,lost'],'value'=>['required','numeric','min:0'],'probability'=>['required','integer','between:0,100'],'expected_close_at'=>['nullable','date'],'notes'=>['nullable','string']])); return back()->with('status','Opportunity created.'); }
-public function destroyCompany(Company $company): RedirectResponse { $company->delete(); return back()->with('status','Company deleted.'); }
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\View\View;
+use Modules\CRM\Models\Company;
+use Modules\CRM\Models\Contact;
+use Modules\CRM\Models\Lead;
+use Modules\CRM\Models\Opportunity;
+
+final class CRMController extends Controller
+{
+ public function index(): View
+ {
+ return view('crm::admin.index', ['companies' => Company::withCount('contacts')->latest()->paginate(20), 'contacts' => Contact::with('company')->latest()->limit(10)->get(), 'leads' => Lead::with('contact')->latest()->limit(10)->get(), 'opportunities' => Opportunity::with('company')->latest()->limit(10)->get(), 'pipelineValue' => Opportunity::whereNotIn('stage', ['won', 'lost'])->sum('value')]);
+ }
+
+ public function storeCompany(Request $r): RedirectResponse
+ {
+ Company::create($r->validate(['name' => ['required', 'string', 'max:255'], 'website' => ['nullable', 'url', 'max:255'], 'phone' => ['nullable', 'string', 'max:80'], 'email' => ['nullable', 'email', 'max:255'], 'industry' => ['nullable', 'string', 'max:120'], 'address' => ['nullable', 'string'], 'notes' => ['nullable', 'string']]) + ['is_active' => $r->boolean('is_active', true)]);
+
+ return back()->with('status', 'Company created.');
+ }
+
+ public function storeContact(Request $r): RedirectResponse
+ {
+ Contact::create($r->validate(['company_id' => ['nullable', 'exists:crm_companies,id'], 'first_name' => ['required', 'string', 'max:120'], 'last_name' => ['required', 'string', 'max:120'], 'email' => ['nullable', 'email', 'max:255'], 'phone' => ['nullable', 'string', 'max:80'], 'job_title' => ['nullable', 'string', 'max:120'], 'notes' => ['nullable', 'string']]) + ['is_active' => $r->boolean('is_active', true)]);
+
+ return back()->with('status', 'Contact created.');
+ }
+
+ public function storeLead(Request $r): RedirectResponse
+ {
+ Lead::create($r->validate(['contact_id' => ['nullable', 'exists:crm_contacts,id'], 'title' => ['required', 'string', 'max:255'], 'source' => ['nullable', 'string', 'max:120'], 'status' => ['required', 'in:new,qualified,proposal,won,lost'], 'estimated_value' => ['nullable', 'numeric', 'min:0'], 'notes' => ['nullable', 'string'], 'next_action_at' => ['nullable', 'date']]));
+
+ return back()->with('status', 'Lead created.');
+ }
+
+ public function storeOpportunity(Request $r): RedirectResponse
+ {
+ Opportunity::create($r->validate(['company_id' => ['nullable', 'exists:crm_companies,id'], 'contact_id' => ['nullable', 'exists:crm_contacts,id'], 'name' => ['required', 'string', 'max:255'], 'stage' => ['required', 'in:qualification,proposal,negotiation,won,lost'], 'value' => ['required', 'numeric', 'min:0'], 'probability' => ['required', 'integer', 'between:0,100'], 'expected_close_at' => ['nullable', 'date'], 'notes' => ['nullable', 'string']]));
+
+ return back()->with('status', 'Opportunity created.');
+ }
+
+ public function destroyCompany(Company $company): RedirectResponse
+ {
+ $company->delete();
+
+ return back()->with('status', 'Company deleted.');
+ }
}
diff --git a/Modules/CRM/Models/Activity.php b/Modules/CRM/Models/Activity.php
index 6574a76..1a2e8b6 100644
--- a/Modules/CRM/Models/Activity.php
+++ b/Modules/CRM/Models/Activity.php
@@ -1,3 +1,23 @@
'datetime','completed_at'=>'datetime']; } public function subject(): MorphTo { return $this->morphTo(); } }
+
+namespace Modules\CRM\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
+
+final class Activity extends Model
+{
+ protected $table = 'crm_activities';
+
+ protected $fillable = ['subject_type', 'subject_id', 'user_id', 'type', 'title', 'description', 'due_at', 'completed_at'];
+
+ protected function casts(): array
+ {
+ return ['due_at' => 'datetime', 'completed_at' => 'datetime'];
+ }
+
+ public function subject(): MorphTo
+ {
+ return $this->morphTo();
+ }
+}
diff --git a/Modules/CRM/Models/Company.php b/Modules/CRM/Models/Company.php
index 2eab9f6..76bcf10 100644
--- a/Modules/CRM/Models/Company.php
+++ b/Modules/CRM/Models/Company.php
@@ -1,3 +1,23 @@
'boolean']; } public function contacts(): HasMany { return $this->hasMany(Contact::class); } }
+
+namespace Modules\CRM\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+
+final class Company extends Model
+{
+ protected $table = 'crm_companies';
+
+ protected $fillable = ['name', 'website', 'phone', 'email', 'industry', 'address', 'notes', 'is_active'];
+
+ protected function casts(): array
+ {
+ return ['is_active' => 'boolean'];
+ }
+
+ public function contacts(): HasMany
+ {
+ return $this->hasMany(Contact::class);
+ }
+}
diff --git a/Modules/CRM/Models/Contact.php b/Modules/CRM/Models/Contact.php
index 0a8f29a..e1f08cb 100644
--- a/Modules/CRM/Models/Contact.php
+++ b/Modules/CRM/Models/Contact.php
@@ -1,3 +1,28 @@
'boolean']; } public function company(): BelongsTo { return $this->belongsTo(Company::class); } public function getNameAttribute(): string { return trim($this->first_name.' '.$this->last_name); } }
+
+namespace Modules\CRM\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+final class Contact extends Model
+{
+ protected $table = 'crm_contacts';
+
+ protected $fillable = ['company_id', 'first_name', 'last_name', 'email', 'phone', 'job_title', 'notes', 'is_active'];
+
+ protected function casts(): array
+ {
+ return ['is_active' => 'boolean'];
+ }
+
+ public function company(): BelongsTo
+ {
+ return $this->belongsTo(Company::class);
+ }
+
+ public function getNameAttribute(): string
+ {
+ return trim($this->first_name.' '.$this->last_name);
+ }
+}
diff --git a/Modules/CRM/Models/Lead.php b/Modules/CRM/Models/Lead.php
index 93c50c1..571b5dd 100644
--- a/Modules/CRM/Models/Lead.php
+++ b/Modules/CRM/Models/Lead.php
@@ -1,3 +1,23 @@
'decimal:2','next_action_at'=>'datetime']; } public function contact(): BelongsTo { return $this->belongsTo(Contact::class); } }
+
+namespace Modules\CRM\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+final class Lead extends Model
+{
+ protected $table = 'crm_leads';
+
+ protected $fillable = ['contact_id', 'title', 'source', 'status', 'estimated_value', 'notes', 'next_action_at'];
+
+ protected function casts(): array
+ {
+ return ['estimated_value' => 'decimal:2', 'next_action_at' => 'datetime'];
+ }
+
+ public function contact(): BelongsTo
+ {
+ return $this->belongsTo(Contact::class);
+ }
+}
diff --git a/Modules/CRM/Models/Opportunity.php b/Modules/CRM/Models/Opportunity.php
index fbe11ed..6cd7884 100644
--- a/Modules/CRM/Models/Opportunity.php
+++ b/Modules/CRM/Models/Opportunity.php
@@ -1,3 +1,28 @@
'decimal:2','expected_close_at'=>'date']; } public function company(): BelongsTo { return $this->belongsTo(Company::class); } public function contact(): BelongsTo { return $this->belongsTo(Contact::class); } }
+
+namespace Modules\CRM\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+final class Opportunity extends Model
+{
+ protected $table = 'crm_opportunities';
+
+ protected $fillable = ['company_id', 'contact_id', 'name', 'stage', 'value', 'probability', 'expected_close_at', 'notes'];
+
+ protected function casts(): array
+ {
+ return ['value' => 'decimal:2', 'expected_close_at' => 'date'];
+ }
+
+ public function company(): BelongsTo
+ {
+ return $this->belongsTo(Company::class);
+ }
+
+ public function contact(): BelongsTo
+ {
+ return $this->belongsTo(Contact::class);
+ }
+}
diff --git a/Modules/CRM/Providers/CRMServiceProvider.php b/Modules/CRM/Providers/CRMServiceProvider.php
index c987cf0..ba46a79 100644
--- a/Modules/CRM/Providers/CRMServiceProvider.php
+++ b/Modules/CRM/Providers/CRMServiceProvider.php
@@ -1,6 +1,9 @@
app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('CRM', 'Config/config.php'), 'crm');
}
+
public function boot(): void
{
$this->loadMigrationsFrom(module_path('CRM', 'Database/Migrations'));
diff --git a/Modules/CRM/Providers/RouteServiceProvider.php b/Modules/CRM/Providers/RouteServiceProvider.php
index 58e1855..94e56b2 100644
--- a/Modules/CRM/Providers/RouteServiceProvider.php
+++ b/Modules/CRM/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
prefix('administration/crm')->name('administration.crm.')->group(function(): void { Route::get('/',[CRMController::class,'index'])->name('index'); Route::post('/companies',[CRMController::class,'storeCompany'])->name('companies.store'); Route::delete('/companies/{company}',[CRMController::class,'destroyCompany'])->name('companies.destroy'); Route::post('/contacts',[CRMController::class,'storeContact'])->name('contacts.store'); Route::post('/leads',[CRMController::class,'storeLead'])->name('leads.store'); Route::post('/opportunities',[CRMController::class,'storeOpportunity'])->name('opportunities.store'); });
+
+use Illuminate\Support\Facades\Route;
+use Modules\CRM\Http\Controllers\CRMController;
+
+Route::middleware(['auth', 'verified'])->prefix('administration/crm')->name('administration.crm.')->group(function (): void {
+ Route::get('/', [CRMController::class, 'index'])->name('index');
+ Route::post('/companies', [CRMController::class, 'storeCompany'])->name('companies.store');
+ Route::delete('/companies/{company}', [CRMController::class, 'destroyCompany'])->name('companies.destroy');
+ Route::post('/contacts', [CRMController::class, 'storeContact'])->name('contacts.store');
+ Route::post('/leads', [CRMController::class, 'storeLead'])->name('leads.store');
+ Route::post('/opportunities', [CRMController::class, 'storeOpportunity'])->name('opportunities.store');
+});
diff --git a/Modules/CRM/Tests/Feature/ModuleBootTest.php b/Modules/CRM/Tests/Feature/ModuleBootTest.php
index ad355a2..3bbcf6f 100644
--- a/Modules/CRM/Tests/Feature/ModuleBootTest.php
+++ b/Modules/CRM/Tests/Feature/ModuleBootTest.php
@@ -1,6 +1,9 @@
'Careers'];
diff --git a/Modules/Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php b/Modules/Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php
index a0a1cf6..c8627af 100644
--- a/Modules/Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php
+++ b/Modules/Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php
@@ -1,3 +1,43 @@
id();$t->string('title');$t->string('slug')->unique();$t->string('department')->nullable();$t->string('location')->nullable();$t->string('employment_type')->default('full-time');$t->longText('description')->nullable();$t->string('status')->default('draft')->index();$t->timestamp('published_at')->nullable();$t->timestamp('closes_at')->nullable();$t->timestamps();}); Schema::create('career_applications',function(Blueprint $t): void{$t->id();$t->foreignId('job_id')->constrained('career_jobs')->cascadeOnDelete();$t->string('name');$t->string('email')->index();$t->string('phone')->nullable();$t->string('cv_path')->nullable();$t->text('cover_letter')->nullable();$t->string('status')->default('new')->index();$t->text('notes')->nullable();$t->timestamps();}); } public function down(): void { Schema::dropIfExists('career_applications');Schema::dropIfExists('career_jobs'); } };
+
+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('career_jobs', function (Blueprint $t): void {
+ $t->id();
+ $t->string('title');
+ $t->string('slug')->unique();
+ $t->string('department')->nullable();
+ $t->string('location')->nullable();
+ $t->string('employment_type')->default('full-time');
+ $t->longText('description')->nullable();
+ $t->string('status')->default('draft')->index();
+ $t->timestamp('published_at')->nullable();
+ $t->timestamp('closes_at')->nullable();
+ $t->timestamps();
+ });
+ Schema::create('career_applications', function (Blueprint $t): void {
+ $t->id();
+ $t->foreignId('job_id')->constrained('career_jobs')->cascadeOnDelete();
+ $t->string('name');
+ $t->string('email')->index();
+ $t->string('phone')->nullable();
+ $t->string('cv_path')->nullable();
+ $t->text('cover_letter')->nullable();
+ $t->string('status')->default('new')->index();
+ $t->text('notes')->nullable();
+ $t->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('career_applications');
+ Schema::dropIfExists('career_jobs');
+ }
+};
diff --git a/Modules/Careers/Http/Controllers/CareerController.php b/Modules/Careers/Http/Controllers/CareerController.php
index 9fce28b..d9c2bad 100644
--- a/Modules/Careers/Http/Controllers/CareerController.php
+++ b/Modules/Careers/Http/Controllers/CareerController.php
@@ -1,3 +1,32 @@
Job::withCount('applications')->latest()->paginate(20),'applications'=>Application::latest()->limit(10)->get()]); } public function store(Request $r): RedirectResponse { Job::create($r->validate(['title'=>['required','string','max:255'],'slug'=>['required','string','max:255','unique:career_jobs,slug'],'department'=>['nullable','string','max:120'],'location'=>['nullable','string','max:120'],'employment_type'=>['required','in:full-time,part-time,contract,temporary,internship'],'description'=>['nullable','string'],'status'=>['required','in:draft,published,closed'],'published_at'=>['nullable','date'],'closes_at'=>['nullable','date','after:published_at']])); return back()->with('status','Job created.'); } public function destroy(Job $job): RedirectResponse { $job->delete(); return back()->with('status','Job deleted.'); } }
+
+namespace Modules\Careers\Http\Controllers;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\View\View;
+use Modules\Careers\Models\Application;
+use Modules\Careers\Models\Job;
+
+final class CareerController extends Controller
+{
+ public function index(): View
+ {
+ return view('careers::admin.index', ['jobs' => Job::withCount('applications')->latest()->paginate(20), 'applications' => Application::latest()->limit(10)->get()]);
+ }
+
+ public function store(Request $r): RedirectResponse
+ {
+ Job::create($r->validate(['title' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255', 'unique:career_jobs,slug'], 'department' => ['nullable', 'string', 'max:120'], 'location' => ['nullable', 'string', 'max:120'], 'employment_type' => ['required', 'in:full-time,part-time,contract,temporary,internship'], 'description' => ['nullable', 'string'], 'status' => ['required', 'in:draft,published,closed'], 'published_at' => ['nullable', 'date'], 'closes_at' => ['nullable', 'date', 'after:published_at']]));
+
+ return back()->with('status', 'Job created.');
+ }
+
+ public function destroy(Job $job): RedirectResponse
+ {
+ $job->delete();
+
+ return back()->with('status', 'Job deleted.');
+ }
+}
diff --git a/Modules/Careers/Models/Application.php b/Modules/Careers/Models/Application.php
index dcdf3c1..e85d73e 100644
--- a/Modules/Careers/Models/Application.php
+++ b/Modules/Careers/Models/Application.php
@@ -1,3 +1,12 @@
'datetime','closes_at'=>'datetime']; } public function applications(): HasMany { return $this->hasMany(Application::class); } }
+
+namespace Modules\Careers\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+
+final class Job extends Model
+{
+ protected $table = 'career_jobs';
+
+ protected $fillable = ['title', 'slug', 'department', 'location', 'employment_type', 'description', 'status', 'published_at', 'closes_at'];
+
+ protected function casts(): array
+ {
+ return ['published_at' => 'datetime', 'closes_at' => 'datetime'];
+ }
+
+ public function applications(): HasMany
+ {
+ return $this->hasMany(Application::class);
+ }
+}
diff --git a/Modules/Careers/Providers/CareersServiceProvider.php b/Modules/Careers/Providers/CareersServiceProvider.php
index c730a60..5f4cfff 100644
--- a/Modules/Careers/Providers/CareersServiceProvider.php
+++ b/Modules/Careers/Providers/CareersServiceProvider.php
@@ -1,6 +1,9 @@
app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Careers', 'Config/config.php'), 'careers');
}
+
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Careers', 'Database/Migrations'));
diff --git a/Modules/Careers/Providers/RouteServiceProvider.php b/Modules/Careers/Providers/RouteServiceProvider.php
index 5646e53..4e15858 100644
--- a/Modules/Careers/Providers/RouteServiceProvider.php
+++ b/Modules/Careers/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
prefix('administration/careers')->name('administration.careers.')->group(function(): void { Route::get('/',[CareerController::class,'index'])->name('index'); Route::post('/',[CareerController::class,'store'])->name('store'); Route::delete('/{job}',[CareerController::class,'destroy'])->name('destroy'); });
+
+use Illuminate\Support\Facades\Route;
+use Modules\Careers\Http\Controllers\CareerController;
+
+Route::middleware(['auth', 'verified'])->prefix('administration/careers')->name('administration.careers.')->group(function (): void {
+ Route::get('/', [CareerController::class, 'index'])->name('index');
+ Route::post('/', [CareerController::class, 'store'])->name('store');
+ Route::delete('/{job}', [CareerController::class, 'destroy'])->name('destroy');
+});
diff --git a/Modules/Careers/Tests/Feature/ModuleBootTest.php b/Modules/Careers/Tests/Feature/ModuleBootTest.php
index afcdaf3..28b7a14 100644
--- a/Modules/Careers/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Careers/Tests/Feature/ModuleBootTest.php
@@ -1,6 +1,9 @@
'Categories'];
diff --git a/Modules/Categories/Database/Migrations/2026_07_24_030100_create_categories_table.php b/Modules/Categories/Database/Migrations/2026_07_24_030100_create_categories_table.php
index 28e530b..9796bc5 100644
--- a/Modules/Categories/Database/Migrations/2026_07_24_030100_create_categories_table.php
+++ b/Modules/Categories/Database/Migrations/2026_07_24_030100_create_categories_table.php
@@ -1,8 +1,28 @@
id(); $table->foreignId('parent_id')->nullable()->constrained('categories')->nullOnDelete(); $table->string('name'); $table->string('slug')->unique(); $table->text('description')->nullable(); $table->string('type')->default('content')->index(); $table->unsignedInteger('sort_order')->default(0); $table->boolean('is_active')->default(true); $table->timestamps(); }); }
- public function down(): void { Schema::dropIfExists('categories'); }
+
+return new class extends Migration
+{
+ public function up(): void
+ {
+ Schema::create('categories', function (Blueprint $table): void {
+ $table->id();
+ $table->foreignId('parent_id')->nullable()->constrained('categories')->nullOnDelete();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->text('description')->nullable();
+ $table->string('type')->default('content')->index();
+ $table->unsignedInteger('sort_order')->default(0);
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('categories');
+ }
};
diff --git a/Modules/Categories/Http/Controllers/CategoryController.php b/Modules/Categories/Http/Controllers/CategoryController.php
index e40003c..feca75e 100644
--- a/Modules/Categories/Http/Controllers/CategoryController.php
+++ b/Modules/Categories/Http/Controllers/CategoryController.php
@@ -1,17 +1,56 @@
Category::with('parent')->orderBy('type')->orderBy('sort_order')->paginate(30)]); }
- public function create(): View { return view('categories::admin.form', ['parents'=>Category::orderBy('name')->get()]); }
- public function store(Request $request): RedirectResponse { Category::create($this->validated($request)); return redirect()->route('administration.categories.index')->with('status','Category created.'); }
- public function edit(Category $category): View { return view('categories::admin.form', ['record'=>$category,'parents'=>Category::whereKeyNot($category->getKey())->orderBy('name')->get()]); }
- public function update(Request $request, Category $category): RedirectResponse { $category->update($this->validated($request)); return redirect()->route('administration.categories.index')->with('status','Category updated.'); }
- public function destroy(Category $category): RedirectResponse { $category->delete(); return back()->with('status','Category deleted.'); }
- private function validated(Request $request): array { $data=$request->validate(['parent_id'=>['nullable','exists:categories,id'],'name'=>['required','string','max:255'],'slug'=>['required','string','max:255'],'description'=>['nullable','string'],'type'=>['required','string','max:50'],'sort_order'=>['nullable','integer','min:0'],'is_active'=>['sometimes','boolean']]); $data['is_active']=$request->boolean('is_active'); return $data; }
+ public function index(): View
+ {
+ return view('categories::admin.index', ['records' => Category::with('parent')->orderBy('type')->orderBy('sort_order')->paginate(30)]);
+ }
+
+ public function create(): View
+ {
+ return view('categories::admin.form', ['parents' => Category::orderBy('name')->get()]);
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ Category::create($this->validated($request));
+
+ return redirect()->route('administration.categories.index')->with('status', 'Category created.');
+ }
+
+ public function edit(Category $category): View
+ {
+ return view('categories::admin.form', ['record' => $category, 'parents' => Category::whereKeyNot($category->getKey())->orderBy('name')->get()]);
+ }
+
+ public function update(Request $request, Category $category): RedirectResponse
+ {
+ $category->update($this->validated($request));
+
+ return redirect()->route('administration.categories.index')->with('status', 'Category updated.');
+ }
+
+ public function destroy(Category $category): RedirectResponse
+ {
+ $category->delete();
+
+ return back()->with('status', 'Category deleted.');
+ }
+
+ private function validated(Request $request): array
+ {
+ $data = $request->validate(['parent_id' => ['nullable', 'exists:categories,id'], 'name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string'], 'type' => ['required', 'string', 'max:50'], 'sort_order' => ['nullable', 'integer', 'min:0'], 'is_active' => ['sometimes', 'boolean']]);
+ $data['is_active'] = $request->boolean('is_active');
+
+ return $data;
+ }
}
diff --git a/Modules/Categories/Models/Category.php b/Modules/Categories/Models/Category.php
index afe2af0..8835411 100644
--- a/Modules/Categories/Models/Category.php
+++ b/Modules/Categories/Models/Category.php
@@ -1,12 +1,27 @@
'boolean','sort_order'=>'integer']; }
- public function parent(): BelongsTo { return $this->belongsTo(self::class, 'parent_id'); }
- public function children(): HasMany { return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order'); }
+ protected $fillable = ['parent_id', 'name', 'slug', 'description', 'type', 'sort_order', 'is_active'];
+
+ protected function casts(): array
+ {
+ return ['is_active' => 'boolean', 'sort_order' => 'integer'];
+ }
+
+ public function parent(): BelongsTo
+ {
+ return $this->belongsTo(self::class, 'parent_id');
+ }
+
+ public function children(): HasMany
+ {
+ return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order');
+ }
}
diff --git a/Modules/Categories/Providers/CategoriesServiceProvider.php b/Modules/Categories/Providers/CategoriesServiceProvider.php
index 4ce9af4..8a20b37 100644
--- a/Modules/Categories/Providers/CategoriesServiceProvider.php
+++ b/Modules/Categories/Providers/CategoriesServiceProvider.php
@@ -1,6 +1,9 @@
app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Categories', 'Config/config.php'), 'categories');
}
+
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Categories', 'Database/Migrations'));
diff --git a/Modules/Categories/Providers/RouteServiceProvider.php b/Modules/Categories/Providers/RouteServiceProvider.php
index 5b0de5b..1423c92 100644
--- a/Modules/Categories/Providers/RouteServiceProvider.php
+++ b/Modules/Categories/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
prefix('administration/categories')->name('administration.categories.')->group(function (): void { Route::resource('/', CategoryController::class)->parameters([''=>'category'])->except('show'); });
+
+Route::middleware(['auth', 'verified'])->prefix('administration/categories')->name('administration.categories.')->group(function (): void {
+ Route::resource('/', CategoryController::class)->parameters(['' => 'category'])->except('show');
+});
diff --git a/Modules/Categories/Tests/Feature/ModuleBootTest.php b/Modules/Categories/Tests/Feature/ModuleBootTest.php
index 827a912..479e0ae 100644
--- a/Modules/Categories/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Categories/Tests/Feature/ModuleBootTest.php
@@ -1,6 +1,9 @@
each(fn($check)=>$runner->run($check)); $this->info('Health checks completed.'); return self::SUCCESS; } }
+
+final class CloudDevOpsHealthCommand extends Command
+{
+ protected $signature = 'cloud-devops:health';
+
+ protected $description = 'Run all active deployment health checks';
+
+ public function handle(HealthCheckRunner $runner): int
+ {
+ DeploymentHealthCheck::query()->each(fn ($check) => $runner->run($check));
+ $this->info('Health checks completed.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Modules/CloudDevOps/Console/Commands/CloudDevOpsPruneCommand.php b/Modules/CloudDevOps/Console/Commands/CloudDevOpsPruneCommand.php
index c709989..4309b43 100644
--- a/Modules/CloudDevOps/Console/Commands/CloudDevOpsPruneCommand.php
+++ b/Modules/CloudDevOps/Console/Commands/CloudDevOpsPruneCommand.php
@@ -1,5 +1,22 @@
option('days') ?: config('clouddevops.retention_days',90)); $count=DeploymentRelease::where('created_at','<',now()->subDays($days))->whereIn('status',['succeeded','failed','cancelled'])->delete(); $this->info("Pruned {$count} releases."); return self::SUCCESS; } }
+
+final class CloudDevOpsPruneCommand extends Command
+{
+ protected $signature = 'cloud-devops:prune {--days=}';
+
+ protected $description = 'Prune old deployment release history';
+
+ public function handle(): int
+ {
+ $days = (int) ($this->option('days') ?: config('clouddevops.retention_days', 90));
+ $count = DeploymentRelease::where('created_at', '<', now()->subDays($days))->whereIn('status', ['succeeded', 'failed', 'cancelled'])->delete();
+ $this->info("Pruned {$count} releases.");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Modules/CloudDevOps/Database/Migrations/2026_07_24_180000_create_cloud_devops_tables.php b/Modules/CloudDevOps/Database/Migrations/2026_07_24_180000_create_cloud_devops_tables.php
index abcfa5f..9b9d98e 100644
--- a/Modules/CloudDevOps/Database/Migrations/2026_07_24_180000_create_cloud_devops_tables.php
+++ b/Modules/CloudDevOps/Database/Migrations/2026_07_24_180000_create_cloud_devops_tables.php
@@ -4,7 +4,8 @@ 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('deployment_environments', function (Blueprint $table): void {
diff --git a/Modules/CloudDevOps/Http/Controllers/CloudDevOpsApiController.php b/Modules/CloudDevOps/Http/Controllers/CloudDevOpsApiController.php
index 806ac3d..54897f1 100644
--- a/Modules/CloudDevOps/Http/Controllers/CloudDevOpsApiController.php
+++ b/Modules/CloudDevOps/Http/Controllers/CloudDevOpsApiController.php
@@ -1,7 +1,21 @@
json(['data'=>DeploymentEnvironment::withCount(['targets','pipelines','releases'])->get()]); } public function releases(): JsonResponse { return response()->json(['data'=>DeploymentRelease::with(['environment','steps'])->latest()->paginate()]); } }
+
+final class CloudDevOpsApiController extends Controller
+{
+ public function environments(): JsonResponse
+ {
+ return response()->json(['data' => DeploymentEnvironment::withCount(['targets', 'pipelines', 'releases'])->get()]);
+ }
+
+ public function releases(): JsonResponse
+ {
+ return response()->json(['data' => DeploymentRelease::with(['environment', 'steps'])->latest()->paginate()]);
+ }
+}
diff --git a/Modules/CloudDevOps/Http/Controllers/CloudDevOpsController.php b/Modules/CloudDevOps/Http/Controllers/CloudDevOpsController.php
index ac3b1bc..dcc212b 100644
--- a/Modules/CloudDevOps/Http/Controllers/CloudDevOpsController.php
+++ b/Modules/CloudDevOps/Http/Controllers/CloudDevOpsController.php
@@ -26,22 +26,25 @@ final class CloudDevOpsController extends Controller
public function storeEnvironment(Request $request): RedirectResponse
{
- $data = $request->validate(['name'=>'required|string|max:120','slug'=>'required|alpha_dash|max:120|unique:deployment_environments,slug','type'=>'required|in:development,staging,production']);
+ $data = $request->validate(['name' => 'required|string|max:120', 'slug' => 'required|alpha_dash|max:120|unique:deployment_environments,slug', 'type' => 'required|in:development,staging,production']);
DeploymentEnvironment::create($data);
+
return back()->with('status', 'Environment created.');
}
public function deploy(Request $request, DeploymentEnvironment $environment, DeploymentManager $manager): RedirectResponse
{
- $data = $request->validate(['pipeline_id'=>'required|integer','version'=>'nullable|string|max:120','commit_hash'=>'nullable|string|max:120']);
+ $data = $request->validate(['pipeline_id' => 'required|integer', 'version' => 'nullable|string|max:120', 'commit_hash' => 'nullable|string|max:120']);
$pipeline = DeploymentPipeline::where('environment_id', $environment->id)->findOrFail($data['pipeline_id']);
$manager->createRelease($environment, $pipeline, $data + ['requested_by' => $request->user()?->id]);
+
return back()->with('status', 'Release created.');
}
public function runCheck(DeploymentHealthCheck $check, HealthCheckRunner $runner): RedirectResponse
{
$runner->run($check);
+
return back()->with('status', 'Health check completed.');
}
}
diff --git a/Modules/CloudDevOps/Models/DeploymentEnvironment.php b/Modules/CloudDevOps/Models/DeploymentEnvironment.php
index bc6afd0..55bc24b 100644
--- a/Modules/CloudDevOps/Models/DeploymentEnvironment.php
+++ b/Modules/CloudDevOps/Models/DeploymentEnvironment.php
@@ -8,9 +8,29 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
final class DeploymentEnvironment extends Model
{
protected $guarded = [];
- protected function casts(): array { return ['variables' => 'encrypted:array', 'is_protected' => 'boolean']; }
- public function targets(): HasMany { return $this->hasMany(DeploymentTarget::class, 'environment_id'); }
- public function pipelines(): HasMany { return $this->hasMany(DeploymentPipeline::class, 'environment_id'); }
- public function releases(): HasMany { return $this->hasMany(DeploymentRelease::class, 'environment_id'); }
- public function healthChecks(): HasMany { return $this->hasMany(DeploymentHealthCheck::class, 'environment_id'); }
+
+ protected function casts(): array
+ {
+ return ['variables' => 'encrypted:array', 'is_protected' => 'boolean'];
+ }
+
+ public function targets(): HasMany
+ {
+ return $this->hasMany(DeploymentTarget::class, 'environment_id');
+ }
+
+ public function pipelines(): HasMany
+ {
+ return $this->hasMany(DeploymentPipeline::class, 'environment_id');
+ }
+
+ public function releases(): HasMany
+ {
+ return $this->hasMany(DeploymentRelease::class, 'environment_id');
+ }
+
+ public function healthChecks(): HasMany
+ {
+ return $this->hasMany(DeploymentHealthCheck::class, 'environment_id');
+ }
}
diff --git a/Modules/CloudDevOps/Models/DeploymentHealthCheck.php b/Modules/CloudDevOps/Models/DeploymentHealthCheck.php
index ec35a73..2ff53fb 100644
--- a/Modules/CloudDevOps/Models/DeploymentHealthCheck.php
+++ b/Modules/CloudDevOps/Models/DeploymentHealthCheck.php
@@ -1,4 +1,15 @@
'datetime']; } }
+
+final class DeploymentHealthCheck extends Model
+{
+ protected $guarded = [];
+
+ protected function casts(): array
+ {
+ return ['checked_at' => 'datetime'];
+ }
+}
diff --git a/Modules/CloudDevOps/Models/DeploymentPipeline.php b/Modules/CloudDevOps/Models/DeploymentPipeline.php
index 904bfc2..1aa3ad2 100644
--- a/Modules/CloudDevOps/Models/DeploymentPipeline.php
+++ b/Modules/CloudDevOps/Models/DeploymentPipeline.php
@@ -1,4 +1,15 @@
'array', 'requires_approval' => 'boolean', 'is_active' => 'boolean']; } }
+
+final class DeploymentPipeline extends Model
+{
+ protected $guarded = [];
+
+ protected function casts(): array
+ {
+ return ['steps' => 'array', 'requires_approval' => 'boolean', 'is_active' => 'boolean'];
+ }
+}
diff --git a/Modules/CloudDevOps/Models/DeploymentRelease.php b/Modules/CloudDevOps/Models/DeploymentRelease.php
index 4faee10..5eca011 100644
--- a/Modules/CloudDevOps/Models/DeploymentRelease.php
+++ b/Modules/CloudDevOps/Models/DeploymentRelease.php
@@ -1,6 +1,27 @@
'array', 'started_at' => 'datetime', 'completed_at' => 'datetime']; } public function environment(): BelongsTo { return $this->belongsTo(DeploymentEnvironment::class, 'environment_id'); } public function steps(): HasMany { return $this->hasMany(DeploymentStep::class, 'release_id')->orderBy('position'); } }
+
+final class DeploymentRelease extends Model
+{
+ protected $guarded = [];
+
+ protected function casts(): array
+ {
+ return ['metadata' => 'array', 'started_at' => 'datetime', 'completed_at' => 'datetime'];
+ }
+
+ public function environment(): BelongsTo
+ {
+ return $this->belongsTo(DeploymentEnvironment::class, 'environment_id');
+ }
+
+ public function steps(): HasMany
+ {
+ return $this->hasMany(DeploymentStep::class, 'release_id')->orderBy('position');
+ }
+}
diff --git a/Modules/CloudDevOps/Models/DeploymentRollback.php b/Modules/CloudDevOps/Models/DeploymentRollback.php
index 370b8cb..8c4cb66 100644
--- a/Modules/CloudDevOps/Models/DeploymentRollback.php
+++ b/Modules/CloudDevOps/Models/DeploymentRollback.php
@@ -1,4 +1,10 @@
'datetime']; } }
+
+final class DeploymentSecret extends Model
+{
+ protected $guarded = [];
+
+ protected function casts(): array
+ {
+ return ['rotated_at' => 'datetime'];
+ }
+}
diff --git a/Modules/CloudDevOps/Models/DeploymentStep.php b/Modules/CloudDevOps/Models/DeploymentStep.php
index 514faaa..fc2e05b 100644
--- a/Modules/CloudDevOps/Models/DeploymentStep.php
+++ b/Modules/CloudDevOps/Models/DeploymentStep.php
@@ -1,4 +1,15 @@
'datetime', 'completed_at' => 'datetime']; } }
+
+final class DeploymentStep extends Model
+{
+ protected $guarded = [];
+
+ protected function casts(): array
+ {
+ return ['started_at' => 'datetime', 'completed_at' => 'datetime'];
+ }
+}
diff --git a/Modules/CloudDevOps/Models/DeploymentTarget.php b/Modules/CloudDevOps/Models/DeploymentTarget.php
index 86efbd3..d5de432 100644
--- a/Modules/CloudDevOps/Models/DeploymentTarget.php
+++ b/Modules/CloudDevOps/Models/DeploymentTarget.php
@@ -1,4 +1,15 @@
'encrypted:array', 'is_active' => 'boolean']; } }
+
+final class DeploymentTarget extends Model
+{
+ protected $guarded = [];
+
+ protected function casts(): array
+ {
+ return ['configuration' => 'encrypted:array', 'is_active' => 'boolean'];
+ }
+}
diff --git a/Modules/CloudDevOps/Providers/CloudDevOpsServiceProvider.php b/Modules/CloudDevOps/Providers/CloudDevOpsServiceProvider.php
index 0f0dd67..763eab7 100644
--- a/Modules/CloudDevOps/Providers/CloudDevOpsServiceProvider.php
+++ b/Modules/CloudDevOps/Providers/CloudDevOpsServiceProvider.php
@@ -1,6 +1,25 @@
mergeConfigFrom(module_path('CloudDevOps','Config/config.php'),'clouddevops'); $this->app->register(RouteServiceProvider::class); } public function boot(): void { $this->loadViewsFrom(module_path('CloudDevOps','Resources/views'),'clouddevops'); $this->loadMigrationsFrom(module_path('CloudDevOps','Database/Migrations')); if ($this->app->runningInConsole()) { $this->commands([CloudDevOpsHealthCommand::class,CloudDevOpsPruneCommand::class]); } } }
+
+final class CloudDevOpsServiceProvider extends ServiceProvider
+{
+ public function register(): void
+ {
+ $this->mergeConfigFrom(module_path('CloudDevOps', 'Config/config.php'), 'clouddevops');
+ $this->app->register(RouteServiceProvider::class);
+ }
+
+ public function boot(): void
+ {
+ $this->loadViewsFrom(module_path('CloudDevOps', 'Resources/views'), 'clouddevops');
+ $this->loadMigrationsFrom(module_path('CloudDevOps', 'Database/Migrations'));
+ if ($this->app->runningInConsole()) {
+ $this->commands([CloudDevOpsHealthCommand::class, CloudDevOpsPruneCommand::class]);
+ }
+ }
+}
diff --git a/Modules/CloudDevOps/Providers/RouteServiceProvider.php b/Modules/CloudDevOps/Providers/RouteServiceProvider.php
index 8c0792c..1b62500 100644
--- a/Modules/CloudDevOps/Providers/RouteServiceProvider.php
+++ b/Modules/CloudDevOps/Providers/RouteServiceProvider.php
@@ -1,5 +1,15 @@
group(module_path('CloudDevOps','Routes/web.php')); Route::middleware('api')->prefix('api')->group(module_path('CloudDevOps','Routes/api.php')); } }
+
+final class RouteServiceProvider extends ServiceProvider
+{
+ public function boot(): void
+ {
+ Route::middleware('web')->group(module_path('CloudDevOps', 'Routes/web.php'));
+ Route::middleware('api')->prefix('api')->group(module_path('CloudDevOps', 'Routes/api.php'));
+ }
+}
diff --git a/Modules/CloudDevOps/Routes/api.php b/Modules/CloudDevOps/Routes/api.php
index c9a14aa..89d17bf 100644
--- a/Modules/CloudDevOps/Routes/api.php
+++ b/Modules/CloudDevOps/Routes/api.php
@@ -1,4 +1,9 @@
prefix('v1/cloud-devops')->group(function (): void { Route::get('/environments',[CloudDevOpsApiController::class,'environments']); Route::get('/releases',[CloudDevOpsApiController::class,'releases']); });
+
+Route::middleware(['auth:sanctum', 'permission:cloud-devops.view'])->prefix('v1/cloud-devops')->group(function (): void {
+ Route::get('/environments', [CloudDevOpsApiController::class, 'environments']);
+ Route::get('/releases', [CloudDevOpsApiController::class, 'releases']);
+});
diff --git a/Modules/CloudDevOps/Services/DeploymentManager.php b/Modules/CloudDevOps/Services/DeploymentManager.php
index 6f12688..1b9c665 100644
--- a/Modules/CloudDevOps/Services/DeploymentManager.php
+++ b/Modules/CloudDevOps/Services/DeploymentManager.php
@@ -34,6 +34,7 @@ final class DeploymentManager
{
abort_unless(config('clouddevops.allow_execution'), 403, 'Deployment execution is disabled.');
$release->update(['status' => 'running', 'started_at' => now()]);
+
return $release->refresh();
}
}
diff --git a/Modules/CloudDevOps/Services/HealthCheckRunner.php b/Modules/CloudDevOps/Services/HealthCheckRunner.php
index f967f9c..3c9f32a 100644
--- a/Modules/CloudDevOps/Services/HealthCheckRunner.php
+++ b/Modules/CloudDevOps/Services/HealthCheckRunner.php
@@ -1,5 +1,24 @@
get((string) $check->endpoint); $status = $response->status() === $check->expected_status ? 'healthy' : 'unhealthy'; $message = 'HTTP '.$response->status(); } catch (\Throwable $e) { $status = 'unhealthy'; $message = $e->getMessage(); } $check->update(['status'=>$status,'response_time_ms'=>(int)((microtime(true)-$started)*1000),'checked_at'=>now(),'message'=>$message]); return $check->refresh(); } }
+
+final class HealthCheckRunner
+{
+ public function run(DeploymentHealthCheck $check): DeploymentHealthCheck
+ {
+ $started = microtime(true);
+ try {
+ $response = Http::timeout(10)->get((string) $check->endpoint);
+ $status = $response->status() === $check->expected_status ? 'healthy' : 'unhealthy';
+ $message = 'HTTP '.$response->status();
+ } catch (\Throwable $e) {
+ $status = 'unhealthy';
+ $message = $e->getMessage();
+ } $check->update(['status' => $status, 'response_time_ms' => (int) ((microtime(true) - $started) * 1000), 'checked_at' => now(), 'message' => $message]);
+
+ return $check->refresh();
+ }
+}
diff --git a/Modules/CloudDevOps/Tests/Feature/CloudDevOpsModuleTest.php b/Modules/CloudDevOps/Tests/Feature/CloudDevOpsModuleTest.php
index 511ef18..5425d71 100644
--- a/Modules/CloudDevOps/Tests/Feature/CloudDevOpsModuleTest.php
+++ b/Modules/CloudDevOps/Tests/Feature/CloudDevOpsModuleTest.php
@@ -1,6 +1,18 @@
'Production','slug'=>'production','type'=>'production']); $this->assertDatabaseHas('deployment_environments',['id'=>$environment->id,'slug'=>'production']); } }
+
+final class CloudDevOpsModuleTest extends TestCase
+{
+ use RefreshDatabase;
+
+ public function test_environment_can_be_created(): void
+ {
+ $environment = DeploymentEnvironment::create(['name' => 'Production', 'slug' => 'production', 'type' => 'production']);
+ $this->assertDatabaseHas('deployment_environments', ['id' => $environment->id, 'slug' => 'production']);
+ }
+}
diff --git a/Modules/Comments/Config/config.php b/Modules/Comments/Config/config.php
index f396772..e205f05 100644
--- a/Modules/Comments/Config/config.php
+++ b/Modules/Comments/Config/config.php
@@ -1,2 +1,3 @@
'Comments'];
diff --git a/Modules/Comments/Database/Migrations/2026_07_24_030600_create_comments_table.php b/Modules/Comments/Database/Migrations/2026_07_24_030600_create_comments_table.php
index 9f27f7b..89de713 100644
--- a/Modules/Comments/Database/Migrations/2026_07_24_030600_create_comments_table.php
+++ b/Modules/Comments/Database/Migrations/2026_07_24_030600_create_comments_table.php
@@ -1,3 +1,31 @@
id(); $table->nullableMorphs('commentable'); $table->foreignId('parent_id')->nullable()->constrained('comments')->cascadeOnDelete(); $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); $table->string('author_name'); $table->string('author_email'); $table->text('body'); $table->string('status')->default('pending')->index(); $table->string('ip_address',45)->nullable(); $table->text('moderation_notes')->nullable(); $table->timestamp('approved_at')->nullable(); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('comments'); } };
+
+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('comments', function (Blueprint $table): void {
+ $table->id();
+ $table->nullableMorphs('commentable');
+ $table->foreignId('parent_id')->nullable()->constrained('comments')->cascadeOnDelete();
+ $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
+ $table->string('author_name');
+ $table->string('author_email');
+ $table->text('body');
+ $table->string('status')->default('pending')->index();
+ $table->string('ip_address', 45)->nullable();
+ $table->text('moderation_notes')->nullable();
+ $table->timestamp('approved_at')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('comments');
+ }
+};
diff --git a/Modules/Comments/Http/Controllers/CommentController.php b/Modules/Comments/Http/Controllers/CommentController.php
index f5dc98c..c8948af 100644
--- a/Modules/Comments/Http/Controllers/CommentController.php
+++ b/Modules/Comments/Http/Controllers/CommentController.php
@@ -1,3 +1,38 @@
filled('status')) $q->where('status',$r->string('status')); return view('comments::admin.index',['records'=>$q->paginate(30)]); } public function update(Request $r, Comment $comment): RedirectResponse { $data=$r->validate(['status'=>['required','in:pending,approved,spam,rejected'],'moderation_notes'=>['nullable','string']]); $data['approved_at']=$data['status']==='approved' ? now() : null; $comment->update($data); return back()->with('status','Comment moderated.'); } public function destroy(Comment $comment): RedirectResponse { $comment->delete(); return back()->with('status','Comment deleted.'); } }
+
+namespace Modules\Comments\Http\Controllers;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\View\View;
+use Modules\Comments\Models\Comment;
+
+final class CommentController extends Controller
+{
+ public function index(Request $r): View
+ {
+ $q = Comment::latest();
+ if ($r->filled('status')) {
+ $q->where('status', $r->string('status'));
+ }
+
+return view('comments::admin.index', ['records' => $q->paginate(30)]);
+ }
+
+ public function update(Request $r, Comment $comment): RedirectResponse
+ {
+ $data = $r->validate(['status' => ['required', 'in:pending,approved,spam,rejected'], 'moderation_notes' => ['nullable', 'string']]);
+ $data['approved_at'] = $data['status'] === 'approved' ? now() : null;
+ $comment->update($data);
+
+ return back()->with('status', 'Comment moderated.');
+ }
+
+ public function destroy(Comment $comment): RedirectResponse
+ {
+ $comment->delete();
+
+ return back()->with('status', 'Comment deleted.');
+ }
+}
diff --git a/Modules/Comments/Models/Comment.php b/Modules/Comments/Models/Comment.php
index 41b6791..0da0238 100644
--- a/Modules/Comments/Models/Comment.php
+++ b/Modules/Comments/Models/Comment.php
@@ -1,3 +1,21 @@
'datetime']; } public function commentable(): MorphTo { return $this->morphTo(); } }
+
+namespace Modules\Comments\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
+
+final class Comment extends Model
+{
+ protected $fillable = ['commentable_type', 'commentable_id', 'parent_id', 'user_id', 'author_name', 'author_email', 'body', 'status', 'ip_address', 'moderation_notes', 'approved_at'];
+
+ protected function casts(): array
+ {
+ return ['approved_at' => 'datetime'];
+ }
+
+ public function commentable(): MorphTo
+ {
+ return $this->morphTo();
+ }
+}
diff --git a/Modules/Comments/Providers/CommentsServiceProvider.php b/Modules/Comments/Providers/CommentsServiceProvider.php
index d82fda6..d82e931 100644
--- a/Modules/Comments/Providers/CommentsServiceProvider.php
+++ b/Modules/Comments/Providers/CommentsServiceProvider.php
@@ -1,6 +1,9 @@
app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Comments', 'Config/config.php'), 'comments');
}
+
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Comments', 'Database/Migrations'));
diff --git a/Modules/Comments/Providers/RouteServiceProvider.php b/Modules/Comments/Providers/RouteServiceProvider.php
index 49209ce..b83240b 100644
--- a/Modules/Comments/Providers/RouteServiceProvider.php
+++ b/Modules/Comments/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
prefix('administration/comments')->name('administration.comments.')->group(function (): void { Route::get('/',[CommentController::class,'index'])->name('index'); Route::put('/{comment}',[CommentController::class,'update'])->name('update'); Route::delete('/{comment}',[CommentController::class,'destroy'])->name('destroy'); });
+
+use Illuminate\Support\Facades\Route;
+use Modules\Comments\Http\Controllers\CommentController;
+
+Route::middleware(['auth', 'verified'])->prefix('administration/comments')->name('administration.comments.')->group(function (): void {
+ Route::get('/', [CommentController::class, 'index'])->name('index');
+ Route::put('/{comment}', [CommentController::class, 'update'])->name('update');
+ Route::delete('/{comment}', [CommentController::class, 'destroy'])->name('destroy');
+});
diff --git a/Modules/Comments/Tests/Feature/ModuleBootTest.php b/Modules/Comments/Tests/Feature/ModuleBootTest.php
index 64fca51..bf969d3 100644
--- a/Modules/Comments/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Comments/Tests/Feature/ModuleBootTest.php
@@ -1,6 +1,9 @@
env('COMMERCE_CURRENCY', 'GBP'),
'currency_symbol' => env('COMMERCE_CURRENCY_SYMBOL', '£'),
diff --git a/Modules/Commerce/Contracts/InventoryProvider.php b/Modules/Commerce/Contracts/InventoryProvider.php
index 7b21e20..d2aaa5c 100644
--- a/Modules/Commerce/Contracts/InventoryProvider.php
+++ b/Modules/Commerce/Contracts/InventoryProvider.php
@@ -1,4 +1,12 @@
id(); $table->foreignId('parent_id')->nullable()->constrained('commerce_categories')->nullOnDelete(); $table->string('name'); $table->string('slug')->unique(); $table->text('description')->nullable(); $table->boolean('is_active')->default(true); $table->unsignedInteger('sort_order')->default(0); $table->timestamps(); });
- Schema::create('commerce_products', function (Blueprint $table) { $table->id(); $table->foreignId('category_id')->nullable()->constrained('commerce_categories')->nullOnDelete(); $table->string('type')->default('physical'); $table->string('name'); $table->string('slug')->unique(); $table->string('sku')->nullable()->unique(); $table->text('summary')->nullable(); $table->longText('description')->nullable(); $table->decimal('price', 12, 2)->default(0); $table->decimal('compare_at_price', 12, 2)->nullable(); $table->decimal('cost_price', 12, 2)->nullable(); $table->string('currency', 3)->default('GBP'); $table->boolean('track_inventory')->default(true); $table->integer('stock_quantity')->default(0); $table->integer('low_stock_threshold')->default(5); $table->boolean('is_featured')->default(false); $table->string('status')->default('draft'); $table->timestamp('published_at')->nullable(); $table->json('metadata')->nullable(); $table->timestamps(); $table->softDeletes(); $table->index(['status','published_at']); });
- Schema::create('commerce_product_variants', function (Blueprint $table) { $table->id(); $table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete(); $table->string('name'); $table->string('sku')->unique(); $table->decimal('price', 12, 2)->nullable(); $table->integer('stock_quantity')->default(0); $table->json('options')->nullable(); $table->boolean('is_active')->default(true); $table->timestamps(); });
- Schema::create('commerce_customers', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); $table->string('email')->index(); $table->string('first_name'); $table->string('last_name'); $table->string('company')->nullable(); $table->string('phone')->nullable(); $table->string('tax_number')->nullable(); $table->json('billing_address')->nullable(); $table->json('shipping_address')->nullable(); $table->string('status')->default('active'); $table->timestamps(); });
- Schema::create('commerce_orders', function (Blueprint $table) { $table->id(); $table->string('number')->unique(); $table->foreignId('customer_id')->nullable()->constrained('commerce_customers')->nullOnDelete(); $table->string('email'); $table->string('status')->default('pending'); $table->string('payment_status')->default('unpaid'); $table->string('fulfilment_status')->default('unfulfilled'); $table->string('currency',3)->default('GBP'); $table->decimal('subtotal',12,2)->default(0); $table->decimal('discount_total',12,2)->default(0); $table->decimal('shipping_total',12,2)->default(0); $table->decimal('tax_total',12,2)->default(0); $table->decimal('grand_total',12,2)->default(0); $table->json('billing_address')->nullable(); $table->json('shipping_address')->nullable(); $table->text('notes')->nullable(); $table->timestamp('placed_at')->nullable(); $table->timestamps(); $table->index(['status','payment_status']); });
- Schema::create('commerce_order_items', function (Blueprint $table) { $table->id(); $table->foreignId('order_id')->constrained('commerce_orders')->cascadeOnDelete(); $table->foreignId('product_id')->nullable()->constrained('commerce_products')->nullOnDelete(); $table->foreignId('variant_id')->nullable()->constrained('commerce_product_variants')->nullOnDelete(); $table->string('name'); $table->string('sku')->nullable(); $table->unsignedInteger('quantity'); $table->decimal('unit_price',12,2); $table->decimal('tax_total',12,2)->default(0); $table->decimal('line_total',12,2); $table->json('metadata')->nullable(); $table->timestamps(); });
- Schema::create('commerce_inventory_movements', function (Blueprint $table) { $table->id(); $table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete(); $table->foreignId('variant_id')->nullable()->constrained('commerce_product_variants')->cascadeOnDelete(); $table->integer('quantity'); $table->string('reason'); $table->string('reference_type')->nullable(); $table->unsignedBigInteger('reference_id')->nullable(); $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); $table->text('notes')->nullable(); $table->timestamps(); $table->index(['reference_type','reference_id']); });
- Schema::create('commerce_promotions', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('code')->nullable()->unique(); $table->string('type')->default('percentage'); $table->decimal('value',12,2); $table->decimal('minimum_order',12,2)->nullable(); $table->unsignedInteger('usage_limit')->nullable(); $table->unsignedInteger('times_used')->default(0); $table->timestamp('starts_at')->nullable(); $table->timestamp('ends_at')->nullable(); $table->boolean('is_active')->default(true); $table->json('conditions')->nullable(); $table->timestamps(); });
- Schema::create('commerce_shipping_methods', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('code')->unique(); $table->decimal('price',12,2)->default(0); $table->decimal('free_over',12,2)->nullable(); $table->boolean('is_active')->default(true); $table->json('settings')->nullable(); $table->timestamps(); });
- Schema::create('commerce_tax_rates', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('country_code',2)->nullable(); $table->decimal('rate',7,4)->default(0); $table->boolean('is_default')->default(false); $table->boolean('is_active')->default(true); $table->timestamps(); });
- Schema::create('commerce_payment_transactions', function (Blueprint $table) { $table->id(); $table->foreignId('order_id')->constrained('commerce_orders')->cascadeOnDelete(); $table->string('provider'); $table->string('provider_reference')->nullable()->index(); $table->string('type')->default('payment'); $table->string('status')->default('pending'); $table->decimal('amount',12,2); $table->string('currency',3); $table->json('payload')->nullable(); $table->timestamps(); });
+ Schema::create('commerce_categories', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('parent_id')->nullable()->constrained('commerce_categories')->nullOnDelete();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->text('description')->nullable();
+ $table->boolean('is_active')->default(true);
+ $table->unsignedInteger('sort_order')->default(0);
+ $table->timestamps();
+ });
+ Schema::create('commerce_products', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('category_id')->nullable()->constrained('commerce_categories')->nullOnDelete();
+ $table->string('type')->default('physical');
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->string('sku')->nullable()->unique();
+ $table->text('summary')->nullable();
+ $table->longText('description')->nullable();
+ $table->decimal('price', 12, 2)->default(0);
+ $table->decimal('compare_at_price', 12, 2)->nullable();
+ $table->decimal('cost_price', 12, 2)->nullable();
+ $table->string('currency', 3)->default('GBP');
+ $table->boolean('track_inventory')->default(true);
+ $table->integer('stock_quantity')->default(0);
+ $table->integer('low_stock_threshold')->default(5);
+ $table->boolean('is_featured')->default(false);
+ $table->string('status')->default('draft');
+ $table->timestamp('published_at')->nullable();
+ $table->json('metadata')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ $table->index(['status', 'published_at']);
+ });
+ Schema::create('commerce_product_variants', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete();
+ $table->string('name');
+ $table->string('sku')->unique();
+ $table->decimal('price', 12, 2)->nullable();
+ $table->integer('stock_quantity')->default(0);
+ $table->json('options')->nullable();
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+ Schema::create('commerce_customers', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
+ $table->string('email')->index();
+ $table->string('first_name');
+ $table->string('last_name');
+ $table->string('company')->nullable();
+ $table->string('phone')->nullable();
+ $table->string('tax_number')->nullable();
+ $table->json('billing_address')->nullable();
+ $table->json('shipping_address')->nullable();
+ $table->string('status')->default('active');
+ $table->timestamps();
+ });
+ Schema::create('commerce_orders', function (Blueprint $table) {
+ $table->id();
+ $table->string('number')->unique();
+ $table->foreignId('customer_id')->nullable()->constrained('commerce_customers')->nullOnDelete();
+ $table->string('email');
+ $table->string('status')->default('pending');
+ $table->string('payment_status')->default('unpaid');
+ $table->string('fulfilment_status')->default('unfulfilled');
+ $table->string('currency', 3)->default('GBP');
+ $table->decimal('subtotal', 12, 2)->default(0);
+ $table->decimal('discount_total', 12, 2)->default(0);
+ $table->decimal('shipping_total', 12, 2)->default(0);
+ $table->decimal('tax_total', 12, 2)->default(0);
+ $table->decimal('grand_total', 12, 2)->default(0);
+ $table->json('billing_address')->nullable();
+ $table->json('shipping_address')->nullable();
+ $table->text('notes')->nullable();
+ $table->timestamp('placed_at')->nullable();
+ $table->timestamps();
+ $table->index(['status', 'payment_status']);
+ });
+ Schema::create('commerce_order_items', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('order_id')->constrained('commerce_orders')->cascadeOnDelete();
+ $table->foreignId('product_id')->nullable()->constrained('commerce_products')->nullOnDelete();
+ $table->foreignId('variant_id')->nullable()->constrained('commerce_product_variants')->nullOnDelete();
+ $table->string('name');
+ $table->string('sku')->nullable();
+ $table->unsignedInteger('quantity');
+ $table->decimal('unit_price', 12, 2);
+ $table->decimal('tax_total', 12, 2)->default(0);
+ $table->decimal('line_total', 12, 2);
+ $table->json('metadata')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('commerce_inventory_movements', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete();
+ $table->foreignId('variant_id')->nullable()->constrained('commerce_product_variants')->cascadeOnDelete();
+ $table->integer('quantity');
+ $table->string('reason');
+ $table->string('reference_type')->nullable();
+ $table->unsignedBigInteger('reference_id')->nullable();
+ $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->text('notes')->nullable();
+ $table->timestamps();
+ $table->index(['reference_type', 'reference_id']);
+ });
+ Schema::create('commerce_promotions', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->string('code')->nullable()->unique();
+ $table->string('type')->default('percentage');
+ $table->decimal('value', 12, 2);
+ $table->decimal('minimum_order', 12, 2)->nullable();
+ $table->unsignedInteger('usage_limit')->nullable();
+ $table->unsignedInteger('times_used')->default(0);
+ $table->timestamp('starts_at')->nullable();
+ $table->timestamp('ends_at')->nullable();
+ $table->boolean('is_active')->default(true);
+ $table->json('conditions')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('commerce_shipping_methods', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->string('code')->unique();
+ $table->decimal('price', 12, 2)->default(0);
+ $table->decimal('free_over', 12, 2)->nullable();
+ $table->boolean('is_active')->default(true);
+ $table->json('settings')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('commerce_tax_rates', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->string('country_code', 2)->nullable();
+ $table->decimal('rate', 7, 4)->default(0);
+ $table->boolean('is_default')->default(false);
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+ Schema::create('commerce_payment_transactions', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('order_id')->constrained('commerce_orders')->cascadeOnDelete();
+ $table->string('provider');
+ $table->string('provider_reference')->nullable()->index();
+ $table->string('type')->default('payment');
+ $table->string('status')->default('pending');
+ $table->decimal('amount', 12, 2);
+ $table->string('currency', 3);
+ $table->json('payload')->nullable();
+ $table->timestamps();
+ });
}
+
public function down(): void
{
- foreach (['commerce_payment_transactions','commerce_tax_rates','commerce_shipping_methods','commerce_promotions','commerce_inventory_movements','commerce_order_items','commerce_orders','commerce_customers','commerce_product_variants','commerce_products','commerce_categories'] as $table) Schema::dropIfExists($table);
+ foreach (['commerce_payment_transactions', 'commerce_tax_rates', 'commerce_shipping_methods', 'commerce_promotions', 'commerce_inventory_movements', 'commerce_order_items', 'commerce_orders', 'commerce_customers', 'commerce_product_variants', 'commerce_products', 'commerce_categories'] as $table) {
+ Schema::dropIfExists($table);
+ }
}
};
diff --git a/Modules/Commerce/Http/Controllers/CommerceController.php b/Modules/Commerce/Http/Controllers/CommerceController.php
index 6bc8014..bf7eb83 100644
--- a/Modules/Commerce/Http/Controllers/CommerceController.php
+++ b/Modules/Commerce/Http/Controllers/CommerceController.php
@@ -1,5 +1,7 @@
Product::latest()->paginate(20),'orders'=>Order::latest()->limit(10)->get(),'revenue'=>Order::where('payment_status','paid')->sum('grand_total'),'lowStock'=>Product::where('track_inventory',true)->whereColumn('stock_quantity','<=','low_stock_threshold')->count(),'customers'=>Customer::count(),'promotions'=>Promotion::where('is_active',true)->count()]); }
+ public function index(): View
+ {
+ return view('commerce::admin.index', ['products' => Product::latest()->paginate(20), 'orders' => Order::latest()->limit(10)->get(), 'revenue' => Order::where('payment_status', 'paid')->sum('grand_total'), 'lowStock' => Product::where('track_inventory', true)->whereColumn('stock_quantity', '<=', 'low_stock_threshold')->count(), 'customers' => Customer::count(), 'promotions' => Promotion::where('is_active', true)->count()]);
+ }
+
public function storeProduct(Request $request): RedirectResponse
{
- $data=$request->validate(['name'=>['required','string','max:255'],'slug'=>['nullable','string','max:255','unique:commerce_products,slug'],'sku'=>['nullable','string','max:120','unique:commerce_products,sku'],'type'=>['required','in:physical,digital,service,bundle'],'summary'=>['nullable','string'],'description'=>['nullable','string'],'price'=>['required','numeric','min:0'],'stock_quantity'=>['required','integer'],'status'=>['required','in:draft,active,archived']]);
- $data['slug']=$data['slug'] ?: Str::slug($data['name']); $data['currency']=config('commerce.currency','GBP'); $data['published_at']=$data['status']==='active'?now():null; Product::create($data); return back()->with('status','Product created.');
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'], 'slug' => ['nullable', 'string', 'max:255', 'unique:commerce_products,slug'], 'sku' => ['nullable', 'string', 'max:120', 'unique:commerce_products,sku'], 'type' => ['required', 'in:physical,digital,service,bundle'], 'summary' => ['nullable', 'string'], 'description' => ['nullable', 'string'], 'price' => ['required', 'numeric', 'min:0'], 'stock_quantity' => ['required', 'integer'], 'status' => ['required', 'in:draft,active,archived']]);
+ $data['slug'] = $data['slug'] ?: Str::slug($data['name']);
+ $data['currency'] = config('commerce.currency', 'GBP');
+ $data['published_at'] = $data['status'] === 'active' ? now() : null;
+ Product::create($data);
+
+ return back()->with('status', 'Product created.');
+ }
+
+ public function updateProduct(Request $request, Product $product): RedirectResponse
+ {
+ $product->update($request->validate(['name' => ['required', 'string', 'max:255'], 'price' => ['required', 'numeric', 'min:0'], 'stock_quantity' => ['required', 'integer'], 'status' => ['required', 'in:draft,active,archived']]));
+
+ return back()->with('status', 'Product updated.');
+ }
+
+ public function destroyProduct(Product $product): RedirectResponse
+ {
+ $product->delete();
+
+ return back()->with('status', 'Product deleted.');
+ }
+
+ public function updateOrder(Request $request, Order $order): RedirectResponse
+ {
+ $order->update($request->validate(['status' => ['required', 'in:pending,processing,completed,cancelled,refunded'], 'payment_status' => ['required', 'in:unpaid,authorised,paid,partially_refunded,refunded,failed'], 'fulfilment_status' => ['required', 'in:unfulfilled,partial,fulfilled,returned']]));
+
+ return back()->with('status', 'Order updated.');
}
- public function updateProduct(Request $request, Product $product): RedirectResponse { $product->update($request->validate(['name'=>['required','string','max:255'],'price'=>['required','numeric','min:0'],'stock_quantity'=>['required','integer'],'status'=>['required','in:draft,active,archived']])); return back()->with('status','Product updated.'); }
- public function destroyProduct(Product $product): RedirectResponse { $product->delete(); return back()->with('status','Product deleted.'); }
- public function updateOrder(Request $request, Order $order): RedirectResponse { $order->update($request->validate(['status'=>['required','in:pending,processing,completed,cancelled,refunded'],'payment_status'=>['required','in:unpaid,authorised,paid,partially_refunded,refunded,failed'],'fulfilment_status'=>['required','in:unfulfilled,partial,fulfilled,returned']])); return back()->with('status','Order updated.'); }
}
diff --git a/Modules/Commerce/Http/Controllers/ProductApiController.php b/Modules/Commerce/Http/Controllers/ProductApiController.php
index e69a2ab..07e871f 100644
--- a/Modules/Commerce/Http/Controllers/ProductApiController.php
+++ b/Modules/Commerce/Http/Controllers/ProductApiController.php
@@ -1,10 +1,20 @@
json(Product::query()->where('status','active')->where(fn($q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->latest()->paginate(24)); }
- public function show(string $product): JsonResponse { return response()->json(Product::where('status','active')->where(fn($q)=>$q->where('slug',$product)->orWhere('id',$product))->firstOrFail()); }
+ public function index(): JsonResponse
+ {
+ return response()->json(Product::query()->where('status', 'active')->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))->latest()->paginate(24));
+ }
+
+ public function show(string $product): JsonResponse
+ {
+ return response()->json(Product::where('status', 'active')->where(fn ($q) => $q->where('slug', $product)->orWhere('id', $product))->firstOrFail());
+ }
}
diff --git a/Modules/Commerce/Models/Category.php b/Modules/Commerce/Models/Category.php
index db747f7..76dce14 100644
--- a/Modules/Commerce/Models/Category.php
+++ b/Modules/Commerce/Models/Category.php
@@ -1,11 +1,17 @@
'boolean']; }
+
+ protected function casts(): array
+ {
+ return ['is_active' => 'boolean'];
+ }
}
diff --git a/Modules/Commerce/Models/Customer.php b/Modules/Commerce/Models/Customer.php
index d02f30d..2ed400c 100644
--- a/Modules/Commerce/Models/Customer.php
+++ b/Modules/Commerce/Models/Customer.php
@@ -1,11 +1,17 @@
'array','shipping_address'=>'array']; }
+
+ protected function casts(): array
+ {
+ return ['billing_address' => 'array', 'shipping_address' => 'array'];
+ }
}
diff --git a/Modules/Commerce/Models/InventoryMovement.php b/Modules/Commerce/Models/InventoryMovement.php
index 899905f..68c3cb1 100644
--- a/Modules/Commerce/Models/InventoryMovement.php
+++ b/Modules/Commerce/Models/InventoryMovement.php
@@ -1,11 +1,17 @@
'decimal:2','discount_total'=>'decimal:2','shipping_total'=>'decimal:2','tax_total'=>'decimal:2','grand_total'=>'decimal:2','billing_address'=>'array','shipping_address'=>'array','placed_at'=>'datetime']; }
+
+ protected function casts(): array
+ {
+ return ['subtotal' => 'decimal:2', 'discount_total' => 'decimal:2', 'shipping_total' => 'decimal:2', 'tax_total' => 'decimal:2', 'grand_total' => 'decimal:2', 'billing_address' => 'array', 'shipping_address' => 'array', 'placed_at' => 'datetime'];
+ }
}
diff --git a/Modules/Commerce/Models/OrderItem.php b/Modules/Commerce/Models/OrderItem.php
index 44d6bdb..06072b7 100644
--- a/Modules/Commerce/Models/OrderItem.php
+++ b/Modules/Commerce/Models/OrderItem.php
@@ -1,11 +1,17 @@
'decimal:2','tax_total'=>'decimal:2','line_total'=>'decimal:2','metadata'=>'array']; }
+
+ protected function casts(): array
+ {
+ return ['unit_price' => 'decimal:2', 'tax_total' => 'decimal:2', 'line_total' => 'decimal:2', 'metadata' => 'array'];
+ }
}
diff --git a/Modules/Commerce/Models/Product.php b/Modules/Commerce/Models/Product.php
index 77933ba..a58482f 100644
--- a/Modules/Commerce/Models/Product.php
+++ b/Modules/Commerce/Models/Product.php
@@ -1,11 +1,20 @@
'decimal:2','compare_at_price'=>'decimal:2','cost_price'=>'decimal:2','track_inventory'=>'boolean','is_featured'=>'boolean','published_at'=>'datetime','metadata'=>'array']; }
+
+ protected function casts(): array
+ {
+ return ['price' => 'decimal:2', 'compare_at_price' => 'decimal:2', 'cost_price' => 'decimal:2', 'track_inventory' => 'boolean', 'is_featured' => 'boolean', 'published_at' => 'datetime', 'metadata' => 'array'];
+ }
}
diff --git a/Modules/Commerce/Models/ProductVariant.php b/Modules/Commerce/Models/ProductVariant.php
index ee0b549..72482ce 100644
--- a/Modules/Commerce/Models/ProductVariant.php
+++ b/Modules/Commerce/Models/ProductVariant.php
@@ -1,11 +1,17 @@
'decimal:2','options'=>'array','is_active'=>'boolean']; }
+
+ protected function casts(): array
+ {
+ return ['price' => 'decimal:2', 'options' => 'array', 'is_active' => 'boolean'];
+ }
}
diff --git a/Modules/Commerce/Models/Promotion.php b/Modules/Commerce/Models/Promotion.php
index 6818b3d..e16cb92 100644
--- a/Modules/Commerce/Models/Promotion.php
+++ b/Modules/Commerce/Models/Promotion.php
@@ -1,11 +1,17 @@
'decimal:2','minimum_order'=>'decimal:2','starts_at'=>'datetime','ends_at'=>'datetime','is_active'=>'boolean','conditions'=>'array']; }
+
+ protected function casts(): array
+ {
+ return ['value' => 'decimal:2', 'minimum_order' => 'decimal:2', 'starts_at' => 'datetime', 'ends_at' => 'datetime', 'is_active' => 'boolean', 'conditions' => 'array'];
+ }
}
diff --git a/Modules/Commerce/Models/Relations.php b/Modules/Commerce/Models/Relations.php
index 58836df..7814843 100644
--- a/Modules/Commerce/Models/Relations.php
+++ b/Modules/Commerce/Models/Relations.php
@@ -1,2 +1,3 @@
app->bind(ShippingProvider::class, FlatRateShippingProvider::class);
$this->app->bind(TaxProvider::class, ConfiguredTaxProvider::class);
}
+
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Commerce', 'Database/Migrations'));
diff --git a/Modules/Commerce/Providers/RouteServiceProvider.php b/Modules/Commerce/Providers/RouteServiceProvider.php
index 1d0c2ec..1dcfdb7 100644
--- a/Modules/Commerce/Providers/RouteServiceProvider.php
+++ b/Modules/Commerce/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
name('administration.commerce.')->middleware(['auth','permission:commerce.view'])->group(function () {
- Route::get('/',[CommerceController::class,'index'])->name('index');
- Route::post('/products',[CommerceController::class,'storeProduct'])->middleware('permission:commerce.manage')->name('products.store');
- Route::put('/products/{product}',[CommerceController::class,'updateProduct'])->middleware('permission:commerce.manage')->name('products.update');
- Route::delete('/products/{product}',[CommerceController::class,'destroyProduct'])->middleware('permission:commerce.manage')->name('products.destroy');
- Route::put('/orders/{order}',[CommerceController::class,'updateOrder'])->middleware('permission:commerce.manage')->name('orders.update');
+
+Route::prefix('administration/commerce')->name('administration.commerce.')->middleware(['auth', 'permission:commerce.view'])->group(function () {
+ Route::get('/', [CommerceController::class, 'index'])->name('index');
+ Route::post('/products', [CommerceController::class, 'storeProduct'])->middleware('permission:commerce.manage')->name('products.store');
+ Route::put('/products/{product}', [CommerceController::class, 'updateProduct'])->middleware('permission:commerce.manage')->name('products.update');
+ Route::delete('/products/{product}', [CommerceController::class, 'destroyProduct'])->middleware('permission:commerce.manage')->name('products.destroy');
+ Route::put('/orders/{order}', [CommerceController::class, 'updateOrder'])->middleware('permission:commerce.manage')->name('orders.update');
});
diff --git a/Modules/Commerce/Services/ConfiguredTaxProvider.php b/Modules/Commerce/Services/ConfiguredTaxProvider.php
index 0952898..ee0a4ad 100644
--- a/Modules/Commerce/Services/ConfiguredTaxProvider.php
+++ b/Modules/Commerce/Services/ConfiguredTaxProvider.php
@@ -1,4 +1,13 @@
increment('stock_quantity',$quantity); InventoryMovement::create(['product_id'=>$product->id,'quantity'=>$quantity,'reason'=>$reason,'created_by'=>$userId]); });
+ DB::transaction(function () use ($product, $quantity, $reason, $userId) {
+ $product->increment('stock_quantity', $quantity);
+ InventoryMovement::create(['product_id' => $product->id, 'quantity' => $quantity, 'reason' => $reason, 'created_by' => $userId]);
+ });
+ }
+
+ public function available(Product $product): int
+ {
+ return max(0, (int) $product->stock_quantity);
}
- public function available(Product $product): int { return max(0,(int)$product->stock_quantity); }
}
diff --git a/Modules/Commerce/Services/FlatRateShippingProvider.php b/Modules/Commerce/Services/FlatRateShippingProvider.php
index d7cf982..30e08ed 100644
--- a/Modules/Commerce/Services/FlatRateShippingProvider.php
+++ b/Modules/Commerce/Services/FlatRateShippingProvider.php
@@ -1,4 +1,13 @@
= 50 ? 0.0 : 4.95; } }
+
+final class FlatRateShippingProvider implements ShippingProvider
+{
+ public function quote(array $address, float $subtotal): float
+ {
+ return $subtotal >= 50 ? 0.0 : 4.95;
+ }
+}
diff --git a/Modules/Commerce/Services/ManualPaymentProvider.php b/Modules/Commerce/Services/ManualPaymentProvider.php
index 9e99055..5d7ebed 100644
--- a/Modules/Commerce/Services/ManualPaymentProvider.php
+++ b/Modules/Commerce/Services/ManualPaymentProvider.php
@@ -1,6 +1,19 @@
'pending','provider'=>'manual','reference'=>null]; } public function refund(Order $order,float $amount): array { return ['status'=>'pending','provider'=>'manual','amount'=>$amount]; } }
+{
+ public function charge(Order $order, array $data = []): array
+ {
+ return ['status' => 'pending', 'provider' => 'manual', 'reference' => null];
+ }
+
+ public function refund(Order $order, float $amount): array
+ {
+ return ['status' => 'pending', 'provider' => 'manual', 'amount' => $amount];
+ }
+}
diff --git a/Modules/Commerce/Tests/Feature/ModuleBootTest.php b/Modules/Commerce/Tests/Feature/ModuleBootTest.php
index 63d503e..2932611 100644
--- a/Modules/Commerce/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Commerce/Tests/Feature/ModuleBootTest.php
@@ -1,8 +1,19 @@
assertTrue(app('router')->has('administration.commerce.index')); }
- public function test_commerce_contracts_are_bound(): void { $this->assertInstanceOf(\Modules\Commerce\Contracts\InventoryProvider::class, app(\Modules\Commerce\Contracts\InventoryProvider::class)); }
+ public function test_commerce_routes_are_registered(): void
+ {
+ $this->assertTrue(app('router')->has('administration.commerce.index'));
+ }
+
+ public function test_commerce_contracts_are_bound(): void
+ {
+ $this->assertInstanceOf(InventoryProvider::class, app(InventoryProvider::class));
+ }
}
diff --git a/Modules/Core/Contracts/ModuleRegistry.php b/Modules/Core/Contracts/ModuleRegistry.php
index 5734b2a..e05ca63 100644
--- a/Modules/Core/Contracts/ModuleRegistry.php
+++ b/Modules/Core/Contracts/ModuleRegistry.php
@@ -6,5 +6,6 @@ interface ModuleRegistry
{
/** @return array */
public function all(): array;
+
public function enabled(string $name): bool;
}
diff --git a/Modules/Core/Providers/CoreServiceProvider.php b/Modules/Core/Providers/CoreServiceProvider.php
index e785888..cee3091 100644
--- a/Modules/Core/Providers/CoreServiceProvider.php
+++ b/Modules/Core/Providers/CoreServiceProvider.php
@@ -3,9 +3,9 @@
namespace Modules\Core\Providers;
use Illuminate\Support\ServiceProvider;
-use Modules\Core\Support\ModuleInfo;
use Modules\Core\Contracts\ModuleRegistry;
use Modules\Core\Services\LaravelModuleRegistry;
+use Modules\Core\Support\ModuleInfo;
final class CoreServiceProvider extends ServiceProvider
{
diff --git a/Modules/Core/Routes/web.php b/Modules/Core/Routes/web.php
index d6152a0..4c3fffc 100644
--- a/Modules/Core/Routes/web.php
+++ b/Modules/Core/Routes/web.php
@@ -1,5 +1,3 @@
$a['name'] <=> $b['name']);
+
return $modules;
}
public function enabled(string $name): bool
{
foreach ($this->all() as $module) {
- if (strcasecmp($module['name'], $name) === 0) return $module['enabled'];
+ if (strcasecmp($module['name'], $name) === 0) {
+ return $module['enabled'];
+ }
}
+
return false;
}
}
diff --git a/Modules/Core/Support/ModuleInfo.php b/Modules/Core/Support/ModuleInfo.php
index ad8de4a..5710024 100644
--- a/Modules/Core/Support/ModuleInfo.php
+++ b/Modules/Core/Support/ModuleInfo.php
@@ -4,6 +4,13 @@ namespace Modules\Core\Support;
final class ModuleInfo
{
- public static function name(): string { return 'Core'; }
- public static function nameLower(): string { return 'core'; }
+ public static function name(): string
+ {
+ return 'Core';
+ }
+
+ public static function nameLower(): string
+ {
+ return 'core';
+ }
}
diff --git a/Modules/Developer/Console/Commands/InspectModulesCommand.php b/Modules/Developer/Console/Commands/InspectModulesCommand.php
index 7d95618..11f3078 100644
--- a/Modules/Developer/Console/Commands/InspectModulesCommand.php
+++ b/Modules/Developer/Console/Commands/InspectModulesCommand.php
@@ -7,6 +7,7 @@ use Illuminate\Console\Command;
final class InspectModulesCommand extends Command
{
protected $signature = 'module:inspect {name?} {--json}';
+
protected $description = 'Validate module manifests and display dependencies and providers.';
public function handle(): int
@@ -16,8 +17,14 @@ final class InspectModulesCommand extends Command
$rows = [];
foreach ($files as $file) {
$manifest = json_decode((string) file_get_contents($file), true);
- if (! is_array($manifest)) { $this->error("Invalid JSON: {$file}"); return self::FAILURE; }
- if ($name && strcasecmp((string) ($manifest['name'] ?? ''), (string) $name) !== 0) { continue; }
+ if (! is_array($manifest)) {
+ $this->error("Invalid JSON: {$file}");
+
+ return self::FAILURE;
+ }
+ if ($name && strcasecmp((string) ($manifest['name'] ?? ''), (string) $name) !== 0) {
+ continue;
+ }
$rows[] = [
'name' => $manifest['name'] ?? basename(dirname($file)),
'version' => $manifest['version'] ?? 'unknown',
@@ -25,8 +32,13 @@ final class InspectModulesCommand extends Command
'providers' => count($manifest['providers'] ?? []),
];
}
- if ($this->option('json')) { $this->line(json_encode($rows, JSON_PRETTY_PRINT)); return self::SUCCESS; }
+ if ($this->option('json')) {
+ $this->line(json_encode($rows, JSON_PRETTY_PRINT));
+
+ return self::SUCCESS;
+ }
$this->table(['Module', 'Version', 'Requires', 'Providers'], $rows);
+
return self::SUCCESS;
}
}
diff --git a/Modules/Developer/Console/Commands/MakeCrudCommand.php b/Modules/Developer/Console/Commands/MakeCrudCommand.php
index 8c83989..d615a48 100644
--- a/Modules/Developer/Console/Commands/MakeCrudCommand.php
+++ b/Modules/Developer/Console/Commands/MakeCrudCommand.php
@@ -9,15 +9,24 @@ use Throwable;
final class MakeCrudCommand extends Command
{
protected $signature = 'crud:make {model} {--module=} {--force}';
+
protected $description = 'Generate model, migration, request, controller, views and test inside a module.';
public function handle(CrudGenerator $generator): int
{
$module = $this->option('module') ?: $this->choice('Select the target module', $this->modules());
- try { $files = $generator->generate((string) $module, $this->argument('model'), (bool) $this->option('force')); }
- catch (Throwable $e) { $this->error($e->getMessage()); return self::FAILURE; }
- foreach ($files as $file) { $this->line("created {$file}"); }
+ try {
+ $files = $generator->generate((string) $module, $this->argument('model'), (bool) $this->option('force'));
+ } catch (Throwable $e) {
+ $this->error($e->getMessage());
+
+ return self::FAILURE;
+ }
+ foreach ($files as $file) {
+ $this->line("created {$file}");
+ }
$this->warn('Register the generated controller routes in the target module Routes/web.php file.');
+
return self::SUCCESS;
}
diff --git a/Modules/Developer/Console/Commands/MakeModuleCommand.php b/Modules/Developer/Console/Commands/MakeModuleCommand.php
index 53e48d9..34ece37 100644
--- a/Modules/Developer/Console/Commands/MakeModuleCommand.php
+++ b/Modules/Developer/Console/Commands/MakeModuleCommand.php
@@ -9,14 +9,24 @@ use Throwable;
final class MakeModuleCommand extends Command
{
protected $signature = 'module:make {name} {--force}';
+
protected $description = 'Generate a complete modular Laravel module scaffold.';
public function handle(ModuleGenerator $generator): int
{
- try { $files = $generator->generate($this->argument('name'), (bool) $this->option('force')); }
- catch (Throwable $e) { $this->error($e->getMessage()); return self::FAILURE; }
- foreach ($files as $file) { $this->line("created {$file}"); }
- $this->newLine(); $this->info('Enable the module in modules_statuses.json, then run composer dump-autoload.');
+ try {
+ $files = $generator->generate($this->argument('name'), (bool) $this->option('force'));
+ } catch (Throwable $e) {
+ $this->error($e->getMessage());
+
+ return self::FAILURE;
+ }
+ foreach ($files as $file) {
+ $this->line("created {$file}");
+ }
+ $this->newLine();
+ $this->info('Enable the module in modules_statuses.json, then run composer dump-autoload.');
+
return self::SUCCESS;
}
}
diff --git a/Modules/Developer/Console/Commands/MakeThemeCommand.php b/Modules/Developer/Console/Commands/MakeThemeCommand.php
index 50c803b..2457559 100644
--- a/Modules/Developer/Console/Commands/MakeThemeCommand.php
+++ b/Modules/Developer/Console/Commands/MakeThemeCommand.php
@@ -9,13 +9,22 @@ use Throwable;
final class MakeThemeCommand extends Command
{
protected $signature = 'theme:make {name} {--force}';
+
protected $description = 'Generate a theme scaffold with metadata, layout and assets.';
public function handle(ThemeGenerator $generator): int
{
- try { $files = $generator->generate($this->argument('name'), (bool) $this->option('force')); }
- catch (Throwable $e) { $this->error($e->getMessage()); return self::FAILURE; }
- foreach ($files as $file) { $this->line("created {$file}"); }
+ try {
+ $files = $generator->generate($this->argument('name'), (bool) $this->option('force'));
+ } catch (Throwable $e) {
+ $this->error($e->getMessage());
+
+ return self::FAILURE;
+ }
+ foreach ($files as $file) {
+ $this->line("created {$file}");
+ }
+
return self::SUCCESS;
}
}
diff --git a/Modules/Developer/Support/CrudGenerator.php b/Modules/Developer/Support/CrudGenerator.php
index c1b7c07..90db77c 100644
--- a/Modules/Developer/Support/CrudGenerator.php
+++ b/Modules/Developer/Support/CrudGenerator.php
@@ -38,6 +38,7 @@ final class CrudGenerator extends Generator
private function controller(string $module, string $model): string
{
$plural = str($model)->plural()->kebab();
+
return "snake()."::admin.{$plural}.index', ['records' => {$model}::query()->latest()->paginate()]);\n }\n\n public function store(Store{$model}Request \$request): RedirectResponse\n {\n {$model}::query()->create(\$request->validated());\n return back()->with('status', '{$model} created.');\n }\n}\n";
}
diff --git a/Modules/Developer/Support/ThemeGenerator.php b/Modules/Developer/Support/ThemeGenerator.php
index e1ed4b7..dd1de18 100644
--- a/Modules/Developer/Support/ThemeGenerator.php
+++ b/Modules/Developer/Support/ThemeGenerator.php
@@ -13,9 +13,12 @@ final class ThemeGenerator extends Generator
"$root/views/layouts/app.blade.php" => "getLocale()) }}\">{{ \$title ?? config('app.name') }}@vite(['resources/css/app.css', 'resources/js/app.js']){{ \$slot }}\n",
"$root/views/home.blade.php" => "{{ config('app.name') }}
\n",
"$root/assets/css/theme.css" => ":root { --theme-radius: 0.5rem; }\n",
- "$root/README.md" => "# ".str($name)->headline()." Theme\n\nGenerated by `php artisan theme:make {$name}`.\n",
+ "$root/README.md" => '# '.str($name)->headline()." Theme\n\nGenerated by `php artisan theme:make {$name}`.\n",
];
- foreach ($files as $path => $contents) { $this->write($path, $contents, $force); }
+ foreach ($files as $path => $contents) {
+ $this->write($path, $contents, $force);
+ }
+
return array_keys($files);
}
}
diff --git a/Modules/Documents/Database/Migrations/2026_07_24_000500_create_documents_table.php b/Modules/Documents/Database/Migrations/2026_07_24_000500_create_documents_table.php
index 60bc66e..87aaf16 100644
--- a/Modules/Documents/Database/Migrations/2026_07_24_000500_create_documents_table.php
+++ b/Modules/Documents/Database/Migrations/2026_07_24_000500_create_documents_table.php
@@ -4,12 +4,23 @@ 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('documents', function (Blueprint $table): void { $table->id();
+return new class extends Migration
+{
+ public function up(): void
+ {
+ Schema::create('documents', function (Blueprint $table): void {
+ $table->id();
$table->string('title');
$table->text('description')->nullable();
$table->string('file_path')->nullable();
$table->boolean('is_public')->default(true);
- $table->timestamps(); $table->softDeletes(); }); }
- public function down(): void { Schema::dropIfExists('documents'); }
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('documents');
+ }
};
diff --git a/Modules/Documents/Http/Controllers/DocumentController.php b/Modules/Documents/Http/Controllers/DocumentController.php
index e14d691..d4701d0 100644
--- a/Modules/Documents/Http/Controllers/DocumentController.php
+++ b/Modules/Documents/Http/Controllers/DocumentController.php
@@ -10,16 +10,49 @@ use Modules\Documents\Models\Document;
final class DocumentController extends Controller
{
- public function index(): View { return view('documents::admin.index', ['records' => Document::query()->latest()->paginate(20)]); }
- public function create(): View { return view('documents::admin.form'); }
- public function store(Request $request): RedirectResponse { $data = $request->validate(['title' => ['required', 'string', 'max:255'],
+ public function index(): View
+ {
+ return view('documents::admin.index', ['records' => Document::query()->latest()->paginate(20)]);
+ }
+
+ public function create(): View
+ {
+ return view('documents::admin.form');
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'file_path' => ['nullable', 'string'],
- 'is_public' => ['sometimes', 'boolean']]); $data['is_public'] = $request->boolean('is_public'); Document::create($data); return redirect()->route('documents.index')->with('status', 'Document created.'); }
- public function edit(Document $document): View { return view('documents::admin.form', ['record' => $document]); }
- public function update(Request $request, Document $document): RedirectResponse { $data = $request->validate(['title' => ['required', 'string', 'max:255'],
+ 'is_public' => ['sometimes', 'boolean']]);
+ $data['is_public'] = $request->boolean('is_public');
+ Document::create($data);
+
+ return redirect()->route('documents.index')->with('status', 'Document created.');
+ }
+
+ public function edit(Document $document): View
+ {
+ return view('documents::admin.form', ['record' => $document]);
+ }
+
+ public function update(Request $request, Document $document): RedirectResponse
+ {
+ $data = $request->validate(['title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'file_path' => ['nullable', 'string'],
- 'is_public' => ['sometimes', 'boolean']]); $data['is_public'] = $request->boolean('is_public'); $document->update($data); return redirect()->route('documents.index')->with('status', 'Document updated.'); }
- public function destroy(Document $document): RedirectResponse { $document->delete(); return back()->with('status', 'Document deleted.'); }
+ 'is_public' => ['sometimes', 'boolean']]);
+ $data['is_public'] = $request->boolean('is_public');
+ $document->update($data);
+
+ return redirect()->route('documents.index')->with('status', 'Document updated.');
+ }
+
+ public function destroy(Document $document): RedirectResponse
+ {
+ $document->delete();
+
+ return back()->with('status', 'Document deleted.');
+ }
}
diff --git a/Modules/Documents/Models/Document.php b/Modules/Documents/Models/Document.php
index 8009f45..2d9d2cf 100644
--- a/Modules/Documents/Models/Document.php
+++ b/Modules/Documents/Models/Document.php
@@ -9,11 +9,15 @@ use Illuminate\Database\Eloquent\SoftDeletes;
final class Document extends Model
{
use HasFactory, SoftDeletes;
- protected $table = 'documents';
- protected $fillable = ['title', 'description', 'file_path', 'is_public'];
-
- protected function casts(): array { return [
- 'is_public' => 'boolean'
- ]; }
+ protected $table = 'documents';
+
+ protected $fillable = ['title', 'description', 'file_path', 'is_public'];
+
+ protected function casts(): array
+ {
+ return [
+ 'is_public' => 'boolean',
+ ];
+ }
}
diff --git a/Modules/Documents/Support/ModuleInfo.php b/Modules/Documents/Support/ModuleInfo.php
index 2643ae5..c815ec2 100644
--- a/Modules/Documents/Support/ModuleInfo.php
+++ b/Modules/Documents/Support/ModuleInfo.php
@@ -2,4 +2,15 @@
namespace Modules\Documents\Support;
-final class ModuleInfo { public static function name(): string { return 'Documents'; } public static function nameLower(): string { return 'documents'; } }
+final class ModuleInfo
+{
+ public static function name(): string
+ {
+ return 'Documents';
+ }
+
+ public static function nameLower(): string
+ {
+ return 'documents';
+ }
+}
diff --git a/Modules/Documents/Tests/Feature/ModuleBootTest.php b/Modules/Documents/Tests/Feature/ModuleBootTest.php
index d1dc93f..7a0edd4 100644
--- a/Modules/Documents/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Documents/Tests/Feature/ModuleBootTest.php
@@ -2,4 +2,6 @@
use Illuminate\Support\Facades\Route;
-it('registers the Documents administration route', function (): void { expect(Route::has('administration.documents.index'))->toBeTrue(); });
+it('registers the Documents administration route', function (): void {
+ expect(Route::has('administration.documents.index'))->toBeTrue();
+});
diff --git a/Modules/Enterprise/Config/config.php b/Modules/Enterprise/Config/config.php
index 12b78c1..2dd4c3a 100644
--- a/Modules/Enterprise/Config/config.php
+++ b/Modules/Enterprise/Config/config.php
@@ -1,4 +1,5 @@
env('ENTERPRISE_BACKUP_DISK', 'local'),
'backup_path' => env('ENTERPRISE_BACKUP_PATH', 'backups'),
diff --git a/Modules/Enterprise/Console/BackupCreateCommand.php b/Modules/Enterprise/Console/BackupCreateCommand.php
index feeebc9..3492f0c 100644
--- a/Modules/Enterprise/Console/BackupCreateCommand.php
+++ b/Modules/Enterprise/Console/BackupCreateCommand.php
@@ -1,4 +1,5 @@
create(); $this->info('Backup created: '.$backup->path); return self::SUCCESS; }
+
+ public function handle(BackupService $service): int
+ {
+ $backup = $service->create();
+ $this->info('Backup created: '.$backup->path);
+
+ return self::SUCCESS;
+ }
}
diff --git a/Modules/Enterprise/Console/BackupPruneCommand.php b/Modules/Enterprise/Console/BackupPruneCommand.php
index c77a56c..bd7fbf4 100644
--- a/Modules/Enterprise/Console/BackupPruneCommand.php
+++ b/Modules/Enterprise/Console/BackupPruneCommand.php
@@ -1,4 +1,5 @@
info($service->prune().' backup(s) pruned.'); return self::SUCCESS; }
+
+ public function handle(BackupService $service): int
+ {
+ $this->info($service->prune().' backup(s) pruned.');
+
+ return self::SUCCESS;
+ }
}
diff --git a/Modules/Enterprise/Console/HealthCheckCommand.php b/Modules/Enterprise/Console/HealthCheckCommand.php
index 9961fd5..40d1b07 100644
--- a/Modules/Enterprise/Console/HealthCheckCommand.php
+++ b/Modules/Enterprise/Console/HealthCheckCommand.php
@@ -1,4 +1,5 @@
run();
HealthRun::create(['status' => $result['status'], 'results' => $result, 'ran_at' => now()]);
- if ($this->option('json')) $this->line(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
- else {
+ if ($this->option('json')) {
+ $this->line(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+ } else {
$this->table(['Check', 'Status', 'Message'], collect($result['checks'])->map(fn (array $check) => [$check['name'], $check['status'], $check['message']])->all());
$this->line('Overall: '.$result['status']);
}
+
return $result['status'] === 'failed' ? self::FAILURE : self::SUCCESS;
}
}
diff --git a/Modules/Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php b/Modules/Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php
index 12c8f3f..1413a3f 100644
--- a/Modules/Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php
+++ b/Modules/Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php
@@ -1,9 +1,11 @@
run();
HealthRun::create(['user_id' => auth()->id(), 'status' => $result['status'], 'results' => $result, 'ran_at' => now()]);
+
return back()->with('status', 'Health checks completed.');
}
@@ -40,18 +42,21 @@ final class EnterpriseController
{
Artisan::call('optimize:clear');
Cache::flush();
+
return back()->with('status', 'Application and cache stores cleared.');
}
public function runScheduler(): RedirectResponse
{
Artisan::call('schedule:run');
+
return back()->with('status', trim(Artisan::output()) ?: 'Scheduler executed.');
}
public function retryFailedJobs(): RedirectResponse
{
Artisan::call('queue:retry', ['id' => 'all']);
+
return back()->with('status', trim(Artisan::output()) ?: 'Failed jobs queued for retry.');
}
@@ -59,9 +64,11 @@ final class EnterpriseController
{
try {
$service->create(auth()->id());
+
return back()->with('status', 'Backup created.');
} catch (Throwable $e) {
report($e);
+
return back()->withErrors(['backup' => $e->getMessage()]);
}
}
@@ -69,6 +76,7 @@ final class EnterpriseController
public function deleteBackup(Backup $backup, BackupService $service): RedirectResponse
{
$service->delete($backup);
+
return back()->with('status', 'Backup deleted.');
}
}
diff --git a/Modules/Enterprise/Models/Backup.php b/Modules/Enterprise/Models/Backup.php
index 3d5ba6e..14ecef9 100644
--- a/Modules/Enterprise/Models/Backup.php
+++ b/Modules/Enterprise/Models/Backup.php
@@ -1,4 +1,5 @@
'datetime']; }
- public function user(): BelongsTo { return $this->belongsTo(User::class); }
+
+ protected function casts(): array
+ {
+ return ['completed_at' => 'datetime'];
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
}
diff --git a/Modules/Enterprise/Models/HealthRun.php b/Modules/Enterprise/Models/HealthRun.php
index eba66b0..34824c2 100644
--- a/Modules/Enterprise/Models/HealthRun.php
+++ b/Modules/Enterprise/Models/HealthRun.php
@@ -1,4 +1,5 @@
'array', 'ran_at' => 'datetime']; }
- public function user(): BelongsTo { return $this->belongsTo(User::class); }
+
+ protected function casts(): array
+ {
+ return ['results' => 'array', 'ran_at' => 'datetime'];
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
}
diff --git a/Modules/Enterprise/Providers/EnterpriseServiceProvider.php b/Modules/Enterprise/Providers/EnterpriseServiceProvider.php
index 59418dc..a532d0a 100644
--- a/Modules/Enterprise/Providers/EnterpriseServiceProvider.php
+++ b/Modules/Enterprise/Providers/EnterpriseServiceProvider.php
@@ -1,4 +1,5 @@
format('Ymd-His').'.zip';
$relativePath = $directory.'/'.$name;
$temporary = storage_path('app/'.$name);
- $zip = new ZipArchive();
- if ($zip->open($temporary, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) throw new RuntimeException('Unable to create backup archive.');
+ $zip = new ZipArchive;
+ if ($zip->open($temporary, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
+ throw new RuntimeException('Unable to create backup archive.');
+ }
$manifest = json_encode([
'application' => config('app.name'),
@@ -30,10 +35,14 @@ final class BackupService
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$zip->addFromString('manifest.json', $manifest ?: '{}');
$zip->addFile(base_path('composer.json'), 'composer.json');
- if (File::exists(base_path('modules_statuses.json'))) $zip->addFile(base_path('modules_statuses.json'), 'modules_statuses.json');
+ if (File::exists(base_path('modules_statuses.json'))) {
+ $zip->addFile(base_path('modules_statuses.json'), 'modules_statuses.json');
+ }
if (config('database.default') === 'sqlite') {
$database = database_path('database.sqlite');
- if (File::exists($database)) $zip->addFile($database, 'database/database.sqlite');
+ if (File::exists($database)) {
+ $zip->addFile($database, 'database/database.sqlite');
+ }
}
$zip->close();
@@ -61,6 +70,7 @@ final class BackupService
$cutoff = now()->subDays((int) config('enterprise.retention_days', 30));
$backups = Backup::query()->where('created_at', '<', $cutoff)->get();
$backups->each(fn (Backup $backup) => $this->delete($backup));
+
return $backups->count();
}
}
diff --git a/Modules/Enterprise/Services/SystemHealthService.php b/Modules/Enterprise/Services/SystemHealthService.php
index 01d1348..b08b319 100644
--- a/Modules/Enterprise/Services/SystemHealthService.php
+++ b/Modules/Enterprise/Services/SystemHealthService.php
@@ -1,4 +1,5 @@
getPdo();
+
return $this->result('Database', 'healthy', DB::connection()->getDriverName());
} catch (Throwable $e) {
return $this->result('Database', 'failed', $e->getMessage());
@@ -43,6 +45,7 @@ final class SystemHealthService
Cache::put($key, 'ok', 10);
$healthy = Cache::get($key) === 'ok';
Cache::forget($key);
+
return $this->result('Cache', $healthy ? 'healthy' : 'failed', config('cache.default'));
} catch (Throwable $e) {
return $this->result('Cache', 'failed', $e->getMessage());
@@ -56,6 +59,7 @@ final class SystemHealthService
Storage::disk('local')->delete('enterprise-health.txt');
$freeMb = (int) floor(disk_free_space(storage_path()) / 1024 / 1024);
$minimum = (int) config('enterprise.health.minimum_free_disk_mb', 512);
+
return $this->result('Storage', $freeMb < $minimum ? 'warning' : 'healthy', $freeMb.' MB free');
} catch (Throwable $e) {
return $this->result('Storage', 'failed', $e->getMessage());
@@ -66,14 +70,20 @@ final class SystemHealthService
{
$failed = Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : 0;
$maximum = (int) config('enterprise.health.maximum_failed_jobs', 0);
+
return $this->result('Queue', $failed > $maximum ? 'warning' : 'healthy', $failed.' failed jobs');
}
private function environment(): array
{
$warnings = [];
- if (app()->environment('production') && config('app.debug')) $warnings[] = 'APP_DEBUG is enabled';
- if (! app()->environment('production')) $warnings[] = 'Not running in production';
+ if (app()->environment('production') && config('app.debug')) {
+ $warnings[] = 'APP_DEBUG is enabled';
+ }
+ if (! app()->environment('production')) {
+ $warnings[] = 'Not running in production';
+ }
+
return $this->result('Environment', $warnings ? 'warning' : 'healthy', $warnings ? implode('; ', $warnings) : app()->environment());
}
diff --git a/Modules/Enterprise/Tests/Feature/EnterpriseModuleBootTest.php b/Modules/Enterprise/Tests/Feature/EnterpriseModuleBootTest.php
index 1715ea5..fe1d1d1 100644
--- a/Modules/Enterprise/Tests/Feature/EnterpriseModuleBootTest.php
+++ b/Modules/Enterprise/Tests/Feature/EnterpriseModuleBootTest.php
@@ -1,7 +1,9 @@
expect(app()->providerIsLoaded(Modules\Enterprise\Providers\EnterpriseServiceProvider::class))->toBeTrue());
+it('boots the enterprise module', fn () => expect(app()->providerIsLoaded(EnterpriseServiceProvider::class))->toBeTrue());
it('runs platform health checks', function (): void {
$result = app(SystemHealthService::class)->run();
expect($result)->toHaveKeys(['status', 'checks', 'generated_at'])->and($result['checks'])->not->toBeEmpty();
diff --git a/Modules/Events/Config/config.php b/Modules/Events/Config/config.php
index 13c1a29..6e87093 100644
--- a/Modules/Events/Config/config.php
+++ b/Modules/Events/Config/config.php
@@ -1,2 +1,3 @@
'Events'];
diff --git a/Modules/Events/Database/Migrations/2026_07_24_04200_create_events_tables.php b/Modules/Events/Database/Migrations/2026_07_24_04200_create_events_tables.php
index 5368f69..a974f12 100644
--- a/Modules/Events/Database/Migrations/2026_07_24_04200_create_events_tables.php
+++ b/Modules/Events/Database/Migrations/2026_07_24_04200_create_events_tables.php
@@ -1,3 +1,51 @@
id();$t->string('name');$t->text('address')->nullable();$t->unsignedInteger('capacity')->nullable();$t->timestamps();}); Schema::create('events',function(Blueprint $t): void{$t->id();$t->foreignId('venue_id')->nullable()->constrained('event_venues')->nullOnDelete();$t->string('title');$t->string('slug')->unique();$t->text('summary')->nullable();$t->longText('description')->nullable();$t->timestamp('starts_at')->index();$t->timestamp('ends_at')->nullable();$t->string('status')->default('draft')->index();$t->unsignedInteger('capacity')->nullable();$t->decimal('price',10,2)->default(0);$t->boolean('registration_open')->default(false);$t->timestamps();}); Schema::create('event_registrations',function(Blueprint $t): void{$t->id();$t->foreignId('event_id')->constrained()->cascadeOnDelete();$t->string('name');$t->string('email')->index();$t->unsignedInteger('quantity')->default(1);$t->string('status')->default('confirmed')->index();$t->text('notes')->nullable();$t->timestamps();}); } public function down(): void { Schema::dropIfExists('event_registrations');Schema::dropIfExists('events');Schema::dropIfExists('event_venues'); } };
+
+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('event_venues', function (Blueprint $t): void {
+ $t->id();
+ $t->string('name');
+ $t->text('address')->nullable();
+ $t->unsignedInteger('capacity')->nullable();
+ $t->timestamps();
+ });
+ Schema::create('events', function (Blueprint $t): void {
+ $t->id();
+ $t->foreignId('venue_id')->nullable()->constrained('event_venues')->nullOnDelete();
+ $t->string('title');
+ $t->string('slug')->unique();
+ $t->text('summary')->nullable();
+ $t->longText('description')->nullable();
+ $t->timestamp('starts_at')->index();
+ $t->timestamp('ends_at')->nullable();
+ $t->string('status')->default('draft')->index();
+ $t->unsignedInteger('capacity')->nullable();
+ $t->decimal('price', 10, 2)->default(0);
+ $t->boolean('registration_open')->default(false);
+ $t->timestamps();
+ });
+ Schema::create('event_registrations', function (Blueprint $t): void {
+ $t->id();
+ $t->foreignId('event_id')->constrained()->cascadeOnDelete();
+ $t->string('name');
+ $t->string('email')->index();
+ $t->unsignedInteger('quantity')->default(1);
+ $t->string('status')->default('confirmed')->index();
+ $t->text('notes')->nullable();
+ $t->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('event_registrations');
+ Schema::dropIfExists('events');
+ Schema::dropIfExists('event_venues');
+ }
+};
diff --git a/Modules/Events/Http/Controllers/EventController.php b/Modules/Events/Http/Controllers/EventController.php
index ef50ff9..5668979 100644
--- a/Modules/Events/Http/Controllers/EventController.php
+++ b/Modules/Events/Http/Controllers/EventController.php
@@ -1,3 +1,32 @@
Event::with(['venue'])->withCount('registrations')->orderBy('starts_at')->paginate(20),'venues'=>Venue::orderBy('name')->get()]); } public function store(Request $r): RedirectResponse { Event::create($r->validate(['venue_id'=>['nullable','exists:event_venues,id'],'title'=>['required','string','max:255'],'slug'=>['required','string','max:255','unique:events,slug'],'summary'=>['nullable','string'],'description'=>['nullable','string'],'starts_at'=>['required','date'],'ends_at'=>['nullable','date','after:starts_at'],'status'=>['required','in:draft,published,cancelled,completed'],'capacity'=>['nullable','integer','min:1'],'price'=>['required','numeric','min:0']])+['registration_open'=>$r->boolean('registration_open')]); return back()->with('status','Event created.'); } public function destroy(Event $event): RedirectResponse { $event->delete(); return back()->with('status','Event deleted.'); } }
+
+namespace Modules\Events\Http\Controllers;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\View\View;
+use Modules\Events\Models\Event;
+use Modules\Events\Models\Venue;
+
+final class EventController extends Controller
+{
+ public function index(): View
+ {
+ return view('events::admin.index', ['events' => Event::with(['venue'])->withCount('registrations')->orderBy('starts_at')->paginate(20), 'venues' => Venue::orderBy('name')->get()]);
+ }
+
+ public function store(Request $r): RedirectResponse
+ {
+ Event::create($r->validate(['venue_id' => ['nullable', 'exists:event_venues,id'], 'title' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255', 'unique:events,slug'], 'summary' => ['nullable', 'string'], 'description' => ['nullable', 'string'], 'starts_at' => ['required', 'date'], 'ends_at' => ['nullable', 'date', 'after:starts_at'], 'status' => ['required', 'in:draft,published,cancelled,completed'], 'capacity' => ['nullable', 'integer', 'min:1'], 'price' => ['required', 'numeric', 'min:0']]) + ['registration_open' => $r->boolean('registration_open')]);
+
+ return back()->with('status', 'Event created.');
+ }
+
+ public function destroy(Event $event): RedirectResponse
+ {
+ $event->delete();
+
+ return back()->with('status', 'Event deleted.');
+ }
+}
diff --git a/Modules/Events/Models/Event.php b/Modules/Events/Models/Event.php
index a96b5f4..cb1d6d4 100644
--- a/Modules/Events/Models/Event.php
+++ b/Modules/Events/Models/Event.php
@@ -1,3 +1,27 @@
'datetime','ends_at'=>'datetime','price'=>'decimal:2','registration_open'=>'boolean']; } public function venue(): BelongsTo { return $this->belongsTo(Venue::class); } public function registrations(): HasMany { return $this->hasMany(Registration::class); } }
+
+namespace Modules\Events\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+
+final class Event extends Model
+{
+ protected $fillable = ['venue_id', 'title', 'slug', 'summary', 'description', 'starts_at', 'ends_at', 'status', 'capacity', 'price', 'registration_open'];
+
+ protected function casts(): array
+ {
+ return ['starts_at' => 'datetime', 'ends_at' => 'datetime', 'price' => 'decimal:2', 'registration_open' => 'boolean'];
+ }
+
+ public function venue(): BelongsTo
+ {
+ return $this->belongsTo(Venue::class);
+ }
+
+ public function registrations(): HasMany
+ {
+ return $this->hasMany(Registration::class);
+ }
+}
diff --git a/Modules/Events/Models/Registration.php b/Modules/Events/Models/Registration.php
index ac071c9..1ea8d7f 100644
--- a/Modules/Events/Models/Registration.php
+++ b/Modules/Events/Models/Registration.php
@@ -1,3 +1,12 @@
app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Events', 'Config/config.php'), 'events');
}
+
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Events', 'Database/Migrations'));
diff --git a/Modules/Events/Providers/RouteServiceProvider.php b/Modules/Events/Providers/RouteServiceProvider.php
index 136f133..ca21554 100644
--- a/Modules/Events/Providers/RouteServiceProvider.php
+++ b/Modules/Events/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
prefix('administration/events')->name('administration.events.')->group(function(): void { Route::get('/',[EventController::class,'index'])->name('index'); Route::post('/',[EventController::class,'store'])->name('store'); Route::delete('/{event}',[EventController::class,'destroy'])->name('destroy'); });
+
+use Illuminate\Support\Facades\Route;
+use Modules\Events\Http\Controllers\EventController;
+
+Route::middleware(['auth', 'verified'])->prefix('administration/events')->name('administration.events.')->group(function (): void {
+ Route::get('/', [EventController::class, 'index'])->name('index');
+ Route::post('/', [EventController::class, 'store'])->name('store');
+ Route::delete('/{event}', [EventController::class, 'destroy'])->name('destroy');
+});
diff --git a/Modules/Events/Tests/Feature/ModuleBootTest.php b/Modules/Events/Tests/Feature/ModuleBootTest.php
index 0c262ac..27cd6b4 100644
--- a/Modules/Events/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Events/Tests/Feature/ModuleBootTest.php
@@ -1,6 +1,9 @@
id();
+return new class extends Migration
+{
+ public function up(): void
+ {
+ Schema::create('faqs', function (Blueprint $table): void {
+ $table->id();
$table->string('question');
$table->longText('answer');
$table->string('category')->nullable();
$table->unsignedInteger('sort_order')->default(0);
$table->boolean('enabled')->default(true);
- $table->timestamps(); $table->softDeletes(); }); }
- public function down(): void { Schema::dropIfExists('faqs'); }
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('faqs');
+ }
};
diff --git a/Modules/Faq/Http/Controllers/FaqController.php b/Modules/Faq/Http/Controllers/FaqController.php
index e58c082..03d09ba 100644
--- a/Modules/Faq/Http/Controllers/FaqController.php
+++ b/Modules/Faq/Http/Controllers/FaqController.php
@@ -10,18 +10,51 @@ use Modules\Faq\Models\Faq;
final class FaqController extends Controller
{
- public function index(): View { return view('faq::admin.index', ['records' => Faq::query()->latest()->paginate(20)]); }
- public function create(): View { return view('faq::admin.form'); }
- public function store(Request $request): RedirectResponse { $data = $request->validate(['question' => ['required', 'string', 'max:255'],
+ public function index(): View
+ {
+ return view('faq::admin.index', ['records' => Faq::query()->latest()->paginate(20)]);
+ }
+
+ public function create(): View
+ {
+ return view('faq::admin.form');
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['question' => ['required', 'string', 'max:255'],
'answer' => ['required', 'string'],
'category' => ['nullable', 'string'],
'sort_order' => ['nullable', 'integer', 'min:0'],
- 'enabled' => ['sometimes', 'boolean']]); $data['enabled'] = $request->boolean('enabled'); Faq::create($data); return redirect()->route('faq.index')->with('status', 'Faq created.'); }
- public function edit(Faq $faq): View { return view('faq::admin.form', ['record' => $faq]); }
- public function update(Request $request, Faq $faq): RedirectResponse { $data = $request->validate(['question' => ['required', 'string', 'max:255'],
+ 'enabled' => ['sometimes', 'boolean']]);
+ $data['enabled'] = $request->boolean('enabled');
+ Faq::create($data);
+
+ return redirect()->route('faq.index')->with('status', 'Faq created.');
+ }
+
+ public function edit(Faq $faq): View
+ {
+ return view('faq::admin.form', ['record' => $faq]);
+ }
+
+ public function update(Request $request, Faq $faq): RedirectResponse
+ {
+ $data = $request->validate(['question' => ['required', 'string', 'max:255'],
'answer' => ['required', 'string'],
'category' => ['nullable', 'string'],
'sort_order' => ['nullable', 'integer', 'min:0'],
- 'enabled' => ['sometimes', 'boolean']]); $data['enabled'] = $request->boolean('enabled'); $faq->update($data); return redirect()->route('faq.index')->with('status', 'Faq updated.'); }
- public function destroy(Faq $faq): RedirectResponse { $faq->delete(); return back()->with('status', 'Faq deleted.'); }
+ 'enabled' => ['sometimes', 'boolean']]);
+ $data['enabled'] = $request->boolean('enabled');
+ $faq->update($data);
+
+ return redirect()->route('faq.index')->with('status', 'Faq updated.');
+ }
+
+ public function destroy(Faq $faq): RedirectResponse
+ {
+ $faq->delete();
+
+ return back()->with('status', 'Faq deleted.');
+ }
}
diff --git a/Modules/Faq/Models/Faq.php b/Modules/Faq/Models/Faq.php
index b381686..b2011f3 100644
--- a/Modules/Faq/Models/Faq.php
+++ b/Modules/Faq/Models/Faq.php
@@ -9,12 +9,16 @@ use Illuminate\Database\Eloquent\SoftDeletes;
final class Faq extends Model
{
use HasFactory, SoftDeletes;
- protected $table = 'faqs';
- protected $fillable = ['question', 'answer', 'category', 'sort_order', 'enabled'];
-
- protected function casts(): array { return [
- 'sort_order' => 'integer',
- 'enabled' => 'boolean'
- ]; }
+ protected $table = 'faqs';
+
+ protected $fillable = ['question', 'answer', 'category', 'sort_order', 'enabled'];
+
+ protected function casts(): array
+ {
+ return [
+ 'sort_order' => 'integer',
+ 'enabled' => 'boolean',
+ ];
+ }
}
diff --git a/Modules/Faq/Support/ModuleInfo.php b/Modules/Faq/Support/ModuleInfo.php
index 0b812e8..34b932c 100644
--- a/Modules/Faq/Support/ModuleInfo.php
+++ b/Modules/Faq/Support/ModuleInfo.php
@@ -2,4 +2,15 @@
namespace Modules\Faq\Support;
-final class ModuleInfo { public static function name(): string { return 'Faq'; } public static function nameLower(): string { return 'faq'; } }
+final class ModuleInfo
+{
+ public static function name(): string
+ {
+ return 'Faq';
+ }
+
+ public static function nameLower(): string
+ {
+ return 'faq';
+ }
+}
diff --git a/Modules/Faq/Tests/Feature/ModuleBootTest.php b/Modules/Faq/Tests/Feature/ModuleBootTest.php
index 6de28c6..99b4bfd 100644
--- a/Modules/Faq/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Faq/Tests/Feature/ModuleBootTest.php
@@ -2,4 +2,6 @@
use Illuminate\Support\Facades\Route;
-it('registers the Faq administration route', function (): void { expect(Route::has('administration.faq.index'))->toBeTrue(); });
+it('registers the Faq administration route', function (): void {
+ expect(Route::has('administration.faq.index'))->toBeTrue();
+});
diff --git a/Modules/Feeds/Config/config.php b/Modules/Feeds/Config/config.php
index 0a91fe1..166f718 100644
--- a/Modules/Feeds/Config/config.php
+++ b/Modules/Feeds/Config/config.php
@@ -1,2 +1,3 @@
'Feeds'];
diff --git a/Modules/Feeds/Http/Controllers/FeedController.php b/Modules/Feeds/Http/Controllers/FeedController.php
index fae5be4..8f2fab8 100644
--- a/Modules/Feeds/Http/Controllers/FeedController.php
+++ b/Modules/Feeds/Http/Controllers/FeedController.php
@@ -1,3 +1,21 @@
view('feeds::rss',['title'=>config('app.name').' Blog','records'=>Blog::published()->latest('published_at')->limit(50)->get(),'routeName'=>'blog.public.show'])->header('Content-Type','application/rss+xml; charset=UTF-8'); } public function news(): Response { return response()->view('feeds::rss',['title'=>config('app.name').' News','records'=>News::published()->latest('published_at')->limit(50)->get(),'routeName'=>'news.public.show'])->header('Content-Type','application/rss+xml; charset=UTF-8'); } }
+
+namespace Modules\Feeds\Http\Controllers;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Response;
+use Modules\Blog\Models\Blog;
+use Modules\News\Models\News;
+
+final class FeedController extends Controller
+{
+ public function blog(): Response
+ {
+ return response()->view('feeds::rss', ['title' => config('app.name').' Blog', 'records' => Blog::published()->latest('published_at')->limit(50)->get(), 'routeName' => 'blog.public.show'])->header('Content-Type', 'application/rss+xml; charset=UTF-8');
+ }
+
+ public function news(): Response
+ {
+ return response()->view('feeds::rss', ['title' => config('app.name').' News', 'records' => News::published()->latest('published_at')->limit(50)->get(), 'routeName' => 'news.public.show'])->header('Content-Type', 'application/rss+xml; charset=UTF-8');
+ }
+}
diff --git a/Modules/Feeds/Providers/FeedsServiceProvider.php b/Modules/Feeds/Providers/FeedsServiceProvider.php
index 324230b..c1200c7 100644
--- a/Modules/Feeds/Providers/FeedsServiceProvider.php
+++ b/Modules/Feeds/Providers/FeedsServiceProvider.php
@@ -1,6 +1,9 @@
app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Feeds', 'Config/config.php'), 'feeds');
}
+
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Feeds', 'Database/Migrations'));
diff --git a/Modules/Feeds/Providers/RouteServiceProvider.php b/Modules/Feeds/Providers/RouteServiceProvider.php
index 443ab7f..89c193a 100644
--- a/Modules/Feeds/Providers/RouteServiceProvider.php
+++ b/Modules/Feeds/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
name('feeds.blog'); Route::get('/feeds/news.xml',[FeedController::class,'news'])->name('feeds.news');
+
+use Illuminate\Support\Facades\Route;
+use Modules\Feeds\Http\Controllers\FeedController;
+
+Route::get('/feeds/blog.xml', [FeedController::class, 'blog'])->name('feeds.blog');
+Route::get('/feeds/news.xml', [FeedController::class, 'news'])->name('feeds.news');
diff --git a/Modules/Feeds/Tests/Feature/ModuleBootTest.php b/Modules/Feeds/Tests/Feature/ModuleBootTest.php
index 8e40d43..7b90716 100644
--- a/Modules/Feeds/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Feeds/Tests/Feature/ModuleBootTest.php
@@ -1,6 +1,9 @@
id();
+return new class extends Migration
+{
+ public function up(): void
+ {
+ Schema::create('forms', function (Blueprint $table): void {
+ $table->id();
$table->string('name');
$table->string('slug');
$table->text('description')->nullable();
$table->json('schema')->nullable();
$table->boolean('enabled')->default(true);
- $table->timestamps(); $table->softDeletes(); }); }
- public function down(): void { Schema::dropIfExists('forms'); }
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('forms');
+ }
};
diff --git a/Modules/Forms/Http/Controllers/FormController.php b/Modules/Forms/Http/Controllers/FormController.php
index 2ba15ed..395ccf6 100644
--- a/Modules/Forms/Http/Controllers/FormController.php
+++ b/Modules/Forms/Http/Controllers/FormController.php
@@ -10,20 +10,57 @@ use Modules\Forms\Models\Form;
final class FormController extends Controller
{
- public function index(): View { return view('forms::admin.index', ['records' => Form::query()->latest()->paginate(20)]); }
- public function create(): View { return view('forms::admin.form'); }
- public function store(Request $request): RedirectResponse { $data = $request->validate(['name' => ['required', 'string', 'max:255'],
+ public function index(): View
+ {
+ return view('forms::admin.index', ['records' => Form::query()->latest()->paginate(20)]);
+ }
+
+ public function create(): View
+ {
+ return view('forms::admin.form');
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'schema' => ['nullable', 'array'],
- 'enabled' => ['sometimes', 'boolean']]); if ($request->filled('schema_json')) { $data['schema'] = json_decode($request->string('schema_json')->toString(), true, 512, JSON_THROW_ON_ERROR); }
- $data['enabled'] = $request->boolean('enabled'); Form::create($data); return redirect()->route('forms.index')->with('status', 'Form created.'); }
- public function edit(Form $form): View { return view('forms::admin.form', ['record' => $form]); }
- public function update(Request $request, Form $form): RedirectResponse { $data = $request->validate(['name' => ['required', 'string', 'max:255'],
+ 'enabled' => ['sometimes', 'boolean']]);
+ if ($request->filled('schema_json')) {
+ $data['schema'] = json_decode($request->string('schema_json')->toString(), true, 512, JSON_THROW_ON_ERROR);
+ }
+ $data['enabled'] = $request->boolean('enabled');
+ Form::create($data);
+
+ return redirect()->route('forms.index')->with('status', 'Form created.');
+ }
+
+ public function edit(Form $form): View
+ {
+ return view('forms::admin.form', ['record' => $form]);
+ }
+
+ public function update(Request $request, Form $form): RedirectResponse
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'schema' => ['nullable', 'array'],
- 'enabled' => ['sometimes', 'boolean']]); if ($request->filled('schema_json')) { $data['schema'] = json_decode($request->string('schema_json')->toString(), true, 512, JSON_THROW_ON_ERROR); }
- $data['enabled'] = $request->boolean('enabled'); $form->update($data); return redirect()->route('forms.index')->with('status', 'Form updated.'); }
- public function destroy(Form $form): RedirectResponse { $form->delete(); return back()->with('status', 'Form deleted.'); }
+ 'enabled' => ['sometimes', 'boolean']]);
+ if ($request->filled('schema_json')) {
+ $data['schema'] = json_decode($request->string('schema_json')->toString(), true, 512, JSON_THROW_ON_ERROR);
+ }
+ $data['enabled'] = $request->boolean('enabled');
+ $form->update($data);
+
+ return redirect()->route('forms.index')->with('status', 'Form updated.');
+ }
+
+ public function destroy(Form $form): RedirectResponse
+ {
+ $form->delete();
+
+ return back()->with('status', 'Form deleted.');
+ }
}
diff --git a/Modules/Forms/Models/Form.php b/Modules/Forms/Models/Form.php
index 2d8c1e4..d72aec6 100644
--- a/Modules/Forms/Models/Form.php
+++ b/Modules/Forms/Models/Form.php
@@ -9,12 +9,16 @@ use Illuminate\Database\Eloquent\SoftDeletes;
final class Form extends Model
{
use HasFactory, SoftDeletes;
- protected $table = 'forms';
- protected $fillable = ['name', 'slug', 'description', 'schema', 'enabled'];
-
- protected function casts(): array { return [
- 'schema' => 'array',
- 'enabled' => 'boolean'
- ]; }
+ protected $table = 'forms';
+
+ protected $fillable = ['name', 'slug', 'description', 'schema', 'enabled'];
+
+ protected function casts(): array
+ {
+ return [
+ 'schema' => 'array',
+ 'enabled' => 'boolean',
+ ];
+ }
}
diff --git a/Modules/Forms/Support/ModuleInfo.php b/Modules/Forms/Support/ModuleInfo.php
index a6e2507..916091c 100644
--- a/Modules/Forms/Support/ModuleInfo.php
+++ b/Modules/Forms/Support/ModuleInfo.php
@@ -2,4 +2,15 @@
namespace Modules\Forms\Support;
-final class ModuleInfo { public static function name(): string { return 'Forms'; } public static function nameLower(): string { return 'forms'; } }
+final class ModuleInfo
+{
+ public static function name(): string
+ {
+ return 'Forms';
+ }
+
+ public static function nameLower(): string
+ {
+ return 'forms';
+ }
+}
diff --git a/Modules/Forms/Tests/Feature/ModuleBootTest.php b/Modules/Forms/Tests/Feature/ModuleBootTest.php
index 7201fc0..fc85e0e 100644
--- a/Modules/Forms/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Forms/Tests/Feature/ModuleBootTest.php
@@ -2,4 +2,6 @@
use Illuminate\Support\Facades\Route;
-it('registers the Forms administration route', function (): void { expect(Route::has('administration.forms.index'))->toBeTrue(); });
+it('registers the Forms administration route', function (): void {
+ expect(Route::has('administration.forms.index'))->toBeTrue();
+});
diff --git a/Modules/Localization/Concerns/HasTranslations.php b/Modules/Localization/Concerns/HasTranslations.php
index 993dea5..4a09f34 100644
--- a/Modules/Localization/Concerns/HasTranslations.php
+++ b/Modules/Localization/Concerns/HasTranslations.php
@@ -1,24 +1,33 @@
morphMany(LocalizedContent::class, 'translatable');
}
+
public function translated(string $field, ?string $locale = null, mixed $fallback = null): mixed
{
$locale ??= app()->getLocale();
$translation = $this->translations->first(fn (LocalizedContent $item): bool => $item->field === $field && $item->locale?->code === $locale);
- if ($translation && filled($translation->value)) { return $translation->value; }
+ if ($translation && filled($translation->value)) {
+ return $translation->value;
+ }
+
return $fallback ?? $this->getAttribute($field);
}
+
public function setTranslation(string $field, string $localeCode, ?string $value, ?int $userId = null): LocalizedContent
{
$locale = Locale::query()->where('code', $localeCode)->firstOrFail();
+
return $this->translations()->updateOrCreate(
['locale_id' => $locale->id, 'field' => $field],
['value' => $value, 'updated_by' => $userId]
diff --git a/Modules/Localization/Config/config.php b/Modules/Localization/Config/config.php
index e5f5736..da2392d 100644
--- a/Modules/Localization/Config/config.php
+++ b/Modules/Localization/Config/config.php
@@ -1,11 +1,16 @@
env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'session_key' => 'platform_locale',
'supported_subjects' => [
- 'pages' => ['model' => Modules\Pages\Models\Page::class, 'label' => 'Pages', 'fields' => ['title', 'summary', 'content', 'meta_title', 'meta_description']],
- 'blog' => ['model' => Modules\Blog\Models\Blog::class, 'label' => 'Blog', 'fields' => ['title', 'excerpt', 'content', 'meta_title', 'meta_description']],
- 'news' => ['model' => Modules\News\Models\News::class, 'label' => 'News', 'fields' => ['title', 'excerpt', 'content', 'meta_title', 'meta_description']],
+ 'pages' => ['model' => Page::class, 'label' => 'Pages', 'fields' => ['title', 'summary', 'content', 'meta_title', 'meta_description']],
+ 'blog' => ['model' => Blog::class, 'label' => 'Blog', 'fields' => ['title', 'excerpt', 'content', 'meta_title', 'meta_description']],
+ 'news' => ['model' => News::class, 'label' => 'News', 'fields' => ['title', 'excerpt', 'content', 'meta_title', 'meta_description']],
],
];
diff --git a/Modules/Localization/Database/Migrations/2026_07_24_06000_create_localization_tables.php b/Modules/Localization/Database/Migrations/2026_07_24_06000_create_localization_tables.php
index 592570a..24f8aba 100644
--- a/Modules/Localization/Database/Migrations/2026_07_24_06000_create_localization_tables.php
+++ b/Modules/Localization/Database/Migrations/2026_07_24_06000_create_localization_tables.php
@@ -1,7 +1,9 @@
index(['locale_id', 'field']);
});
}
+
public function down(): void
{
Schema::dropIfExists('localized_contents');
diff --git a/Modules/Localization/Database/Seeders/LocaleSeeder.php b/Modules/Localization/Database/Seeders/LocaleSeeder.php
index 1570ff8..1bf793d 100644
--- a/Modules/Localization/Database/Seeders/LocaleSeeder.php
+++ b/Modules/Localization/Database/Seeders/LocaleSeeder.php
@@ -1,7 +1,10 @@
user()->can('localization.manage'), 403);
$subjects = collect(config('localization.supported_subjects'))->map(function (array $config): array {
$model = $config['model'];
+
return $config + ['records' => $model::query()->latest()->limit(8)->get()];
});
+
return view('localization::admin.index', ['locales' => Locale::query()->orderBy('position')->get(), 'subjects' => $subjects]);
}
+
public function switch(string $locale, LocaleManager $manager): RedirectResponse
{
$manager->activate($locale);
+
return back();
}
+
public function store(Request $request): RedirectResponse
{
abort_unless($request->user()->can('localization.manage'), 403);
$data = $request->validate($this->rules());
- if (($data['is_default'] ?? false) === true) { Locale::query()->update(['is_default' => false]); }
+ if (($data['is_default'] ?? false) === true) {
+ Locale::query()->update(['is_default' => false]);
+ }
Locale::query()->create($data);
+
return back()->with('success', 'Locale created.');
}
+
public function update(Request $request, Locale $locale): RedirectResponse
{
abort_unless($request->user()->can('localization.manage'), 403);
$data = $request->validate($this->rules($locale));
- if (($data['is_default'] ?? false) === true) { Locale::query()->whereKeyNot($locale->id)->update(['is_default' => false]); }
+ if (($data['is_default'] ?? false) === true) {
+ Locale::query()->whereKeyNot($locale->id)->update(['is_default' => false]);
+ }
$locale->update($data);
+
return back()->with('success', 'Locale updated.');
}
+
public function destroy(Request $request, Locale $locale): RedirectResponse
{
abort_unless($request->user()->can('localization.manage'), 403);
abort_if($locale->is_default, 422, 'The default locale cannot be deleted.');
$locale->delete();
+
return back()->with('success', 'Locale deleted.');
}
+
private function rules(?Locale $locale = null): array
{
return [
- 'code' => ['required','string','max:20','unique:locales,code,'.($locale?->id ?? 'NULL')],
- 'name' => ['required','string','max:100'], 'native_name' => ['required','string','max:100'],
- 'regional_code' => ['nullable','string','max:20'], 'direction' => ['required','in:ltr,rtl'],
- 'date_format' => ['required','string','max:40'], 'time_format' => ['required','string','max:40'],
- 'timezone' => ['required','timezone'], 'currency_code' => ['nullable','string','size:3'],
- 'currency_symbol' => ['nullable','string','max:10'], 'position' => ['nullable','integer','min:0'],
- 'is_default' => ['sometimes','boolean'], 'is_active' => ['sometimes','boolean'],
+ 'code' => ['required', 'string', 'max:20', 'unique:locales,code,'.($locale?->id ?? 'NULL')],
+ 'name' => ['required', 'string', 'max:100'], 'native_name' => ['required', 'string', 'max:100'],
+ 'regional_code' => ['nullable', 'string', 'max:20'], 'direction' => ['required', 'in:ltr,rtl'],
+ 'date_format' => ['required', 'string', 'max:40'], 'time_format' => ['required', 'string', 'max:40'],
+ 'timezone' => ['required', 'timezone'], 'currency_code' => ['nullable', 'string', 'size:3'],
+ 'currency_symbol' => ['nullable', 'string', 'max:10'], 'position' => ['nullable', 'integer', 'min:0'],
+ 'is_default' => ['sometimes', 'boolean'], 'is_active' => ['sometimes', 'boolean'],
];
}
}
diff --git a/Modules/Localization/Http/Controllers/TranslationController.php b/Modules/Localization/Http/Controllers/TranslationController.php
index 1c27df3..69fd8b5 100644
--- a/Modules/Localization/Http/Controllers/TranslationController.php
+++ b/Modules/Localization/Http/Controllers/TranslationController.php
@@ -1,10 +1,13 @@
user()->can('localization.translate'), 403);
[$record, $config] = $this->resolve($subject, $id);
$record->load('translations.locale');
+
return view('localization::admin.translate', ['record' => $record, 'subject' => $subject, 'config' => $config, 'locales' => Locale::query()->active()->get()]);
}
+
public function update(Request $request, string $subject, int $id): RedirectResponse
{
abort_unless($request->user()->can('localization.translate'), 403);
@@ -24,13 +29,16 @@ final class TranslationController extends Controller
$record->setTranslation($field, $localeCode, $fields[$field] ?? null, $request->user()->id);
}
}
+
return back()->with('success', 'Translations saved.');
}
+
private function resolve(string $subject, int $id): array
{
$config = config("localization.supported_subjects.$subject");
abort_unless(is_array($config), 404);
$record = $config['model']::query()->findOrFail($id);
+
return [$record, $config];
}
}
diff --git a/Modules/Localization/Models/Locale.php b/Modules/Localization/Models/Locale.php
index f204c9d..97fa973 100644
--- a/Modules/Localization/Models/Locale.php
+++ b/Modules/Localization/Models/Locale.php
@@ -1,12 +1,27 @@
'boolean', 'is_active' => 'boolean']; }
- public function translations(): HasMany { return $this->hasMany(LocalizedContent::class); }
- public function scopeActive(Builder $query): Builder { return $query->where('is_active', true)->orderBy('position')->orderBy('name'); }
+ protected $fillable = ['code', 'name', 'native_name', 'regional_code', 'direction', 'date_format', 'time_format', 'timezone', 'currency_code', 'currency_symbol', 'position', 'is_default', 'is_active'];
+
+ protected function casts(): array
+ {
+ return ['is_default' => 'boolean', 'is_active' => 'boolean'];
+ }
+
+ public function translations(): HasMany
+ {
+ return $this->hasMany(LocalizedContent::class);
+ }
+
+ public function scopeActive(Builder $query): Builder
+ {
+ return $query->where('is_active', true)->orderBy('position')->orderBy('name');
+ }
}
diff --git a/Modules/Localization/Models/LocalizedContent.php b/Modules/Localization/Models/LocalizedContent.php
index 4c8a489..2a10675 100644
--- a/Modules/Localization/Models/LocalizedContent.php
+++ b/Modules/Localization/Models/LocalizedContent.php
@@ -1,13 +1,28 @@
morphTo(); }
- public function locale(): BelongsTo { return $this->belongsTo(Locale::class); }
- public function editor(): BelongsTo { return $this->belongsTo(User::class, 'updated_by'); }
+ protected $fillable = ['locale_id', 'field', 'value', 'updated_by'];
+
+ public function translatable(): MorphTo
+ {
+ return $this->morphTo();
+ }
+
+ public function locale(): BelongsTo
+ {
+ return $this->belongsTo(Locale::class);
+ }
+
+ public function editor(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'updated_by');
+ }
}
diff --git a/Modules/Localization/Providers/LocalizationServiceProvider.php b/Modules/Localization/Providers/LocalizationServiceProvider.php
index 8e2e6b5..6383fa8 100644
--- a/Modules/Localization/Providers/LocalizationServiceProvider.php
+++ b/Modules/Localization/Providers/LocalizationServiceProvider.php
@@ -1,7 +1,10 @@
app->singleton(LocaleManager::class);
$this->app->register(RouteServiceProvider::class);
}
+
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Localization', 'Database/Migrations'));
diff --git a/Modules/Localization/Providers/RouteServiceProvider.php b/Modules/Localization/Providers/RouteServiceProvider.php
index 4856455..39dae19 100644
--- a/Modules/Localization/Providers/RouteServiceProvider.php
+++ b/Modules/Localization/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
name('localization.switch');
-Route::middleware(['auth','verified'])->prefix('administration/localization')->name('administration.localization.')->group(function (): void {
+Route::middleware(['auth', 'verified'])->prefix('administration/localization')->name('administration.localization.')->group(function (): void {
Route::get('/', [LocaleController::class, 'index'])->name('index');
Route::post('/locales', [LocaleController::class, 'store'])->name('locales.store');
Route::put('/locales/{locale}', [LocaleController::class, 'update'])->name('locales.update');
diff --git a/Modules/Localization/Services/LocaleManager.php b/Modules/Localization/Services/LocaleManager.php
index ffde98b..0454a58 100644
--- a/Modules/Localization/Services/LocaleManager.php
+++ b/Modules/Localization/Services/LocaleManager.php
@@ -1,16 +1,28 @@
active()->get(); }
- public function default(): ?Locale { return Locale::query()->where('is_default', true)->first() ?? Locale::query()->active()->first(); }
+ public function active(): Collection
+ {
+ return Locale::query()->active()->get();
+ }
+
+ public function default(): ?Locale
+ {
+ return Locale::query()->where('is_default', true)->first() ?? Locale::query()->active()->first();
+ }
+
public function activate(string $code): Locale
{
$locale = Locale::query()->active()->where('code', $code)->firstOrFail();
app()->setLocale($locale->code);
session()->put(config('localization.session_key'), $locale->code);
+
return $locale;
}
}
diff --git a/Modules/Localization/Tests/Feature/ModuleBootTest.php b/Modules/Localization/Tests/Feature/ModuleBootTest.php
index a1e77c9..c4aea49 100644
--- a/Modules/Localization/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Localization/Tests/Feature/ModuleBootTest.php
@@ -1,10 +1,14 @@
assertTrue(app()->providerIsLoaded(\Modules\Localization\Providers\LocalizationServiceProvider::class));
+ $this->assertTrue(app()->providerIsLoaded(LocalizationServiceProvider::class));
}
}
diff --git a/Modules/Locations/Config/config.php b/Modules/Locations/Config/config.php
index 6269238..ba035af 100644
--- a/Modules/Locations/Config/config.php
+++ b/Modules/Locations/Config/config.php
@@ -1,2 +1,3 @@
'Locations'];
diff --git a/Modules/Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php b/Modules/Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php
index 13f2c46..8ef54fb 100644
--- a/Modules/Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php
+++ b/Modules/Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php
@@ -1,3 +1,34 @@
id();$t->string('name');$t->string('slug')->unique();$t->string('type')->default('office');$t->text('address')->nullable();$t->string('postcode')->nullable();$t->string('country')->nullable();$t->string('phone')->nullable();$t->string('email')->nullable();$t->decimal('latitude',10,7)->nullable();$t->decimal('longitude',10,7)->nullable();$t->json('opening_hours')->nullable();$t->boolean('is_active')->default(true);$t->unsignedInteger('sort_order')->default(0);$t->timestamps();}); } public function down(): void { Schema::dropIfExists('locations'); } };
+
+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('locations', function (Blueprint $t): void {
+ $t->id();
+ $t->string('name');
+ $t->string('slug')->unique();
+ $t->string('type')->default('office');
+ $t->text('address')->nullable();
+ $t->string('postcode')->nullable();
+ $t->string('country')->nullable();
+ $t->string('phone')->nullable();
+ $t->string('email')->nullable();
+ $t->decimal('latitude', 10, 7)->nullable();
+ $t->decimal('longitude', 10, 7)->nullable();
+ $t->json('opening_hours')->nullable();
+ $t->boolean('is_active')->default(true);
+ $t->unsignedInteger('sort_order')->default(0);
+ $t->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('locations');
+ }
+};
diff --git a/Modules/Locations/Http/Controllers/LocationController.php b/Modules/Locations/Http/Controllers/LocationController.php
index cb6dce7..a1b2606 100644
--- a/Modules/Locations/Http/Controllers/LocationController.php
+++ b/Modules/Locations/Http/Controllers/LocationController.php
@@ -1,3 +1,31 @@
Location::orderBy('sort_order')->orderBy('name')->paginate(25)]); } public function store(Request $r): RedirectResponse { Location::create($r->validate(['name'=>['required','string','max:255'],'slug'=>['required','string','max:255','unique:locations,slug'],'type'=>['required','in:office,store,warehouse,venue,service'],'address'=>['nullable','string'],'postcode'=>['nullable','string','max:30'],'country'=>['nullable','string','max:120'],'phone'=>['nullable','string','max:80'],'email'=>['nullable','email','max:255'],'latitude'=>['nullable','numeric','between:-90,90'],'longitude'=>['nullable','numeric','between:-180,180'],'sort_order'=>['nullable','integer','min:0']])+['is_active'=>$r->boolean('is_active',true)]); return back()->with('status','Location created.'); } public function destroy(Location $location): RedirectResponse { $location->delete(); return back()->with('status','Location deleted.'); } }
+
+namespace Modules\Locations\Http\Controllers;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\View\View;
+use Modules\Locations\Models\Location;
+
+final class LocationController extends Controller
+{
+ public function index(): View
+ {
+ return view('locations::admin.index', ['locations' => Location::orderBy('sort_order')->orderBy('name')->paginate(25)]);
+ }
+
+ public function store(Request $r): RedirectResponse
+ {
+ Location::create($r->validate(['name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255', 'unique:locations,slug'], 'type' => ['required', 'in:office,store,warehouse,venue,service'], 'address' => ['nullable', 'string'], 'postcode' => ['nullable', 'string', 'max:30'], 'country' => ['nullable', 'string', 'max:120'], 'phone' => ['nullable', 'string', 'max:80'], 'email' => ['nullable', 'email', 'max:255'], 'latitude' => ['nullable', 'numeric', 'between:-90,90'], 'longitude' => ['nullable', 'numeric', 'between:-180,180'], 'sort_order' => ['nullable', 'integer', 'min:0']]) + ['is_active' => $r->boolean('is_active', true)]);
+
+ return back()->with('status', 'Location created.');
+ }
+
+ public function destroy(Location $location): RedirectResponse
+ {
+ $location->delete();
+
+ return back()->with('status', 'Location deleted.');
+ }
+}
diff --git a/Modules/Locations/Models/Location.php b/Modules/Locations/Models/Location.php
index 8ac3d1d..913fd40 100644
--- a/Modules/Locations/Models/Location.php
+++ b/Modules/Locations/Models/Location.php
@@ -1,3 +1,15 @@
'decimal:7','longitude'=>'decimal:7','opening_hours'=>'array','is_active'=>'boolean']; } }
+
+namespace Modules\Locations\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+final class Location extends Model
+{
+ protected $fillable = ['name', 'slug', 'type', 'address', 'postcode', 'country', 'phone', 'email', 'latitude', 'longitude', 'opening_hours', 'is_active', 'sort_order'];
+
+ protected function casts(): array
+ {
+ return ['latitude' => 'decimal:7', 'longitude' => 'decimal:7', 'opening_hours' => 'array', 'is_active' => 'boolean'];
+ }
+}
diff --git a/Modules/Locations/Providers/LocationsServiceProvider.php b/Modules/Locations/Providers/LocationsServiceProvider.php
index a13736e..de2419b 100644
--- a/Modules/Locations/Providers/LocationsServiceProvider.php
+++ b/Modules/Locations/Providers/LocationsServiceProvider.php
@@ -1,6 +1,9 @@
app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Locations', 'Config/config.php'), 'locations');
}
+
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Locations', 'Database/Migrations'));
diff --git a/Modules/Locations/Providers/RouteServiceProvider.php b/Modules/Locations/Providers/RouteServiceProvider.php
index ceb62de..cb2ee3e 100644
--- a/Modules/Locations/Providers/RouteServiceProvider.php
+++ b/Modules/Locations/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
prefix('administration/locations')->name('administration.locations.')->group(function(): void { Route::get('/',[LocationController::class,'index'])->name('index'); Route::post('/',[LocationController::class,'store'])->name('store'); Route::delete('/{location}',[LocationController::class,'destroy'])->name('destroy'); });
+
+use Illuminate\Support\Facades\Route;
+use Modules\Locations\Http\Controllers\LocationController;
+
+Route::middleware(['auth', 'verified'])->prefix('administration/locations')->name('administration.locations.')->group(function (): void {
+ Route::get('/', [LocationController::class, 'index'])->name('index');
+ Route::post('/', [LocationController::class, 'store'])->name('store');
+ Route::delete('/{location}', [LocationController::class, 'destroy'])->name('destroy');
+});
diff --git a/Modules/Locations/Tests/Feature/ModuleBootTest.php b/Modules/Locations/Tests/Feature/ModuleBootTest.php
index 1ae9804..c8dec12 100644
--- a/Modules/Locations/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Locations/Tests/Feature/ModuleBootTest.php
@@ -1,6 +1,9 @@
(bool) env('MARKETPLACE_ENABLED', true),
'catalogue_url' => env('MARKETPLACE_CATALOGUE_URL'),
diff --git a/Modules/Marketplace/Console/Commands/MarketplaceAuditCommand.php b/Modules/Marketplace/Console/Commands/MarketplaceAuditCommand.php
index f8d4a4f..b8a36b0 100644
--- a/Modules/Marketplace/Console/Commands/MarketplaceAuditCommand.php
+++ b/Modules/Marketplace/Console/Commands/MarketplaceAuditCommand.php
@@ -1,14 +1,24 @@
with('package')->get();
- foreach ($packages as $package) $this->line(sprintf('%s %s [%s]%s',$package->package_name,$package->version,$package->status,$package->hasUpdate()?' update available':''));
- $this->info($packages->count().' installed package(s) audited.'); return self::SUCCESS;
+ $packages = InstalledPackage::query()->with('package')->get();
+ foreach ($packages as $package) {
+ $this->line(sprintf('%s %s [%s]%s', $package->package_name, $package->version, $package->status, $package->hasUpdate() ? ' update available' : ''));
+ }
+ $this->info($packages->count().' installed package(s) audited.');
+
+ return self::SUCCESS;
}
}
diff --git a/Modules/Marketplace/Console/Commands/MarketplaceSyncCommand.php b/Modules/Marketplace/Console/Commands/MarketplaceSyncCommand.php
index 9625253..060aa58 100644
--- a/Modules/Marketplace/Console/Commands/MarketplaceSyncCommand.php
+++ b/Modules/Marketplace/Console/Commands/MarketplaceSyncCommand.php
@@ -1,12 +1,24 @@
warn('MARKETPLACE_CATALOGUE_URL is not configured.'); return self::SUCCESS; }
- $this->info('Catalogue synchronisation is configured. Remote installs remain disabled unless explicitly enabled.'); return self::SUCCESS;
+ if (blank(config('marketplace.catalogue_url'))) {
+ $this->warn('MARKETPLACE_CATALOGUE_URL is not configured.');
+
+ return self::SUCCESS;
+ }
+ $this->info('Catalogue synchronisation is configured. Remote installs remain disabled unless explicitly enabled.');
+
+ return self::SUCCESS;
}
}
diff --git a/Modules/Marketplace/Database/Migrations/2026_07_24_170000_create_marketplace_tables.php b/Modules/Marketplace/Database/Migrations/2026_07_24_170000_create_marketplace_tables.php
index 622087d..eb2a3b9 100644
--- a/Modules/Marketplace/Database/Migrations/2026_07_24_170000_create_marketplace_tables.php
+++ b/Modules/Marketplace/Database/Migrations/2026_07_24_170000_create_marketplace_tables.php
@@ -1,48 +1,96 @@
id(); $table->string('name'); $table->string('slug')->unique(); $table->string('email')->nullable();
- $table->string('website')->nullable(); $table->text('public_key')->nullable(); $table->boolean('is_verified')->default(false)->index();
- $table->json('metadata')->nullable(); $table->timestamps();
+ $table->id();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->string('email')->nullable();
+ $table->string('website')->nullable();
+ $table->text('public_key')->nullable();
+ $table->boolean('is_verified')->default(false)->index();
+ $table->json('metadata')->nullable();
+ $table->timestamps();
});
Schema::create('marketplace_packages', function (Blueprint $table) {
- $table->id(); $table->foreignId('publisher_id')->nullable()->constrained('marketplace_publishers')->nullOnDelete();
- $table->string('type',30)->index(); $table->string('name'); $table->string('slug')->unique(); $table->string('package_name')->unique();
- $table->text('description')->nullable(); $table->string('latest_version')->nullable(); $table->string('homepage')->nullable();
- $table->string('repository_url')->nullable(); $table->string('download_url')->nullable(); $table->string('checksum',128)->nullable();
- $table->text('signature')->nullable(); $table->json('manifest')->nullable(); $table->boolean('is_featured')->default(false)->index();
- $table->boolean('is_active')->default(true)->index(); $table->timestamp('catalogue_updated_at')->nullable(); $table->timestamps();
+ $table->id();
+ $table->foreignId('publisher_id')->nullable()->constrained('marketplace_publishers')->nullOnDelete();
+ $table->string('type', 30)->index();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->string('package_name')->unique();
+ $table->text('description')->nullable();
+ $table->string('latest_version')->nullable();
+ $table->string('homepage')->nullable();
+ $table->string('repository_url')->nullable();
+ $table->string('download_url')->nullable();
+ $table->string('checksum', 128)->nullable();
+ $table->text('signature')->nullable();
+ $table->json('manifest')->nullable();
+ $table->boolean('is_featured')->default(false)->index();
+ $table->boolean('is_active')->default(true)->index();
+ $table->timestamp('catalogue_updated_at')->nullable();
+ $table->timestamps();
});
Schema::create('marketplace_installed_packages', function (Blueprint $table) {
- $table->id(); $table->foreignId('marketplace_package_id')->nullable()->constrained('marketplace_packages')->nullOnDelete();
- $table->string('package_name')->unique(); $table->string('type',30)->index(); $table->string('version');
- $table->string('status',30)->default('installed')->index(); $table->json('manifest')->nullable(); $table->string('checksum',128)->nullable();
- $table->string('install_path')->nullable(); $table->boolean('is_enabled')->default(true)->index();
- $table->foreignId('installed_by')->nullable()->constrained('users')->nullOnDelete(); $table->timestamp('installed_at')->nullable();
- $table->timestamp('updated_at_marketplace')->nullable(); $table->timestamps();
+ $table->id();
+ $table->foreignId('marketplace_package_id')->nullable()->constrained('marketplace_packages')->nullOnDelete();
+ $table->string('package_name')->unique();
+ $table->string('type', 30)->index();
+ $table->string('version');
+ $table->string('status', 30)->default('installed')->index();
+ $table->json('manifest')->nullable();
+ $table->string('checksum', 128)->nullable();
+ $table->string('install_path')->nullable();
+ $table->boolean('is_enabled')->default(true)->index();
+ $table->foreignId('installed_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamp('installed_at')->nullable();
+ $table->timestamp('updated_at_marketplace')->nullable();
+ $table->timestamps();
});
Schema::create('marketplace_installations', function (Blueprint $table) {
- $table->id(); $table->foreignId('installed_package_id')->nullable()->constrained('marketplace_installed_packages')->nullOnDelete();
- $table->string('package_name')->index(); $table->string('operation',30)->index(); $table->string('from_version')->nullable();
- $table->string('to_version')->nullable(); $table->string('status',30)->default('pending')->index(); $table->string('snapshot_path')->nullable();
- $table->text('message')->nullable(); $table->json('context')->nullable(); $table->foreignId('performed_by')->nullable()->constrained('users')->nullOnDelete();
- $table->timestamp('started_at')->nullable(); $table->timestamp('completed_at')->nullable(); $table->timestamps();
+ $table->id();
+ $table->foreignId('installed_package_id')->nullable()->constrained('marketplace_installed_packages')->nullOnDelete();
+ $table->string('package_name')->index();
+ $table->string('operation', 30)->index();
+ $table->string('from_version')->nullable();
+ $table->string('to_version')->nullable();
+ $table->string('status', 30)->default('pending')->index();
+ $table->string('snapshot_path')->nullable();
+ $table->text('message')->nullable();
+ $table->json('context')->nullable();
+ $table->foreignId('performed_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamp('started_at')->nullable();
+ $table->timestamp('completed_at')->nullable();
+ $table->timestamps();
});
Schema::create('marketplace_licences', function (Blueprint $table) {
- $table->id(); $table->foreignId('marketplace_package_id')->nullable()->constrained('marketplace_packages')->nullOnDelete();
- $table->string('licence_key')->nullable(); $table->string('status',30)->default('inactive')->index(); $table->string('plan')->nullable();
- $table->string('licensed_to')->nullable(); $table->timestamp('activated_at')->nullable(); $table->timestamp('expires_at')->nullable();
- $table->json('metadata')->nullable(); $table->timestamps();
+ $table->id();
+ $table->foreignId('marketplace_package_id')->nullable()->constrained('marketplace_packages')->nullOnDelete();
+ $table->string('licence_key')->nullable();
+ $table->string('status', 30)->default('inactive')->index();
+ $table->string('plan')->nullable();
+ $table->string('licensed_to')->nullable();
+ $table->timestamp('activated_at')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->json('metadata')->nullable();
+ $table->timestamps();
});
}
+
public function down(): void
{
- Schema::dropIfExists('marketplace_licences'); Schema::dropIfExists('marketplace_installations');
- Schema::dropIfExists('marketplace_installed_packages'); Schema::dropIfExists('marketplace_packages'); Schema::dropIfExists('marketplace_publishers');
+ Schema::dropIfExists('marketplace_licences');
+ Schema::dropIfExists('marketplace_installations');
+ Schema::dropIfExists('marketplace_installed_packages');
+ Schema::dropIfExists('marketplace_packages');
+ Schema::dropIfExists('marketplace_publishers');
}
};
diff --git a/Modules/Marketplace/Http/Controllers/MarketplaceApiController.php b/Modules/Marketplace/Http/Controllers/MarketplaceApiController.php
index b4d2499..958d8f3 100644
--- a/Modules/Marketplace/Http/Controllers/MarketplaceApiController.php
+++ b/Modules/Marketplace/Http/Controllers/MarketplaceApiController.php
@@ -1,16 +1,40 @@
json(MarketplacePackage::query()->with('publisher')->where('is_active',true)->when($request->filled('type'),fn($q)=>$q->where('type',$request->string('type')))->paginate(min(100,max(1,$request->integer('per_page',25))))); }
- public function installed(): JsonResponse { return response()->json(InstalledPackage::query()->with('package')->paginate(50)); }
- public function install(MarketplacePackage $package,MarketplaceManager $manager,Request $request): JsonResponse { return response()->json($manager->install($package,$request->user()?->id)->load('package'),201); }
- public function toggle(InstalledPackage $package,MarketplaceManager $manager,Request $request): JsonResponse { return response()->json($manager->setEnabled($package,!$package->is_enabled,$request->user()?->id)); }
- public function remove(InstalledPackage $package,MarketplaceManager $manager,Request $request): JsonResponse { $manager->remove($package,$request->user()?->id); return response()->json(['status'=>'removed']); }
+ public function packages(Request $request): JsonResponse
+ {
+ return response()->json(MarketplacePackage::query()->with('publisher')->where('is_active', true)->when($request->filled('type'), fn ($q) => $q->where('type', $request->string('type')))->paginate(min(100, max(1, $request->integer('per_page', 25)))));
+ }
+
+ public function installed(): JsonResponse
+ {
+ return response()->json(InstalledPackage::query()->with('package')->paginate(50));
+ }
+
+ public function install(MarketplacePackage $package, MarketplaceManager $manager, Request $request): JsonResponse
+ {
+ return response()->json($manager->install($package, $request->user()?->id)->load('package'), 201);
+ }
+
+ public function toggle(InstalledPackage $package, MarketplaceManager $manager, Request $request): JsonResponse
+ {
+ return response()->json($manager->setEnabled($package, ! $package->is_enabled, $request->user()?->id));
+ }
+
+ public function remove(InstalledPackage $package, MarketplaceManager $manager, Request $request): JsonResponse
+ {
+ $manager->remove($package, $request->user()?->id);
+
+ return response()->json(['status' => 'removed']);
+ }
}
diff --git a/Modules/Marketplace/Http/Controllers/MarketplaceController.php b/Modules/Marketplace/Http/Controllers/MarketplaceController.php
index 7eca206..45744a0 100644
--- a/Modules/Marketplace/Http/Controllers/MarketplaceController.php
+++ b/Modules/Marketplace/Http/Controllers/MarketplaceController.php
@@ -1,5 +1,7 @@
with('publisher')->where('is_active',true)->when($request->filled('type'),fn($q)=>$q->where('type',$request->string('type')))->when($request->filled('search'),fn($q)=>$q->where(fn($q)=>$q->where('name','like','%'.$request->string('search').'%')->orWhere('description','like','%'.$request->string('search').'%')))->latest('is_featured')->paginate(20)->withQueryString();
- return view('marketplace::index',compact('packages'));
+ $packages = MarketplacePackage::query()->with('publisher')->where('is_active', true)->when($request->filled('type'), fn ($q) => $q->where('type', $request->string('type')))->when($request->filled('search'), fn ($q) => $q->where(fn ($q) => $q->where('name', 'like', '%'.$request->string('search').'%')->orWhere('description', 'like', '%'.$request->string('search').'%')))->latest('is_featured')->paginate(20)->withQueryString();
+
+ return view('marketplace::index', compact('packages'));
+ }
+
+ public function installed(): View
+ {
+ $packages = InstalledPackage::query()->with('package')->latest()->paginate(25);
+
+ return view('marketplace::installed', compact('packages'));
+ }
+
+ public function install(MarketplacePackage $package, MarketplaceManager $manager, Request $request): RedirectResponse
+ {
+ $manager->install($package, $request->user()?->id);
+
+ return back()->with('status', 'Package installed successfully.');
+ }
+
+ public function update(MarketplacePackage $package, MarketplaceManager $manager, Request $request): RedirectResponse
+ {
+ $manager->install($package, $request->user()?->id);
+
+ return back()->with('status', 'Package updated successfully.');
+ }
+
+ public function toggle(InstalledPackage $package, MarketplaceManager $manager, Request $request): RedirectResponse
+ {
+ $manager->setEnabled($package, ! $package->is_enabled, $request->user()?->id);
+
+ return back()->with('status', 'Package state updated.');
+ }
+
+ public function remove(InstalledPackage $package, MarketplaceManager $manager, Request $request): RedirectResponse
+ {
+ $manager->remove($package, $request->user()?->id);
+
+ return back()->with('status', 'Package removed.');
+ }
+
+ public function rollback(InstalledPackage $package, Installation $installation, MarketplaceManager $manager, Request $request): RedirectResponse
+ {
+ $manager->rollback($package, $installation, $request->user()?->id);
+
+ return back()->with('status','Package rolled back.');
}
- public function installed(): View { $packages=InstalledPackage::query()->with('package')->latest()->paginate(25); return view('marketplace::installed',compact('packages')); }
- public function install(MarketplacePackage $package,MarketplaceManager $manager,Request $request): RedirectResponse { $manager->install($package,$request->user()?->id); return back()->with('status','Package installed successfully.'); }
- public function update(MarketplacePackage $package,MarketplaceManager $manager,Request $request): RedirectResponse { $manager->install($package,$request->user()?->id); return back()->with('status','Package updated successfully.'); }
- public function toggle(InstalledPackage $package,MarketplaceManager $manager,Request $request): RedirectResponse { $manager->setEnabled($package,!$package->is_enabled,$request->user()?->id); return back()->with('status','Package state updated.'); }
- public function remove(InstalledPackage $package,MarketplaceManager $manager,Request $request): RedirectResponse { $manager->remove($package,$request->user()?->id); return back()->with('status','Package removed.'); }
- public function rollback(InstalledPackage $package,Installation $installation,MarketplaceManager $manager,Request $request): RedirectResponse { $manager->rollback($package,$installation,$request->user()?->id); return back()->with('status','Package rolled back.'); }
}
diff --git a/Modules/Marketplace/Models/Installation.php b/Modules/Marketplace/Models/Installation.php
index 6573279..c152b8c 100644
--- a/Modules/Marketplace/Models/Installation.php
+++ b/Modules/Marketplace/Models/Installation.php
@@ -1,9 +1,14 @@
'array','started_at'=>'datetime','completed_at'=>'datetime'];
+ protected $table = 'marketplace_installations';
+
+ protected $fillable = ['installed_package_id', 'package_name', 'operation', 'from_version', 'to_version', 'status', 'snapshot_path', 'message', 'context', 'performed_by', 'started_at', 'completed_at'];
+
+ protected $casts = ['context' => 'array', 'started_at' => 'datetime', 'completed_at' => 'datetime'];
}
diff --git a/Modules/Marketplace/Models/InstalledPackage.php b/Modules/Marketplace/Models/InstalledPackage.php
index c2dace8..bb7eda5 100644
--- a/Modules/Marketplace/Models/InstalledPackage.php
+++ b/Modules/Marketplace/Models/InstalledPackage.php
@@ -1,14 +1,31 @@
'array','is_enabled'=>'boolean','installed_at'=>'datetime','updated_at_marketplace'=>'datetime'];
- public function package(): BelongsTo { return $this->belongsTo(MarketplacePackage::class,'marketplace_package_id'); }
- public function installations(): HasMany { return $this->hasMany(Installation::class,'installed_package_id'); }
- public function hasUpdate(): bool { return $this->package?->latest_version && version_compare($this->package->latest_version,$this->version,'>'); }
+ protected $table = 'marketplace_installed_packages';
+
+ protected $fillable = ['marketplace_package_id', 'package_name', 'type', 'version', 'status', 'manifest', 'checksum', 'install_path', 'is_enabled', 'installed_by', 'installed_at', 'updated_at_marketplace'];
+
+ protected $casts = ['manifest' => 'array', 'is_enabled' => 'boolean', 'installed_at' => 'datetime', 'updated_at_marketplace' => 'datetime'];
+
+ public function package(): BelongsTo
+ {
+ return $this->belongsTo(MarketplacePackage::class, 'marketplace_package_id');
+ }
+
+ public function installations(): HasMany
+ {
+ return $this->hasMany(Installation::class, 'installed_package_id');
+ }
+
+ public function hasUpdate(): bool
+ {
+ return $this->package?->latest_version && version_compare($this->package->latest_version, $this->version, '>');
+ }
}
diff --git a/Modules/Marketplace/Models/MarketplaceLicence.php b/Modules/Marketplace/Models/MarketplaceLicence.php
index 48ef02a..22038e8 100644
--- a/Modules/Marketplace/Models/MarketplaceLicence.php
+++ b/Modules/Marketplace/Models/MarketplaceLicence.php
@@ -1,10 +1,16 @@
'datetime','expires_at'=>'datetime','metadata'=>'array'];
+ protected $table = 'marketplace_licences';
+
+ protected $fillable = ['marketplace_package_id', 'licence_key', 'status', 'plan', 'licensed_to', 'activated_at', 'expires_at', 'metadata'];
+
+ protected $hidden = ['licence_key'];
+
+ protected $casts = ['activated_at' => 'datetime', 'expires_at' => 'datetime', 'metadata' => 'array'];
}
diff --git a/Modules/Marketplace/Models/MarketplacePackage.php b/Modules/Marketplace/Models/MarketplacePackage.php
index 7134aa6..ad4a0c2 100644
--- a/Modules/Marketplace/Models/MarketplacePackage.php
+++ b/Modules/Marketplace/Models/MarketplacePackage.php
@@ -1,13 +1,26 @@
'array','is_featured'=>'boolean','is_active'=>'boolean','catalogue_updated_at'=>'datetime'];
- public function publisher(): BelongsTo { return $this->belongsTo(Publisher::class); }
- public function licences(): HasMany { return $this->hasMany(MarketplaceLicence::class,'marketplace_package_id'); }
+ protected $table = 'marketplace_packages';
+
+ protected $fillable = ['publisher_id', 'type', 'name', 'slug', 'package_name', 'description', 'latest_version', 'homepage', 'repository_url', 'download_url', 'checksum', 'signature', 'manifest', 'is_featured', 'is_active', 'catalogue_updated_at'];
+
+ protected $casts = ['manifest' => 'array', 'is_featured' => 'boolean', 'is_active' => 'boolean', 'catalogue_updated_at' => 'datetime'];
+
+ public function publisher(): BelongsTo
+ {
+ return $this->belongsTo(Publisher::class);
+ }
+
+ public function licences(): HasMany
+ {
+ return $this->hasMany(MarketplaceLicence::class, 'marketplace_package_id');
+ }
}
diff --git a/Modules/Marketplace/Models/Publisher.php b/Modules/Marketplace/Models/Publisher.php
index b74acba..16558ef 100644
--- a/Modules/Marketplace/Models/Publisher.php
+++ b/Modules/Marketplace/Models/Publisher.php
@@ -1,11 +1,20 @@
'boolean','metadata'=>'array'];
- public function packages(): HasMany { return $this->hasMany(MarketplacePackage::class,'publisher_id'); }
+ protected $table = 'marketplace_publishers';
+
+ protected $fillable = ['name', 'slug', 'email', 'website', 'public_key', 'is_verified', 'metadata'];
+
+ protected $casts = ['is_verified' => 'boolean', 'metadata' => 'array'];
+
+ public function packages(): HasMany
+ {
+ return $this->hasMany(MarketplacePackage::class, 'publisher_id');
+ }
}
diff --git a/Modules/Marketplace/Providers/MarketplaceServiceProvider.php b/Modules/Marketplace/Providers/MarketplaceServiceProvider.php
index 9902c22..0edd17f 100644
--- a/Modules/Marketplace/Providers/MarketplaceServiceProvider.php
+++ b/Modules/Marketplace/Providers/MarketplaceServiceProvider.php
@@ -1,11 +1,16 @@
app->singleton(MarketplaceManager::class);
$this->app->register(RouteServiceProvider::class);
}
+
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Marketplace', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Marketplace', 'Resources/views'), 'marketplace');
$this->loadTranslationsFrom(module_path('Marketplace', 'Resources/lang'), 'marketplace');
if ($this->app->runningInConsole()) {
- $this->commands([\Modules\Marketplace\Console\Commands\MarketplaceSyncCommand::class, \Modules\Marketplace\Console\Commands\MarketplaceAuditCommand::class]);
+ $this->commands([MarketplaceSyncCommand::class, MarketplaceAuditCommand::class]);
}
}
}
diff --git a/Modules/Marketplace/Providers/RouteServiceProvider.php b/Modules/Marketplace/Providers/RouteServiceProvider.php
index 1f248db..6bf0725 100644
--- a/Modules/Marketplace/Providers/RouteServiceProvider.php
+++ b/Modules/Marketplace/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
prefix('marketplace')->name('api.marketplace.')->group(function () {
- Route::get('/packages',[MarketplaceApiController::class,'packages'])->middleware('permission:marketplace.view')->name('packages');
- Route::get('/installed',[MarketplaceApiController::class,'installed'])->middleware('permission:marketplace.view')->name('installed');
- Route::post('/packages/{package}/install',[MarketplaceApiController::class,'install'])->middleware('permission:marketplace.install')->name('install');
- Route::post('/installed/{package}/toggle',[MarketplaceApiController::class,'toggle'])->middleware('permission:marketplace.update')->name('toggle');
- Route::delete('/installed/{package}',[MarketplaceApiController::class,'remove'])->middleware('permission:marketplace.remove')->name('remove');
+ Route::get('/packages', [MarketplaceApiController::class, 'packages'])->middleware('permission:marketplace.view')->name('packages');
+ Route::get('/installed', [MarketplaceApiController::class, 'installed'])->middleware('permission:marketplace.view')->name('installed');
+ Route::post('/packages/{package}/install', [MarketplaceApiController::class, 'install'])->middleware('permission:marketplace.install')->name('install');
+ Route::post('/installed/{package}/toggle', [MarketplaceApiController::class, 'toggle'])->middleware('permission:marketplace.update')->name('toggle');
+ Route::delete('/installed/{package}', [MarketplaceApiController::class, 'remove'])->middleware('permission:marketplace.remove')->name('remove');
});
diff --git a/Modules/Marketplace/Routes/web.php b/Modules/Marketplace/Routes/web.php
index e92e32f..2ac0b30 100644
--- a/Modules/Marketplace/Routes/web.php
+++ b/Modules/Marketplace/Routes/web.php
@@ -1,12 +1,14 @@
prefix('administration/marketplace')->name('administration.marketplace.')->group(function () {
- Route::get('/',[MarketplaceController::class,'index'])->middleware('permission:marketplace.view')->name('index');
- Route::get('/installed',[MarketplaceController::class,'installed'])->middleware('permission:marketplace.view')->name('installed');
- Route::post('/packages/{package}/install',[MarketplaceController::class,'install'])->middleware('permission:marketplace.install')->name('install');
- Route::post('/packages/{package}/update',[MarketplaceController::class,'update'])->middleware('permission:marketplace.update')->name('update');
- Route::post('/installed/{package}/toggle',[MarketplaceController::class,'toggle'])->middleware('permission:marketplace.update')->name('toggle');
- Route::delete('/installed/{package}',[MarketplaceController::class,'remove'])->middleware('permission:marketplace.remove')->name('remove');
- Route::post('/installed/{package}/rollback/{installation}',[MarketplaceController::class,'rollback'])->middleware('permission:marketplace.update')->name('rollback');
+
+Route::middleware(['auth', 'verified'])->prefix('administration/marketplace')->name('administration.marketplace.')->group(function () {
+ Route::get('/', [MarketplaceController::class, 'index'])->middleware('permission:marketplace.view')->name('index');
+ Route::get('/installed', [MarketplaceController::class, 'installed'])->middleware('permission:marketplace.view')->name('installed');
+ Route::post('/packages/{package}/install', [MarketplaceController::class, 'install'])->middleware('permission:marketplace.install')->name('install');
+ Route::post('/packages/{package}/update', [MarketplaceController::class, 'update'])->middleware('permission:marketplace.update')->name('update');
+ Route::post('/installed/{package}/toggle', [MarketplaceController::class, 'toggle'])->middleware('permission:marketplace.update')->name('toggle');
+ Route::delete('/installed/{package}', [MarketplaceController::class, 'remove'])->middleware('permission:marketplace.remove')->name('remove');
+ Route::post('/installed/{package}/rollback/{installation}', [MarketplaceController::class, 'rollback'])->middleware('permission:marketplace.update')->name('rollback');
});
diff --git a/Modules/Marketplace/Services/CompatibilityChecker.php b/Modules/Marketplace/Services/CompatibilityChecker.php
index 5fd7f96..4ef2e12 100644
--- a/Modules/Marketplace/Services/CompatibilityChecker.php
+++ b/Modules/Marketplace/Services/CompatibilityChecker.php
@@ -1,17 +1,30 @@
constraints->matches(PHP_VERSION,$php)) $errors[]="Requires PHP $php; running ".PHP_VERSION.'.';
- if (!$this->constraints->matches(App::version(),$laravel)) $errors[]="Requires Laravel $laravel; running ".App::version().'.';
- if (!$this->constraints->matches($platformVersion,$platform)) $errors[]="Requires Content Manager $platform; running $platformVersion.";
- return ['compatible'=>$errors===[],'errors'=>$errors];
+ $errors = [];
+ $php = (string) ($manifest['php'] ?? '*');
+ $laravel = (string) ($manifest['laravel'] ?? '*');
+ $platform = (string) ($manifest['content_manager'] ?? '*');
+ $platformVersion = (string) config('app.version', '1.0.0');
+ if (! $this->constraints->matches(PHP_VERSION, $php)) {
+ $errors[] = "Requires PHP $php; running ".PHP_VERSION.'.';
+ }
+ if (! $this->constraints->matches(App::version(), $laravel)) {
+ $errors[] = "Requires Laravel $laravel; running ".App::version().'.';
+ }
+ if (! $this->constraints->matches($platformVersion, $platform)) {
+ $errors[] = "Requires Content Manager $platform; running $platformVersion.";
+ }
+
+ return ['compatible' => $errors === [], 'errors' => $errors];
}
}
diff --git a/Modules/Marketplace/Services/DependencyResolver.php b/Modules/Marketplace/Services/DependencyResolver.php
index dc97614..02c711a 100644
--- a/Modules/Marketplace/Services/DependencyResolver.php
+++ b/Modules/Marketplace/Services/DependencyResolver.php
@@ -1,17 +1,29 @@
$constraint) {
- $installed=InstalledPackage::query()->where('package_name',$package)->where('status','installed')->first();
- if (!$installed) { $missing[$package]=$constraint; continue; }
- if (!$this->constraints->matches($installed->version,(string)$constraint)) $incompatible[$package]=['required'=>$constraint,'installed'=>$installed->version];
+ $missing = [];
+ $incompatible = [];
+ foreach (($manifest['requires'] ?? []) as $package => $constraint) {
+ $installed = InstalledPackage::query()->where('package_name', $package)->where('status', 'installed')->first();
+ if (! $installed) {
+ $missing[$package] = $constraint;
+
+ continue;
+ }
+ if (! $this->constraints->matches($installed->version, (string) $constraint)) {
+ $incompatible[$package] = ['required' => $constraint, 'installed' => $installed->version];
+ }
}
- return ['satisfied'=>$missing===[] && $incompatible===[],'missing'=>$missing,'incompatible'=>$incompatible];
+
+ return ['satisfied' => $missing === [] && $incompatible === [], 'missing' => $missing, 'incompatible' => $incompatible];
}
}
diff --git a/Modules/Marketplace/Services/MarketplaceManager.php b/Modules/Marketplace/Services/MarketplaceManager.php
index c3d8741..a457800 100644
--- a/Modules/Marketplace/Services/MarketplaceManager.php
+++ b/Modules/Marketplace/Services/MarketplaceManager.php
@@ -1,39 +1,72 @@
manifests->parse($package->manifest ?: ['name'=>$package->package_name,'type'=>$package->type,'version'=>$package->latest_version]);
- $compatibility=$this->compatibility->check($manifest); if (!$compatibility['compatible']) throw new RuntimeException(implode(' ',$compatibility['errors']));
- $dependencies=$this->dependencies->resolve($manifest); if (!$dependencies['satisfied']) throw new RuntimeException('Package dependencies are not satisfied.');
- return DB::transaction(function() use ($package,$manifest,$userId) {
- $installed=InstalledPackage::query()->firstOrNew(['package_name'=>$package->package_name]); $from=$installed->exists?$installed->version:null;
- $installed->fill(['marketplace_package_id'=>$package->id,'type'=>$package->type,'version'=>$manifest['version'],'status'=>'installed','manifest'=>$manifest,'checksum'=>$package->checksum,'is_enabled'=>true,'installed_by'=>$userId,'installed_at'=>$installed->installed_at ?: now(),'updated_at_marketplace'=>now()])->save();
- $this->history($installed,$from?'update':'install',$from,$manifest['version'],$userId,'completed'); return $installed->refresh();
+ if (! config('marketplace.enabled')) {
+ throw new RuntimeException('The marketplace is disabled.');
+ }
+ $manifest = $this->manifests->parse($package->manifest ?: ['name' => $package->package_name, 'type' => $package->type, 'version' => $package->latest_version]);
+ $compatibility = $this->compatibility->check($manifest);
+ if (! $compatibility['compatible']) {
+ throw new RuntimeException(implode(' ', $compatibility['errors']));
+ }
+ $dependencies = $this->dependencies->resolve($manifest);
+ if (! $dependencies['satisfied']) {
+ throw new RuntimeException('Package dependencies are not satisfied.');
+ }
+
+ return DB::transaction(function () use ($package, $manifest, $userId) {
+ $installed = InstalledPackage::query()->firstOrNew(['package_name' => $package->package_name]);
+ $from = $installed->exists ? $installed->version : null;
+ $installed->fill(['marketplace_package_id' => $package->id, 'type' => $package->type, 'version' => $manifest['version'], 'status' => 'installed', 'manifest' => $manifest, 'checksum' => $package->checksum, 'is_enabled' => true, 'installed_by' => $userId, 'installed_at' => $installed->installed_at ?: now(), 'updated_at_marketplace' => now()])->save();
+ $this->history($installed, $from ? 'update' : 'install', $from, $manifest['version'], $userId, 'completed');
+
+ return $installed->refresh();
});
}
- public function setEnabled(InstalledPackage $package,bool $enabled,?int $userId=null): InstalledPackage
+
+ public function setEnabled(InstalledPackage $package, bool $enabled, ?int $userId = null): InstalledPackage
{
- $package->update(['is_enabled'=>$enabled,'status'=>$enabled?'installed':'disabled']); $this->history($package,$enabled?'enable':'disable',$package->version,$package->version,$userId,'completed'); return $package->refresh();
+ $package->update(['is_enabled' => $enabled, 'status' => $enabled ? 'installed' : 'disabled']);
+ $this->history($package, $enabled ? 'enable' : 'disable', $package->version, $package->version, $userId, 'completed');
+
+ return $package->refresh();
}
- public function remove(InstalledPackage $package,?int $userId=null): void
+
+ public function remove(InstalledPackage $package, ?int $userId = null): void
{
- DB::transaction(function() use ($package,$userId) { $this->history($package,'remove',$package->version,null,$userId,'completed'); $package->update(['status'=>'removed','is_enabled'=>false]); });
+ DB::transaction(function () use ($package, $userId) {
+ $this->history($package, 'remove', $package->version, null, $userId, 'completed');
+ $package->update(['status' => 'removed', 'is_enabled' => false]);
+ });
}
- public function rollback(InstalledPackage $package,Installation $installation,?int $userId=null): InstalledPackage
+
+ public function rollback(InstalledPackage $package, Installation $installation, ?int $userId = null): InstalledPackage
{
- if ($installation->package_name!==$package->package_name || blank($installation->from_version)) throw new RuntimeException('This installation cannot be rolled back.');
- $current=$package->version; $package->update(['version'=>$installation->from_version,'status'=>'installed','is_enabled'=>true]);
- $this->history($package,'rollback',$current,$installation->from_version,$userId,'completed'); return $package->refresh();
+ if ($installation->package_name !== $package->package_name || blank($installation->from_version)) {
+ throw new RuntimeException('This installation cannot be rolled back.');
+ }
+ $current = $package->version;
+ $package->update(['version' => $installation->from_version, 'status' => 'installed', 'is_enabled' => true]);
+ $this->history($package, 'rollback', $current, $installation->from_version, $userId, 'completed');
+
+ return $package->refresh();
+ }
+
+ private function history(InstalledPackage $package, string $operation, ?string $from, ?string $to, ?int $userId, string $status): Installation
+ {
+ return Installation::query()->create(['installed_package_id' => $package->id, 'package_name' => $package->package_name, 'operation' => $operation, 'from_version' => $from, 'to_version' => $to, 'status' => $status, 'performed_by' => $userId, 'started_at' => now(), 'completed_at' => now()]);
}
- private function history(InstalledPackage $package,string $operation,?string $from,?string $to,?int $userId,string $status): Installation
- { return Installation::query()->create(['installed_package_id'=>$package->id,'package_name'=>$package->package_name,'operation'=>$operation,'from_version'=>$from,'to_version'=>$to,'status'=>$status,'performed_by'=>$userId,'started_at'=>now(),'completed_at'=>now()]); }
}
diff --git a/Modules/Marketplace/Services/PackageManifest.php b/Modules/Marketplace/Services/PackageManifest.php
index 6a7de47..2fecb73 100644
--- a/Modules/Marketplace/Services/PackageManifest.php
+++ b/Modules/Marketplace/Services/PackageManifest.php
@@ -1,22 +1,38 @@
'The package manifest is not valid JSON.']);
- $manifest=$decoded;
+ $decoded = json_decode($manifest, true);
+ if (! is_array($decoded)) {
+ throw ValidationException::withMessages(['manifest' => 'The package manifest is not valid JSON.']);
+ }
+ $manifest = $decoded;
}
- foreach (['name','type','version'] as $field) if (!filled(Arr::get($manifest,$field))) throw ValidationException::withMessages(['manifest'=>"Manifest field [$field] is required."]);
- if (!in_array($manifest['type'],config('marketplace.supported_types',[]),true)) throw ValidationException::withMessages(['manifest'=>'Unsupported marketplace package type.']);
- $manifest['requires']=$manifest['requires'] ?? [];
- $manifest['content_manager']=$manifest['content_manager'] ?? '*';
- $manifest['laravel']=$manifest['laravel'] ?? '*';
+ foreach (['name', 'type', 'version'] as $field) {
+ if (! filled(Arr::get($manifest, $field))) {
+ throw ValidationException::withMessages(['manifest' => "Manifest field [$field] is required."]);
+ }
+ }
+ if (! in_array($manifest['type'], config('marketplace.supported_types', []), true)) {
+ throw ValidationException::withMessages(['manifest' => 'Unsupported marketplace package type.']);
+ }
+ $manifest['requires'] = $manifest['requires'] ?? [];
+ $manifest['content_manager'] = $manifest['content_manager'] ?? '*';
+ $manifest['laravel'] = $manifest['laravel'] ?? '*';
+
return $manifest;
}
- public function fromFile(string $path): array { return $this->parse((string) file_get_contents($path)); }
+
+ public function fromFile(string $path): array
+ {
+ return $this->parse((string) file_get_contents($path));
+ }
}
diff --git a/Modules/Marketplace/Services/PackageVerifier.php b/Modules/Marketplace/Services/PackageVerifier.php
index 647e00c..1f909f7 100644
--- a/Modules/Marketplace/Services/PackageVerifier.php
+++ b/Modules/Marketplace/Services/PackageVerifier.php
@@ -1,16 +1,28 @@
true,'checksum'=>$actual,'signature_present'=>filled($manifest['signature'] ?? null)];
+ if (! is_file($path)) {
+ throw new RuntimeException("Package archive [$path] does not exist.");
+ }
+ $actual = hash_file('sha256', $path);
+ $expected = $expectedChecksum ?: ($manifest['checksum'] ?? null);
+ if (config('marketplace.require_checksum', true) && blank($expected)) {
+ throw new RuntimeException('A SHA-256 checksum is required.');
+ }
+ if (filled($expected) && ! hash_equals(strtolower((string) $expected), strtolower($actual))) {
+ throw new RuntimeException('Package checksum verification failed.');
+ }
+ if (config('marketplace.require_signature', false) && blank($manifest['signature'] ?? null)) {
+ throw new RuntimeException('A package signature is required.');
+ }
+
+ return ['verified' => true, 'checksum' => $actual, 'signature_present' => filled($manifest['signature'] ?? null)];
}
}
diff --git a/Modules/Marketplace/Services/VersionConstraint.php b/Modules/Marketplace/Services/VersionConstraint.php
index d0919c0..14e206c 100644
--- a/Modules/Marketplace/Services/VersionConstraint.php
+++ b/Modules/Marketplace/Services/VersionConstraint.php
@@ -1,22 +1,47 @@
=') && version_compare($version,($major+1).'.0.0','<'); continue; }
- if (str_starts_with($part,'~')) { $base=substr($part,1); $bits=array_pad(explode('.',$base),3,'0'); $upper=$bits[0].'.'.((int)$bits[1]+1).'.0'; $ok=$ok && version_compare($version,$base,'>=') && version_compare($version,$upper,'<'); continue; }
- if (preg_match('/^(>=|<=|>|<|=)?\s*([0-9][0-9A-Za-z.\-+]*)$/',$part,$m)) $ok=$ok && version_compare($version,$m[2],$m[1] ?: '=');
- else $ok=$ok && $version===$part;
- }
- if ($ok) return true;
+ $constraint = trim($constraint);
+ if ($constraint === '' || $constraint === '*') {
+ return true;
}
+ foreach (preg_split('/\s*\|\|\s*/', $constraint) ?: [] as $alternative) {
+ $ok = true;
+ foreach (preg_split('/\s*,?\s+/', trim($alternative)) ?: [] as $part) {
+ if ($part === '') {
+ continue;
+ }
+ if (str_starts_with($part, '^')) {
+ $base = substr($part, 1);
+ $major = (int) explode('.', $base)[0];
+ $ok = $ok && version_compare($version, $base, '>=') && version_compare($version, ($major + 1).'.0.0', '<');
+
+ continue;
+ }
+ if (str_starts_with($part, '~')) {
+ $base = substr($part, 1);
+ $bits = array_pad(explode('.', $base), 3, '0');
+ $upper = $bits[0].'.'.((int) $bits[1] + 1).'.0';
+ $ok = $ok && version_compare($version, $base, '>=') && version_compare($version, $upper, '<');
+
+ continue;
+ }
+ if (preg_match('/^(>=|<=|>|<|=)?\s*([0-9][0-9A-Za-z.\-+]*)$/', $part, $m)) {
+ $ok = $ok && version_compare($version, $m[2], $m[1] ?: '=');
+ } else {
+ $ok = $ok && $version === $part;
+ }
+ }
+ if ($ok) {
+ return true;
+ }
+ }
+
return false;
}
}
diff --git a/Modules/Marketplace/Tests/Feature/MarketplaceModuleTest.php b/Modules/Marketplace/Tests/Feature/MarketplaceModuleTest.php
index e02803a..663809b 100644
--- a/Modules/Marketplace/Tests/Feature/MarketplaceModuleTest.php
+++ b/Modules/Marketplace/Tests/Feature/MarketplaceModuleTest.php
@@ -1,18 +1,23 @@
parse(['name'=>'vendor/example','type'=>'module','version'=>'1.0.0']);
- $this->assertSame('module',$manifest['type']); $this->assertSame([], $manifest['requires']);
+ $manifest = app(PackageManifest::class)->parse(['name' => 'vendor/example', 'type' => 'module', 'version' => '1.0.0']);
+ $this->assertSame('module', $manifest['type']);
+ $this->assertSame([], $manifest['requires']);
}
+
public function test_current_platform_is_compatible_with_wildcard_manifest(): void
{
- $result=app(CompatibilityChecker::class)->check(['php'=>'*','laravel'=>'*','content_manager'=>'*']);
+ $result = app(CompatibilityChecker::class)->check(['php' => '*', 'laravel' => '*', 'content_manager' => '*']);
$this->assertTrue($result['compatible']);
}
}
diff --git a/Modules/Media/Database/Migrations/2026_07_23_000300_create_media_table.php b/Modules/Media/Database/Migrations/2026_07_23_000300_create_media_table.php
index 4e874ad..581799e 100644
--- a/Modules/Media/Database/Migrations/2026_07_23_000300_create_media_table.php
+++ b/Modules/Media/Database/Migrations/2026_07_23_000300_create_media_table.php
@@ -1,3 +1,37 @@
id(); $table->uuid('uuid')->unique(); $table->nullableMorphs('attachable'); $table->string('collection')->default('default')->index(); $table->string('disk')->default('public'); $table->string('path'); $table->string('original_name'); $table->string('mime_type')->nullable(); $table->unsignedBigInteger('size')->default(0); $table->string('title')->nullable(); $table->string('alt_text')->nullable(); $table->text('caption')->nullable(); $table->json('metadata')->nullable(); $table->unsignedInteger('sort_order')->default(0); $table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete(); $table->timestamps(); $table->softDeletes(); $table->index(['attachable_type','attachable_id','collection','sort_order'],'media_attachable_collection_index'); }); } public function down(): void { Schema::dropIfExists('media'); } };
+
+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('media', function (Blueprint $table): void {
+ $table->id();
+ $table->uuid('uuid')->unique();
+ $table->nullableMorphs('attachable');
+ $table->string('collection')->default('default')->index();
+ $table->string('disk')->default('public');
+ $table->string('path');
+ $table->string('original_name');
+ $table->string('mime_type')->nullable();
+ $table->unsignedBigInteger('size')->default(0);
+ $table->string('title')->nullable();
+ $table->string('alt_text')->nullable();
+ $table->text('caption')->nullable();
+ $table->json('metadata')->nullable();
+ $table->unsignedInteger('sort_order')->default(0);
+ $table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+ $table->softDeletes();
+ $table->index(['attachable_type', 'attachable_id', 'collection', 'sort_order'], 'media_attachable_collection_index');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('media');
+ }
+};
diff --git a/Modules/Media/Http/Controllers/MediaController.php b/Modules/Media/Http/Controllers/MediaController.php
index f9dcb90..ee5c15e 100644
--- a/Modules/Media/Http/Controllers/MediaController.php
+++ b/Modules/Media/Http/Controllers/MediaController.php
@@ -1,8 +1,34 @@
Media::query()->latest()->paginate(30)]); }
- public function store(Request $request){ $request->validate(['file'=>['required','file','max:20480'],'title'=>['nullable','string','max:255'],'alt_text'=>['nullable','string','max:255']]); $file=$request->file('file'); $path=$file->store('media','public'); Media::create(['disk'=>'public','path'=>$path,'original_name'=>$file->getClientOriginalName(),'mime_type'=>$file->getMimeType(),'size'=>$file->getSize(),'title'=>$request->string('title')->toString() ?: pathinfo($file->getClientOriginalName(),PATHINFO_FILENAME),'alt_text'=>$request->string('alt_text')->toString() ?: null,'uploaded_by'=>$request->user()->id]); return back()->with('status','Media uploaded.'); }
- public function destroy(Media $medium){ Storage::disk($medium->disk)->delete($medium->path); $medium->delete(); return back()->with('status','Media deleted.'); }
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Storage;
+use Modules\Media\Models\Media;
+
+final class MediaController extends Controller
+{
+ public function index()
+ {
+ return view('media::index', ['media' => Media::query()->latest()->paginate(30)]);
+ }
+
+ public function store(Request $request)
+ {
+ $request->validate(['file' => ['required', 'file', 'max:20480'], 'title' => ['nullable', 'string', 'max:255'], 'alt_text' => ['nullable', 'string', 'max:255']]);
+ $file = $request->file('file');
+ $path = $file->store('media', 'public');
+ Media::create(['disk' => 'public', 'path' => $path, 'original_name' => $file->getClientOriginalName(), 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), 'title' => $request->string('title')->toString() ?: pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME), 'alt_text' => $request->string('alt_text')->toString() ?: null, 'uploaded_by' => $request->user()->id]);
+
+ return back()->with('status', 'Media uploaded.');
+ }
+
+ public function destroy(Media $medium)
+ {
+ Storage::disk($medium->disk)->delete($medium->path);
+ $medium->delete();
+
+ return back()->with('status', 'Media deleted.');
+ }
}
diff --git a/Modules/Media/Models/Media.php b/Modules/Media/Models/Media.php
index 0a8c81e..56af7ee 100644
--- a/Modules/Media/Models/Media.php
+++ b/Modules/Media/Models/Media.php
@@ -1,3 +1,26 @@
$m->uuid ??= (string) Str::uuid()); } protected function casts(): array { return ['metadata'=>'array','size'=>'integer','sort_order'=>'integer']; } }
+
+namespace Modules\Media\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
+use Illuminate\Support\Str;
+
+final class Media extends Model
+{
+ use SoftDeletes;
+
+ protected $table = 'media';
+
+ protected $guarded = [];
+
+ protected static function booted(): void
+ {
+ self::creating(fn (self $m) => $m->uuid ??= (string) Str::uuid());
+ }
+
+ protected function casts(): array
+ {
+ return ['metadata' => 'array', 'size' => 'integer', 'sort_order' => 'integer'];
+ }
+}
diff --git a/Modules/Media/Routes/web.php b/Modules/Media/Routes/web.php
index 4d9162f..172cab2 100644
--- a/Modules/Media/Routes/web.php
+++ b/Modules/Media/Routes/web.php
@@ -1,3 +1,10 @@
prefix('administration/media')->name('administration.media.')->group(function (): void { Route::get('/',[MediaController::class,'index'])->name('index'); Route::post('/',[MediaController::class,'store'])->name('store'); Route::delete('/{medium}',[MediaController::class,'destroy'])->name('destroy'); });
+
+use Illuminate\Support\Facades\Route;
+use Modules\Media\Http\Controllers\MediaController;
+
+Route::middleware(['auth', 'verified'])->prefix('administration/media')->name('administration.media.')->group(function (): void {
+ Route::get('/', [MediaController::class, 'index'])->name('index');
+ Route::post('/', [MediaController::class, 'store'])->name('store');
+ Route::delete('/{medium}', [MediaController::class, 'destroy'])->name('destroy');
+});
diff --git a/Modules/Media/Support/ModuleInfo.php b/Modules/Media/Support/ModuleInfo.php
index 78d45a0..91bfbd1 100644
--- a/Modules/Media/Support/ModuleInfo.php
+++ b/Modules/Media/Support/ModuleInfo.php
@@ -4,6 +4,13 @@ namespace Modules\Media\Support;
final class ModuleInfo
{
- public static function name(): string { return 'Media'; }
- public static function nameLower(): string { return 'media'; }
+ public static function name(): string
+ {
+ return 'Media';
+ }
+
+ public static function nameLower(): string
+ {
+ return 'media';
+ }
}
diff --git a/Modules/Menus/Database/Migrations/2026_07_24_000200_create_menus_table.php b/Modules/Menus/Database/Migrations/2026_07_24_000200_create_menus_table.php
index e49ccd6..6ab61c5 100644
--- a/Modules/Menus/Database/Migrations/2026_07_24_000200_create_menus_table.php
+++ b/Modules/Menus/Database/Migrations/2026_07_24_000200_create_menus_table.php
@@ -4,12 +4,23 @@ 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('menus', function (Blueprint $table): void { $table->id();
+return new class extends Migration
+{
+ public function up(): void
+ {
+ Schema::create('menus', function (Blueprint $table): void {
+ $table->id();
$table->string('name');
$table->string('location');
$table->json('items')->nullable();
$table->boolean('enabled')->default(true);
- $table->timestamps(); $table->softDeletes(); }); }
- public function down(): void { Schema::dropIfExists('menus'); }
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('menus');
+ }
};
diff --git a/Modules/Menus/Http/Controllers/MenuController.php b/Modules/Menus/Http/Controllers/MenuController.php
index 419dc64..1b43eb6 100644
--- a/Modules/Menus/Http/Controllers/MenuController.php
+++ b/Modules/Menus/Http/Controllers/MenuController.php
@@ -10,18 +10,55 @@ use Modules\Menus\Models\Menu;
final class MenuController extends Controller
{
- public function index(): View { return view('menus::admin.index', ['records' => Menu::query()->latest()->paginate(20)]); }
- public function create(): View { return view('menus::admin.form'); }
- public function store(Request $request): RedirectResponse { $data = $request->validate(['name' => ['required', 'string', 'max:255'],
+ public function index(): View
+ {
+ return view('menus::admin.index', ['records' => Menu::query()->latest()->paginate(20)]);
+ }
+
+ public function create(): View
+ {
+ return view('menus::admin.form');
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'],
'location' => ['required', 'string', 'max:255'],
'items' => ['nullable', 'array'],
- 'enabled' => ['sometimes', 'boolean']]); if ($request->filled('items_json')) { $data['items'] = json_decode($request->string('items_json')->toString(), true, 512, JSON_THROW_ON_ERROR); }
- $data['enabled'] = $request->boolean('enabled'); Menu::create($data); return redirect()->route('menus.index')->with('status', 'Menu created.'); }
- public function edit(Menu $menu): View { return view('menus::admin.form', ['record' => $menu]); }
- public function update(Request $request, Menu $menu): RedirectResponse { $data = $request->validate(['name' => ['required', 'string', 'max:255'],
+ 'enabled' => ['sometimes', 'boolean']]);
+ if ($request->filled('items_json')) {
+ $data['items'] = json_decode($request->string('items_json')->toString(), true, 512, JSON_THROW_ON_ERROR);
+ }
+ $data['enabled'] = $request->boolean('enabled');
+ Menu::create($data);
+
+ return redirect()->route('menus.index')->with('status', 'Menu created.');
+ }
+
+ public function edit(Menu $menu): View
+ {
+ return view('menus::admin.form', ['record' => $menu]);
+ }
+
+ public function update(Request $request, Menu $menu): RedirectResponse
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'],
'location' => ['required', 'string', 'max:255'],
'items' => ['nullable', 'array'],
- 'enabled' => ['sometimes', 'boolean']]); if ($request->filled('items_json')) { $data['items'] = json_decode($request->string('items_json')->toString(), true, 512, JSON_THROW_ON_ERROR); }
- $data['enabled'] = $request->boolean('enabled'); $menu->update($data); return redirect()->route('menus.index')->with('status', 'Menu updated.'); }
- public function destroy(Menu $menu): RedirectResponse { $menu->delete(); return back()->with('status', 'Menu deleted.'); }
+ 'enabled' => ['sometimes', 'boolean']]);
+ if ($request->filled('items_json')) {
+ $data['items'] = json_decode($request->string('items_json')->toString(), true, 512, JSON_THROW_ON_ERROR);
+ }
+ $data['enabled'] = $request->boolean('enabled');
+ $menu->update($data);
+
+ return redirect()->route('menus.index')->with('status', 'Menu updated.');
+ }
+
+ public function destroy(Menu $menu): RedirectResponse
+ {
+ $menu->delete();
+
+ return back()->with('status', 'Menu deleted.');
+ }
}
diff --git a/Modules/Menus/Models/Menu.php b/Modules/Menus/Models/Menu.php
index 6eb9153..0f957c6 100644
--- a/Modules/Menus/Models/Menu.php
+++ b/Modules/Menus/Models/Menu.php
@@ -9,12 +9,16 @@ use Illuminate\Database\Eloquent\SoftDeletes;
final class Menu extends Model
{
use HasFactory, SoftDeletes;
- protected $table = 'menus';
- protected $fillable = ['name', 'location', 'items', 'enabled'];
-
- protected function casts(): array { return [
- 'items' => 'array',
- 'enabled' => 'boolean'
- ]; }
+ protected $table = 'menus';
+
+ protected $fillable = ['name', 'location', 'items', 'enabled'];
+
+ protected function casts(): array
+ {
+ return [
+ 'items' => 'array',
+ 'enabled' => 'boolean',
+ ];
+ }
}
diff --git a/Modules/Menus/Support/ModuleInfo.php b/Modules/Menus/Support/ModuleInfo.php
index 4a845a6..e96b94b 100644
--- a/Modules/Menus/Support/ModuleInfo.php
+++ b/Modules/Menus/Support/ModuleInfo.php
@@ -2,4 +2,15 @@
namespace Modules\Menus\Support;
-final class ModuleInfo { public static function name(): string { return 'Menus'; } public static function nameLower(): string { return 'menus'; } }
+final class ModuleInfo
+{
+ public static function name(): string
+ {
+ return 'Menus';
+ }
+
+ public static function nameLower(): string
+ {
+ return 'menus';
+ }
+}
diff --git a/Modules/Menus/Tests/Feature/ModuleBootTest.php b/Modules/Menus/Tests/Feature/ModuleBootTest.php
index f45977b..516d963 100644
--- a/Modules/Menus/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Menus/Tests/Feature/ModuleBootTest.php
@@ -2,4 +2,6 @@
use Illuminate\Support\Facades\Route;
-it('registers the Menus administration route', function (): void { expect(Route::has('administration.menus.index'))->toBeTrue(); });
+it('registers the Menus administration route', function (): void {
+ expect(Route::has('administration.menus.index'))->toBeTrue();
+});
diff --git a/Modules/News/Config/config.php b/Modules/News/Config/config.php
index 0db6d9a..3909f94 100644
--- a/Modules/News/Config/config.php
+++ b/Modules/News/Config/config.php
@@ -1,2 +1,3 @@
'News'];
diff --git a/Modules/News/Database/Migrations/2026_07_24_03042_create_news_articles_table.php b/Modules/News/Database/Migrations/2026_07_24_03042_create_news_articles_table.php
index d1a675a..d939ffc 100644
--- a/Modules/News/Database/Migrations/2026_07_24_03042_create_news_articles_table.php
+++ b/Modules/News/Database/Migrations/2026_07_24_03042_create_news_articles_table.php
@@ -1,3 +1,40 @@
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('news_article_tag', function(Blueprint $table): void { $table->foreignId('news_article_id')->constrained('news_articles')->cascadeOnDelete(); $table->foreignId('tag_id')->constrained('tags')->cascadeOnDelete(); $table->primary(['news_article_id','tag_id']); }); } public function down(): void { Schema::dropIfExists('news_article_tag'); Schema::dropIfExists('news_articles'); } };
+
+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('news_articles', 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('news_article_tag', function (Blueprint $table): void {
+ $table->foreignId('news_article_id')->constrained('news_articles')->cascadeOnDelete();
+ $table->foreignId('tag_id')->constrained('tags')->cascadeOnDelete();
+ $table->primary(['news_article_id', 'tag_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('news_article_tag');
+ Schema::dropIfExists('news_articles');
+ }
+};
diff --git a/Modules/News/Http/Controllers/NewsController.php b/Modules/News/Http/Controllers/NewsController.php
index 34e9073..cc69cc2 100644
--- a/Modules/News/Http/Controllers/NewsController.php
+++ b/Modules/News/Http/Controllers/NewsController.php
@@ -1,4 +1,66 @@
News::with(['author','category'])->latest()->paginate(25)]); } public function create(): View { return view('news::admin.form',$this->lookups()); } public function store(Request $r): RedirectResponse { $record=News::create($this->data($r)); $record->tags()->sync($r->input('tag_ids',[])); return redirect()->route('administration.news.index')->with('status','News created.'); } public function edit(News $news_article): View { return view('news::admin.form',array_merge($this->lookups(),['record'=>$news_article])); } public function update(Request $r, News $news_article): RedirectResponse { $news_article->update($this->data($r)); $news_article->tags()->sync($r->input('tag_ids',[])); return redirect()->route('administration.news.index')->with('status','News updated.'); } public function destroy(News $news_article): RedirectResponse { $news_article->delete(); return back()->with('status','News 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\Categories\Models\Category;
+use Modules\News\Models\News;
+use Modules\Tags\Models\Tag;
+
+final class NewsController extends Controller
+{
+ public function index(): View
+ {
+ return view('news::admin.index', ['records' => News::with(['author', 'category'])->latest()->paginate(25)]);
+ }
+
+ public function create(): View
+ {
+ return view('news::admin.form', $this->lookups());
+ }
+
+ public function store(Request $r): RedirectResponse
+ {
+ $record = News::create($this->data($r));
+ $record->tags()->sync($r->input('tag_ids', []));
+
+ return redirect()->route('administration.news.index')->with('status', 'News created.');
+ }
+
+ public function edit(News $news_article): View
+ {
+ return view('news::admin.form', array_merge($this->lookups(), ['record' => $news_article]));
+ }
+
+ public function update(Request $r, News $news_article): RedirectResponse
+ {
+ $news_article->update($this->data($r));
+ $news_article->tags()->sync($r->input('tag_ids', []));
+
+ return redirect()->route('administration.news.index')->with('status', 'News updated.');
+ }
+
+ public function destroy(News $news_article): RedirectResponse
+ {
+ $news_article->delete();
+
+ return back()->with('status', 'News 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;
+ }
+}
diff --git a/Modules/News/Http/Controllers/PublicNewsController.php b/Modules/News/Http/Controllers/PublicNewsController.php
index c0fef89..5783072 100644
--- a/Modules/News/Http/Controllers/PublicNewsController.php
+++ b/Modules/News/Http/Controllers/PublicNewsController.php
@@ -1,3 +1,20 @@
News::published()->with(['author','category'])->latest('published_at')->paginate(12)]); } public function show(string $slug): View { return view('news::public.show',['record'=>News::published()->with(['author','category','tags'])->where('slug',$slug)->firstOrFail()]); } }
+
+namespace Modules\News\Http\Controllers;
+
+use App\Http\Controllers\Controller;
+use Illuminate\View\View;
+use Modules\News\Models\News;
+
+final class PublicNewsController extends Controller
+{
+ public function index(): View
+ {
+ return view('news::public.index', ['records' => News::published()->with(['author', 'category'])->latest('published_at')->paginate(12)]);
+ }
+
+ public function show(string $slug): View
+ {
+ return view('news::public.show', ['record' => News::published()->with(['author', 'category', 'tags'])->where('slug', $slug)->firstOrFail()]);
+ }
+}
diff --git a/Modules/News/Models/News.php b/Modules/News/Models/News.php
index c481083..586c952 100644
--- a/Modules/News/Models/News.php
+++ b/Modules/News/Models/News.php
@@ -1,5 +1,48 @@
'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, 'news_article_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 News extends Model
+{
+ use BelongsToSites, HasTranslations, HasWorkflow;
+
+ protected $table = 'news_articles';
+
+ 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, 'news_article_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()));
+ }
+}
diff --git a/Modules/News/Providers/NewsServiceProvider.php b/Modules/News/Providers/NewsServiceProvider.php
index b791919..959998d 100644
--- a/Modules/News/Providers/NewsServiceProvider.php
+++ b/Modules/News/Providers/NewsServiceProvider.php
@@ -1,6 +1,9 @@
app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('News', 'Config/config.php'), 'news');
}
+
public function boot(): void
{
$this->loadMigrationsFrom(module_path('News', 'Database/Migrations'));
diff --git a/Modules/News/Providers/RouteServiceProvider.php b/Modules/News/Providers/RouteServiceProvider.php
index 4a8265e..cfa0850 100644
--- a/Modules/News/Providers/RouteServiceProvider.php
+++ b/Modules/News/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
prefix('administration/news')->name('administration.news.')->group(function (): void { Route::resource('/', NewsController::class)->parameters([''=>'news_article'])->except('show'); });
-Route::get('/news',[PublicNewsController::class,'index'])->name('news.public.index'); Route::get('/news/{slug}',[PublicNewsController::class,'show'])->name('news.public.show');
+
+use Illuminate\Support\Facades\Route;
+use Modules\News\Http\Controllers\NewsController;
+use Modules\News\Http\Controllers\PublicNewsController;
+
+Route::middleware(['auth', 'verified'])->prefix('administration/news')->name('administration.news.')->group(function (): void {
+ Route::resource('/', NewsController::class)->parameters(['' => 'news_article'])->except('show');
+});
+Route::get('/news', [PublicNewsController::class, 'index'])->name('news.public.index');
+Route::get('/news/{slug}', [PublicNewsController::class, 'show'])->name('news.public.show');
diff --git a/Modules/News/Tests/Feature/ModuleBootTest.php b/Modules/News/Tests/Feature/ModuleBootTest.php
index 443ef12..dbe1c8a 100644
--- a/Modules/News/Tests/Feature/ModuleBootTest.php
+++ b/Modules/News/Tests/Feature/ModuleBootTest.php
@@ -1,6 +1,9 @@
'Newsletter'];
diff --git a/Modules/Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php b/Modules/Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php
index 6253e31..613856a 100644
--- a/Modules/Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php
+++ b/Modules/Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php
@@ -1,3 +1,57 @@
id();$t->string('name');$t->text('description')->nullable();$t->boolean('is_active')->default(true);$t->timestamps();}); Schema::create('newsletter_subscribers',function(Blueprint $t): void{$t->id();$t->string('email')->unique();$t->string('name')->nullable();$t->string('status')->default('pending')->index();$t->string('confirmation_token')->nullable()->unique();$t->timestamp('confirmed_at')->nullable();$t->timestamp('unsubscribed_at')->nullable();$t->json('metadata')->nullable();$t->timestamps();}); Schema::create('newsletter_list_subscriber',function(Blueprint $t): void{$t->foreignId('list_id')->constrained('newsletter_lists')->cascadeOnDelete();$t->foreignId('subscriber_id')->constrained('newsletter_subscribers')->cascadeOnDelete();$t->primary(['list_id','subscriber_id']);}); Schema::create('newsletter_campaigns',function(Blueprint $t): void{$t->id();$t->foreignId('list_id')->nullable()->constrained('newsletter_lists')->nullOnDelete();$t->string('name');$t->string('subject');$t->longText('content')->nullable();$t->string('status')->default('draft')->index();$t->timestamp('scheduled_at')->nullable();$t->timestamp('sent_at')->nullable();$t->unsignedInteger('sent_count')->default(0);$t->unsignedInteger('open_count')->default(0);$t->unsignedInteger('click_count')->default(0);$t->timestamps();}); } public function down(): void { Schema::dropIfExists('newsletter_campaigns');Schema::dropIfExists('newsletter_list_subscriber');Schema::dropIfExists('newsletter_subscribers');Schema::dropIfExists('newsletter_lists'); } };
+
+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('newsletter_lists', function (Blueprint $t): void {
+ $t->id();
+ $t->string('name');
+ $t->text('description')->nullable();
+ $t->boolean('is_active')->default(true);
+ $t->timestamps();
+ });
+ Schema::create('newsletter_subscribers', function (Blueprint $t): void {
+ $t->id();
+ $t->string('email')->unique();
+ $t->string('name')->nullable();
+ $t->string('status')->default('pending')->index();
+ $t->string('confirmation_token')->nullable()->unique();
+ $t->timestamp('confirmed_at')->nullable();
+ $t->timestamp('unsubscribed_at')->nullable();
+ $t->json('metadata')->nullable();
+ $t->timestamps();
+ });
+ Schema::create('newsletter_list_subscriber', function (Blueprint $t): void {
+ $t->foreignId('list_id')->constrained('newsletter_lists')->cascadeOnDelete();
+ $t->foreignId('subscriber_id')->constrained('newsletter_subscribers')->cascadeOnDelete();
+ $t->primary(['list_id', 'subscriber_id']);
+ });
+ Schema::create('newsletter_campaigns', function (Blueprint $t): void {
+ $t->id();
+ $t->foreignId('list_id')->nullable()->constrained('newsletter_lists')->nullOnDelete();
+ $t->string('name');
+ $t->string('subject');
+ $t->longText('content')->nullable();
+ $t->string('status')->default('draft')->index();
+ $t->timestamp('scheduled_at')->nullable();
+ $t->timestamp('sent_at')->nullable();
+ $t->unsignedInteger('sent_count')->default(0);
+ $t->unsignedInteger('open_count')->default(0);
+ $t->unsignedInteger('click_count')->default(0);
+ $t->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('newsletter_campaigns');
+ Schema::dropIfExists('newsletter_list_subscriber');
+ Schema::dropIfExists('newsletter_subscribers');
+ Schema::dropIfExists('newsletter_lists');
+ }
+};
diff --git a/Modules/Newsletter/Http/Controllers/NewsletterController.php b/Modules/Newsletter/Http/Controllers/NewsletterController.php
index b815a5b..0522ce1 100644
--- a/Modules/Newsletter/Http/Controllers/NewsletterController.php
+++ b/Modules/Newsletter/Http/Controllers/NewsletterController.php
@@ -1,3 +1,41 @@
Subscriber::latest()->paginate(20),'campaigns'=>Campaign::latest()->limit(10)->get(),'lists'=>MailingList::orderBy('name')->get()]); } public function storeSubscriber(Request $r): RedirectResponse { $d=$r->validate(['email'=>['required','email','unique:newsletter_subscribers,email'],'name'=>['nullable','string','max:255']]); Subscriber::create($d+['status'=>'confirmed','confirmation_token'=>Str::random(48),'confirmed_at'=>now()]); return back()->with('status','Subscriber added.'); } public function storeCampaign(Request $r): RedirectResponse { Campaign::create($r->validate(['list_id'=>['nullable','exists:newsletter_lists,id'],'name'=>['required','string','max:255'],'subject'=>['required','string','max:255'],'content'=>['nullable','string'],'status'=>['required','in:draft,scheduled,sent'],'scheduled_at'=>['nullable','date']])); return back()->with('status','Campaign created.'); } public function destroySubscriber(Subscriber $subscriber): RedirectResponse { $subscriber->delete(); return back()->with('status','Subscriber deleted.'); } }
+
+namespace Modules\Newsletter\Http\Controllers;
+
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use Illuminate\View\View;
+use Modules\Newsletter\Models\Campaign;
+use Modules\Newsletter\Models\MailingList;
+use Modules\Newsletter\Models\Subscriber;
+
+final class NewsletterController
+{
+ public function index(): View
+ {
+ return view('newsletter::admin.index', ['subscribers' => Subscriber::latest()->paginate(20), 'campaigns' => Campaign::latest()->limit(10)->get(), 'lists' => MailingList::orderBy('name')->get()]);
+ }
+
+ public function storeSubscriber(Request $r): RedirectResponse
+ {
+ $d = $r->validate(['email' => ['required', 'email', 'unique:newsletter_subscribers,email'], 'name' => ['nullable', 'string', 'max:255']]);
+ Subscriber::create($d + ['status' => 'confirmed', 'confirmation_token' => Str::random(48), 'confirmed_at' => now()]);
+
+ return back()->with('status', 'Subscriber added.');
+ }
+
+ public function storeCampaign(Request $r): RedirectResponse
+ {
+ Campaign::create($r->validate(['list_id' => ['nullable', 'exists:newsletter_lists,id'], 'name' => ['required', 'string', 'max:255'], 'subject' => ['required', 'string', 'max:255'], 'content' => ['nullable', 'string'], 'status' => ['required', 'in:draft,scheduled,sent'], 'scheduled_at' => ['nullable', 'date']]));
+
+ return back()->with('status', 'Campaign created.');
+ }
+
+ public function destroySubscriber(Subscriber $subscriber): RedirectResponse
+ {
+ $subscriber->delete();
+
+ return back()->with('status', 'Subscriber deleted.');
+ }
+}
diff --git a/Modules/Newsletter/Models/Campaign.php b/Modules/Newsletter/Models/Campaign.php
index dd60a87..924a6a8 100644
--- a/Modules/Newsletter/Models/Campaign.php
+++ b/Modules/Newsletter/Models/Campaign.php
@@ -1,3 +1,17 @@
'datetime','sent_at'=>'datetime']; } }
+
+namespace Modules\Newsletter\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+final class Campaign extends Model
+{
+ protected $table = 'newsletter_campaigns';
+
+ protected $fillable = ['list_id', 'name', 'subject', 'content', 'status', 'scheduled_at', 'sent_at', 'sent_count', 'open_count', 'click_count'];
+
+ protected function casts(): array
+ {
+ return ['scheduled_at' => 'datetime', 'sent_at' => 'datetime'];
+ }
+}
diff --git a/Modules/Newsletter/Models/MailingList.php b/Modules/Newsletter/Models/MailingList.php
index 8df61d6..786b233 100644
--- a/Modules/Newsletter/Models/MailingList.php
+++ b/Modules/Newsletter/Models/MailingList.php
@@ -1,3 +1,17 @@
'boolean']; } }
+
+namespace Modules\Newsletter\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+final class MailingList extends Model
+{
+ protected $table = 'newsletter_lists';
+
+ protected $fillable = ['name', 'description', 'is_active'];
+
+ protected function casts(): array
+ {
+ return ['is_active' => 'boolean'];
+ }
+}
diff --git a/Modules/Newsletter/Models/Subscriber.php b/Modules/Newsletter/Models/Subscriber.php
index 9ac6508..9d0bd30 100644
--- a/Modules/Newsletter/Models/Subscriber.php
+++ b/Modules/Newsletter/Models/Subscriber.php
@@ -1,3 +1,17 @@
'datetime','unsubscribed_at'=>'datetime','metadata'=>'array']; } }
+
+namespace Modules\Newsletter\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+final class Subscriber extends Model
+{
+ protected $table = 'newsletter_subscribers';
+
+ protected $fillable = ['email', 'name', 'status', 'confirmation_token', 'confirmed_at', 'unsubscribed_at', 'metadata'];
+
+ protected function casts(): array
+ {
+ return ['confirmed_at' => 'datetime', 'unsubscribed_at' => 'datetime', 'metadata' => 'array'];
+ }
+}
diff --git a/Modules/Newsletter/Providers/NewsletterServiceProvider.php b/Modules/Newsletter/Providers/NewsletterServiceProvider.php
index 07ed786..d10726f 100644
--- a/Modules/Newsletter/Providers/NewsletterServiceProvider.php
+++ b/Modules/Newsletter/Providers/NewsletterServiceProvider.php
@@ -1,6 +1,9 @@
app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Newsletter', 'Config/config.php'), 'newsletter');
}
+
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Newsletter', 'Database/Migrations'));
diff --git a/Modules/Newsletter/Providers/RouteServiceProvider.php b/Modules/Newsletter/Providers/RouteServiceProvider.php
index 2ee035c..47472fc 100644
--- a/Modules/Newsletter/Providers/RouteServiceProvider.php
+++ b/Modules/Newsletter/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
prefix('administration/newsletter')->name('administration.newsletter.')->group(function(): void { Route::get('/',[NewsletterController::class,'index'])->name('index'); Route::post('/subscribers',[NewsletterController::class,'storeSubscriber'])->name('subscribers.store'); Route::delete('/subscribers/{subscriber}',[NewsletterController::class,'destroySubscriber'])->name('subscribers.destroy'); Route::post('/campaigns',[NewsletterController::class,'storeCampaign'])->name('campaigns.store'); });
+
+use Illuminate\Support\Facades\Route;
+use Modules\Newsletter\Http\Controllers\NewsletterController;
+
+Route::middleware(['auth', 'verified'])->prefix('administration/newsletter')->name('administration.newsletter.')->group(function (): void {
+ Route::get('/', [NewsletterController::class, 'index'])->name('index');
+ Route::post('/subscribers', [NewsletterController::class, 'storeSubscriber'])->name('subscribers.store');
+ Route::delete('/subscribers/{subscriber}', [NewsletterController::class, 'destroySubscriber'])->name('subscribers.destroy');
+ Route::post('/campaigns', [NewsletterController::class, 'storeCampaign'])->name('campaigns.store');
+});
diff --git a/Modules/Newsletter/Tests/Feature/ModuleBootTest.php b/Modules/Newsletter/Tests/Feature/ModuleBootTest.php
index 863df14..d76ba2b 100644
--- a/Modules/Newsletter/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Newsletter/Tests/Feature/ModuleBootTest.php
@@ -1,6 +1,9 @@
string('template')->default('default'); $table->timestamp('expires_at')->nullable(); $table->string('preview_token',64)->nullable()->unique(); $table->foreignId('locked_by')->nullable()->constrained('users')->nullOnDelete(); $table->timestamp('locked_at')->nullable(); }); Schema::create('page_revisions', function(Blueprint $table): void { $table->id(); $table->foreignId('page_id')->constrained('pages')->cascadeOnDelete(); $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); $table->string('title'); $table->longText('content')->nullable(); $table->json('snapshot'); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('page_revisions'); Schema::table('pages', function(Blueprint $table): void { $table->dropConstrainedForeignId('locked_by'); $table->dropColumn(['template','expires_at','preview_token','locked_at']); }); } };
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ public function up(): void
+ {
+ Schema::table('pages', function (Blueprint $table): void {
+ $table->string('template')->default('default');
+ $table->timestamp('expires_at')->nullable();
+ $table->string('preview_token', 64)->nullable()->unique();
+ $table->foreignId('locked_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamp('locked_at')->nullable();
+ });
+ Schema::create('page_revisions', function (Blueprint $table): void {
+ $table->id();
+ $table->foreignId('page_id')->constrained('pages')->cascadeOnDelete();
+ $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
+ $table->string('title');
+ $table->longText('content')->nullable();
+ $table->json('snapshot');
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('page_revisions');
+ Schema::table('pages', function (Blueprint $table): void {
+ $table->dropConstrainedForeignId('locked_by');
+ $table->dropColumn(['template', 'expires_at', 'preview_token', 'locked_at']);
+ });
+ }
+};
diff --git a/Modules/Pages/Http/Controllers/PageController.php b/Modules/Pages/Http/Controllers/PageController.php
index 8928a75..166f313 100644
--- a/Modules/Pages/Http/Controllers/PageController.php
+++ b/Modules/Pages/Http/Controllers/PageController.php
@@ -10,22 +10,55 @@ use Modules\Pages\Models\Page;
final class PageController extends Controller
{
- public function index(): View { return view('pages::admin.index', ['records' => Page::query()->latest()->paginate(20)]); }
- public function create(): View { return view('pages::admin.form'); }
- public function store(Request $request): RedirectResponse { $data = $request->validate(['title' => ['required', 'string', 'max:255'],
+ public function index(): View
+ {
+ return view('pages::admin.index', ['records' => Page::query()->latest()->paginate(20)]);
+ }
+
+ public function create(): View
+ {
+ return view('pages::admin.form');
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255'],
'summary' => ['nullable', 'string'],
'content' => ['nullable', 'string'],
'status' => ['required', 'string', 'max:255'],
'published_at' => ['nullable', 'date'],
- 'is_home' => ['sometimes', 'boolean']]); $data['is_home'] = $request->boolean('is_home'); Page::create($data); return redirect()->route('pages.index')->with('status', 'Page created.'); }
- public function edit(Page $page): View { return view('pages::admin.form', ['record' => $page]); }
- public function update(Request $request, Page $page): RedirectResponse { $data = $request->validate(['title' => ['required', 'string', 'max:255'],
+ 'is_home' => ['sometimes', 'boolean']]);
+ $data['is_home'] = $request->boolean('is_home');
+ Page::create($data);
+
+ return redirect()->route('pages.index')->with('status', 'Page created.');
+ }
+
+ public function edit(Page $page): View
+ {
+ return view('pages::admin.form', ['record' => $page]);
+ }
+
+ public function update(Request $request, Page $page): RedirectResponse
+ {
+ $data = $request->validate(['title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255'],
'summary' => ['nullable', 'string'],
'content' => ['nullable', 'string'],
'status' => ['required', 'string', 'max:255'],
'published_at' => ['nullable', 'date'],
- 'is_home' => ['sometimes', 'boolean']]); $data['is_home'] = $request->boolean('is_home'); $page->update($data); return redirect()->route('pages.index')->with('status', 'Page updated.'); }
- public function destroy(Page $page): RedirectResponse { $page->delete(); return back()->with('status', 'Page deleted.'); }
+ 'is_home' => ['sometimes', 'boolean']]);
+ $data['is_home'] = $request->boolean('is_home');
+ $page->update($data);
+
+ return redirect()->route('pages.index')->with('status', 'Page updated.');
+ }
+
+ public function destroy(Page $page): RedirectResponse
+ {
+ $page->delete();
+
+ return back()->with('status', 'Page deleted.');
+ }
}
diff --git a/Modules/Pages/Http/Controllers/PublicPageController.php b/Modules/Pages/Http/Controllers/PublicPageController.php
index fd3e0df..c9e531f 100644
--- a/Modules/Pages/Http/Controllers/PublicPageController.php
+++ b/Modules/Pages/Http/Controllers/PublicPageController.php
@@ -1,6 +1,17 @@
where('slug',$slug)->where('status','published')->where(fn($q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->firstOrFail(); return view('pages::public.show',compact('page')); } }
+
+final class PublicPageController extends Controller
+{
+ public function show(string $slug): View
+ {
+ $page = Page::query()->where('slug', $slug)->where('status', 'published')->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))->firstOrFail();
+
+ return view('pages::public.show', compact('page'));
+ }
+}
diff --git a/Modules/Pages/Models/Page.php b/Modules/Pages/Models/Page.php
index 28a2084..84f31e9 100644
--- a/Modules/Pages/Models/Page.php
+++ b/Modules/Pages/Models/Page.php
@@ -5,19 +5,23 @@ namespace Modules\Pages\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
-use Modules\Workflow\Concerns\HasWorkflow;
use Modules\Localization\Concerns\HasTranslations;
use Modules\Sites\Concerns\BelongsToSites;
+use Modules\Workflow\Concerns\HasWorkflow;
final class Page extends Model
{
use BelongsToSites, HasFactory, HasTranslations, HasWorkflow, SoftDeletes;
- protected $table = 'pages';
- protected $fillable = ['title', 'slug', 'summary', 'content', 'status', 'published_at', 'is_home'];
-
- protected function casts(): array { return [
- 'published_at' => 'datetime',
- 'is_home' => 'boolean'
- ]; }
+ protected $table = 'pages';
+
+ protected $fillable = ['title', 'slug', 'summary', 'content', 'status', 'published_at', 'is_home'];
+
+ protected function casts(): array
+ {
+ return [
+ 'published_at' => 'datetime',
+ 'is_home' => 'boolean',
+ ];
+ }
}
diff --git a/Modules/Pages/Routes/web.php b/Modules/Pages/Routes/web.php
index c3eef5b..bef1fcf 100644
--- a/Modules/Pages/Routes/web.php
+++ b/Modules/Pages/Routes/web.php
@@ -2,9 +2,10 @@
use Illuminate\Support\Facades\Route;
use Modules\Pages\Http\Controllers\PageController;
+use Modules\Pages\Http\Controllers\PublicPageController;
Route::middleware(['auth', 'verified'])->prefix('administration/pages')->name('administration.pages.')->group(function (): void {
Route::resource('/', PageController::class)->parameters(['' => 'page'])->except('show');
});
-Route::get('/pages/{slug}', [Modules\Pages\Http\Controllers\PublicPageController::class, 'show'])->name('pages.public.show');
+Route::get('/pages/{slug}', [PublicPageController::class, 'show'])->name('pages.public.show');
diff --git a/Modules/Pages/Tests/Feature/ModuleBootTest.php b/Modules/Pages/Tests/Feature/ModuleBootTest.php
index 2dd98b2..2a22cc4 100644
--- a/Modules/Pages/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Pages/Tests/Feature/ModuleBootTest.php
@@ -2,4 +2,6 @@
use Illuminate\Support\Facades\Route;
-it('registers the Pages administration route', function (): void { expect(Route::has('administration.pages.index'))->toBeTrue(); });
+it('registers the Pages administration route', function (): void {
+ expect(Route::has('administration.pages.index'))->toBeTrue();
+});
diff --git a/Modules/Products/Database/Migrations/2026_07_24_105054_create_products_table.php b/Modules/Products/Database/Migrations/2026_07_24_105054_create_products_table.php
index 1d27a88..a009d1a 100644
--- a/Modules/Products/Database/Migrations/2026_07_24_105054_create_products_table.php
+++ b/Modules/Products/Database/Migrations/2026_07_24_105054_create_products_table.php
@@ -4,7 +4,20 @@ 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('products', function (Blueprint $table): void { $table->id(); $table->string('name'); $table->boolean('is_active')->default(true); $table->timestamps(); }); }
- public function down(): void { Schema::dropIfExists('products'); }
+return new class extends Migration
+{
+ public function up(): void
+ {
+ Schema::create('products', function (Blueprint $table): void {
+ $table->id();
+ $table->string('name');
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('products');
+ }
};
diff --git a/Modules/Products/Http/Controllers/ProductController.php b/Modules/Products/Http/Controllers/ProductController.php
index dd70055..c3ccf6f 100644
--- a/Modules/Products/Http/Controllers/ProductController.php
+++ b/Modules/Products/Http/Controllers/ProductController.php
@@ -17,6 +17,7 @@ final class ProductController
public function store(StoreProductRequest $request): RedirectResponse
{
Product::query()->create($request->validated());
+
return back()->with('status', 'Product created.');
}
}
diff --git a/Modules/Products/Http/Requests/StoreProductRequest.php b/Modules/Products/Http/Requests/StoreProductRequest.php
index ba5e5dc..e41dc6c 100644
--- a/Modules/Products/Http/Requests/StoreProductRequest.php
+++ b/Modules/Products/Http/Requests/StoreProductRequest.php
@@ -6,6 +6,13 @@ use Illuminate\Foundation\Http\FormRequest;
final class StoreProductRequest extends FormRequest
{
- public function authorize(): bool { return $this->user() !== null; }
- public function rules(): array { return ['name' => ['required', 'string', 'max:255'], 'is_active' => ['sometimes', 'boolean']]; }
+ public function authorize(): bool
+ {
+ return $this->user() !== null;
+ }
+
+ public function rules(): array
+ {
+ return ['name' => ['required', 'string', 'max:255'], 'is_active' => ['sometimes', 'boolean']];
+ }
}
diff --git a/Modules/Products/Tests/Feature/ProductCrudTest.php b/Modules/Products/Tests/Feature/ProductCrudTest.php
index 523eb1d..f596dc9 100644
--- a/Modules/Products/Tests/Feature/ProductCrudTest.php
+++ b/Modules/Products/Tests/Feature/ProductCrudTest.php
@@ -2,4 +2,6 @@
use Modules\Products\Models\Product;
-it('can create a Product', function (): void { expect(Product::query()->create(['name' => 'Example']))->toBeInstanceOf(Product::class); });
+it('can create a Product', function (): void {
+ expect(Product::query()->create(['name' => 'Example']))->toBeInstanceOf(Product::class);
+});
diff --git a/Modules/Products/Tests/Feature/ProductsModuleBootTest.php b/Modules/Products/Tests/Feature/ProductsModuleBootTest.php
index 57b18f9..f89049e 100644
--- a/Modules/Products/Tests/Feature/ProductsModuleBootTest.php
+++ b/Modules/Products/Tests/Feature/ProductsModuleBootTest.php
@@ -1,5 +1,7 @@
providerIsLoaded(Modules\Products\Providers\ProductsServiceProvider::class))->toBeTrue();
+ expect(app()->providerIsLoaded(ProductsServiceProvider::class))->toBeTrue();
});
diff --git a/Modules/Redirects/Database/Migrations/2026_07_24_000300_create_redirects_table.php b/Modules/Redirects/Database/Migrations/2026_07_24_000300_create_redirects_table.php
index 9708c38..5635594 100644
--- a/Modules/Redirects/Database/Migrations/2026_07_24_000300_create_redirects_table.php
+++ b/Modules/Redirects/Database/Migrations/2026_07_24_000300_create_redirects_table.php
@@ -4,12 +4,23 @@ 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('redirects', function (Blueprint $table): void { $table->id();
+return new class extends Migration
+{
+ public function up(): void
+ {
+ Schema::create('redirects', function (Blueprint $table): void {
+ $table->id();
$table->string('source');
$table->string('destination');
$table->unsignedInteger('status_code')->default(301);
$table->boolean('enabled')->default(true);
- $table->timestamps(); $table->softDeletes(); }); }
- public function down(): void { Schema::dropIfExists('redirects'); }
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('redirects');
+ }
};
diff --git a/Modules/Redirects/Http/Controllers/RedirectController.php b/Modules/Redirects/Http/Controllers/RedirectController.php
index 9b45119..d396875 100644
--- a/Modules/Redirects/Http/Controllers/RedirectController.php
+++ b/Modules/Redirects/Http/Controllers/RedirectController.php
@@ -10,16 +10,49 @@ use Modules\Redirects\Models\Redirect;
final class RedirectController extends Controller
{
- public function index(): View { return view('redirects::admin.index', ['records' => Redirect::query()->latest()->paginate(20)]); }
- public function create(): View { return view('redirects::admin.form'); }
- public function store(Request $request): RedirectResponse { $data = $request->validate(['source' => ['required', 'string', 'max:255'],
+ public function index(): View
+ {
+ return view('redirects::admin.index', ['records' => Redirect::query()->latest()->paginate(20)]);
+ }
+
+ public function create(): View
+ {
+ return view('redirects::admin.form');
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['source' => ['required', 'string', 'max:255'],
'destination' => ['required', 'string', 'max:255'],
'status_code' => ['nullable', 'integer', 'min:0'],
- 'enabled' => ['sometimes', 'boolean']]); $data['enabled'] = $request->boolean('enabled'); Redirect::create($data); return redirect()->route('redirects.index')->with('status', 'Redirect created.'); }
- public function edit(Redirect $redirect): View { return view('redirects::admin.form', ['record' => $redirect]); }
- public function update(Request $request, Redirect $redirect): RedirectResponse { $data = $request->validate(['source' => ['required', 'string', 'max:255'],
+ 'enabled' => ['sometimes', 'boolean']]);
+ $data['enabled'] = $request->boolean('enabled');
+ Redirect::create($data);
+
+ return redirect()->route('redirects.index')->with('status', 'Redirect created.');
+ }
+
+ public function edit(Redirect $redirect): View
+ {
+ return view('redirects::admin.form', ['record' => $redirect]);
+ }
+
+ public function update(Request $request, Redirect $redirect): RedirectResponse
+ {
+ $data = $request->validate(['source' => ['required', 'string', 'max:255'],
'destination' => ['required', 'string', 'max:255'],
'status_code' => ['nullable', 'integer', 'min:0'],
- 'enabled' => ['sometimes', 'boolean']]); $data['enabled'] = $request->boolean('enabled'); $redirect->update($data); return redirect()->route('redirects.index')->with('status', 'Redirect updated.'); }
- public function destroy(Redirect $redirect): RedirectResponse { $redirect->delete(); return back()->with('status', 'Redirect deleted.'); }
+ 'enabled' => ['sometimes', 'boolean']]);
+ $data['enabled'] = $request->boolean('enabled');
+ $redirect->update($data);
+
+ return redirect()->route('redirects.index')->with('status', 'Redirect updated.');
+ }
+
+ public function destroy(Redirect $redirect): RedirectResponse
+ {
+ $redirect->delete();
+
+ return back()->with('status', 'Redirect deleted.');
+ }
}
diff --git a/Modules/Redirects/Models/Redirect.php b/Modules/Redirects/Models/Redirect.php
index cb2b84f..ba4db4e 100644
--- a/Modules/Redirects/Models/Redirect.php
+++ b/Modules/Redirects/Models/Redirect.php
@@ -9,12 +9,16 @@ use Illuminate\Database\Eloquent\SoftDeletes;
final class Redirect extends Model
{
use HasFactory, SoftDeletes;
- protected $table = 'redirects';
- protected $fillable = ['source', 'destination', 'status_code', 'enabled'];
-
- protected function casts(): array { return [
- 'status_code' => 'integer',
- 'enabled' => 'boolean'
- ]; }
+ protected $table = 'redirects';
+
+ protected $fillable = ['source', 'destination', 'status_code', 'enabled'];
+
+ protected function casts(): array
+ {
+ return [
+ 'status_code' => 'integer',
+ 'enabled' => 'boolean',
+ ];
+ }
}
diff --git a/Modules/Redirects/Support/ModuleInfo.php b/Modules/Redirects/Support/ModuleInfo.php
index 3f265d0..a226dac 100644
--- a/Modules/Redirects/Support/ModuleInfo.php
+++ b/Modules/Redirects/Support/ModuleInfo.php
@@ -2,4 +2,15 @@
namespace Modules\Redirects\Support;
-final class ModuleInfo { public static function name(): string { return 'Redirects'; } public static function nameLower(): string { return 'redirects'; } }
+final class ModuleInfo
+{
+ public static function name(): string
+ {
+ return 'Redirects';
+ }
+
+ public static function nameLower(): string
+ {
+ return 'redirects';
+ }
+}
diff --git a/Modules/Redirects/Tests/Feature/ModuleBootTest.php b/Modules/Redirects/Tests/Feature/ModuleBootTest.php
index 9c0f403..31baa84 100644
--- a/Modules/Redirects/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Redirects/Tests/Feature/ModuleBootTest.php
@@ -2,4 +2,6 @@
use Illuminate\Support\Facades\Route;
-it('registers the Redirects administration route', function (): void { expect(Route::has('administration.redirects.index'))->toBeTrue(); });
+it('registers the Redirects administration route', function (): void {
+ expect(Route::has('administration.redirects.index'))->toBeTrue();
+});
diff --git a/Modules/Roles/Http/Controllers/RoleController.php b/Modules/Roles/Http/Controllers/RoleController.php
index f56c8d0..d5b0e16 100644
--- a/Modules/Roles/Http/Controllers/RoleController.php
+++ b/Modules/Roles/Http/Controllers/RoleController.php
@@ -10,11 +10,49 @@ use Spatie\Permission\Models\Role;
final class RoleController extends Controller
{
- public function index() { return view('roles::index',['roles'=>Role::withCount(['users','permissions'])->orderBy('name')->get()]); }
- public function create() { return view('roles::form',['role'=>new Role(['guard_name'=>'web']),'permissions'=>Permission::orderBy('name')->get()]); }
- public function store(Request $request) { $data=$this->validated($request); $role=Role::create(['name'=>$data['name'],'guard_name'=>'web']); $role->syncPermissions($data['permissions']??[]); return redirect()->route('administration.roles.index')->with('status','Role created.'); }
- public function edit(Role $role) { return view('roles::form',['role'=>$role->load('permissions'),'permissions'=>Permission::orderBy('name')->get()]); }
- public function update(Request $request, Role $role) { $data=$this->validated($request,$role); $role->update(['name'=>$data['name']]); $role->syncPermissions($data['permissions']??[]); return redirect()->route('administration.roles.index')->with('status','Role updated.'); }
- public function destroy(Role $role) { abort_if($role->name==='admin',422,'The administrator role cannot be deleted.'); $role->delete(); return back()->with('status','Role deleted.'); }
- private function validated(Request $request, ?Role $role=null): array { return $request->validate(['name'=>['required','string','max:100',Rule::unique('roles','name')->ignore($role)],'permissions'=>['array'],'permissions.*'=>['string','exists:permissions,name']]); }
+ public function index()
+ {
+ return view('roles::index', ['roles' => Role::withCount(['users', 'permissions'])->orderBy('name')->get()]);
+ }
+
+ public function create()
+ {
+ return view('roles::form', ['role' => new Role(['guard_name' => 'web']), 'permissions' => Permission::orderBy('name')->get()]);
+ }
+
+ public function store(Request $request)
+ {
+ $data = $this->validated($request);
+ $role = Role::create(['name' => $data['name'], 'guard_name' => 'web']);
+ $role->syncPermissions($data['permissions'] ?? []);
+
+ return redirect()->route('administration.roles.index')->with('status', 'Role created.');
+ }
+
+ public function edit(Role $role)
+ {
+ return view('roles::form', ['role' => $role->load('permissions'), 'permissions' => Permission::orderBy('name')->get()]);
+ }
+
+ public function update(Request $request, Role $role)
+ {
+ $data = $this->validated($request, $role);
+ $role->update(['name' => $data['name']]);
+ $role->syncPermissions($data['permissions'] ?? []);
+
+ return redirect()->route('administration.roles.index')->with('status', 'Role updated.');
+ }
+
+ public function destroy(Role $role)
+ {
+ abort_if($role->name === 'admin', 422, 'The administrator role cannot be deleted.');
+ $role->delete();
+
+ return back()->with('status', 'Role deleted.');
+ }
+
+ private function validated(Request $request, ?Role $role = null): array
+ {
+ return $request->validate(['name' => ['required', 'string', 'max:100', Rule::unique('roles', 'name')->ignore($role)], 'permissions' => ['array'], 'permissions.*' => ['string', 'exists:permissions,name']]);
+ }
}
diff --git a/Modules/Roles/Routes/web.php b/Modules/Roles/Routes/web.php
index fd675d0..f611599 100644
--- a/Modules/Roles/Routes/web.php
+++ b/Modules/Roles/Routes/web.php
@@ -1,3 +1,8 @@
prefix('administration/roles')->name('administration.roles.')->group(function (): void { Route::resource('', RoleController::class)->parameters([''=>'role'])->except(['show']); });
+
+use Illuminate\Support\Facades\Route;
+use Modules\Roles\Http\Controllers\RoleController;
+
+Route::middleware(['auth', 'verified'])->prefix('administration/roles')->name('administration.roles.')->group(function (): void {
+ Route::resource('', RoleController::class)->parameters(['' => 'role'])->except(['show']);
+});
diff --git a/Modules/Roles/Support/ModuleInfo.php b/Modules/Roles/Support/ModuleInfo.php
index bca87db..339da0a 100644
--- a/Modules/Roles/Support/ModuleInfo.php
+++ b/Modules/Roles/Support/ModuleInfo.php
@@ -4,6 +4,13 @@ namespace Modules\Roles\Support;
final class ModuleInfo
{
- public static function name(): string { return 'Roles'; }
- public static function nameLower(): string { return 'roles'; }
+ public static function name(): string
+ {
+ return 'Roles';
+ }
+
+ public static function nameLower(): string
+ {
+ return 'roles';
+ }
}
diff --git a/Modules/Search/Database/Migrations/2026_07_23_000500_create_search_index_table.php b/Modules/Search/Database/Migrations/2026_07_23_000500_create_search_index_table.php
index 749990e..ca700a7 100644
--- a/Modules/Search/Database/Migrations/2026_07_23_000500_create_search_index_table.php
+++ b/Modules/Search/Database/Migrations/2026_07_23_000500_create_search_index_table.php
@@ -1,3 +1,30 @@
id(); $table->morphs('searchable'); $table->string('title')->index(); $table->text('summary')->nullable(); $table->longText('content')->nullable(); $table->string('url')->nullable(); $table->json('metadata')->nullable(); $table->boolean('is_public')->default(true)->index(); $table->timestamp('published_at')->nullable()->index(); $table->timestamps(); $table->unique(['searchable_type','searchable_id']); }); } public function down(): void { Schema::dropIfExists('search_index'); } };
+
+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('search_index', function (Blueprint $table): void {
+ $table->id();
+ $table->morphs('searchable');
+ $table->string('title')->index();
+ $table->text('summary')->nullable();
+ $table->longText('content')->nullable();
+ $table->string('url')->nullable();
+ $table->json('metadata')->nullable();
+ $table->boolean('is_public')->default(true)->index();
+ $table->timestamp('published_at')->nullable()->index();
+ $table->timestamps();
+ $table->unique(['searchable_type', 'searchable_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('search_index');
+ }
+};
diff --git a/Modules/Search/Http/Controllers/SearchController.php b/Modules/Search/Http/Controllers/SearchController.php
index b74a619..9c591c6 100644
--- a/Modules/Search/Http/Controllers/SearchController.php
+++ b/Modules/Search/Http/Controllers/SearchController.php
@@ -1,4 +1,18 @@
query('q')); $results=DB::table('search_index')->when($q!== '',fn($query)=>$query->where(fn($w)=>$w->where('title','like',"%{$q}%")->orWhere('summary','like',"%{$q}%")->orWhere('content','like',"%{$q}%")))->latest('updated_at')->paginate(25)->withQueryString(); return view('search::index',compact('results','q')); } }
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+
+final class SearchController extends Controller
+{
+ public function index(Request $request)
+ {
+ $q = trim((string) $request->query('q'));
+ $results = DB::table('search_index')->when($q !== '', fn ($query) => $query->where(fn ($w) => $w->where('title', 'like', "%{$q}%")->orWhere('summary', 'like', "%{$q}%")->orWhere('content', 'like', "%{$q}%")))->latest('updated_at')->paginate(25)->withQueryString();
+
+ return view('search::index', compact('results', 'q'));
+ }
+}
diff --git a/Modules/Search/Routes/web.php b/Modules/Search/Routes/web.php
index 1a4abbc..ad6c1f7 100644
--- a/Modules/Search/Routes/web.php
+++ b/Modules/Search/Routes/web.php
@@ -1,3 +1,6 @@
get('administration/search',[SearchController::class,'index'])->name('administration.search.index');
+
+use Illuminate\Support\Facades\Route;
+use Modules\Search\Http\Controllers\SearchController;
+
+Route::middleware(['auth', 'verified'])->get('administration/search', [SearchController::class, 'index'])->name('administration.search.index');
diff --git a/Modules/Search/Support/ModuleInfo.php b/Modules/Search/Support/ModuleInfo.php
index 267c39a..98f93c7 100644
--- a/Modules/Search/Support/ModuleInfo.php
+++ b/Modules/Search/Support/ModuleInfo.php
@@ -4,6 +4,13 @@ namespace Modules\Search\Support;
final class ModuleInfo
{
- public static function name(): string { return 'Search'; }
- public static function nameLower(): string { return 'search'; }
+ public static function name(): string
+ {
+ return 'Search';
+ }
+
+ public static function nameLower(): string
+ {
+ return 'search';
+ }
}
diff --git a/Modules/Security/Config/config.php b/Modules/Security/Config/config.php
index 55cce9d..b4e3156 100644
--- a/Modules/Security/Config/config.php
+++ b/Modules/Security/Config/config.php
@@ -1,4 +1,5 @@
(bool) env('SECURITY_RECORD_AUTH_EVENTS', true),
'trusted_device_days' => (int) env('SECURITY_TRUSTED_DEVICE_DAYS', 30),
diff --git a/Modules/Security/Database/Migrations/2026_07_24_120000_create_security_tables.php b/Modules/Security/Database/Migrations/2026_07_24_120000_create_security_tables.php
index 9b8dd41..a393e34 100644
--- a/Modules/Security/Database/Migrations/2026_07_24_120000_create_security_tables.php
+++ b/Modules/Security/Database/Migrations/2026_07_24_120000_create_security_tables.php
@@ -1,45 +1,76 @@
id(); $table->string('name')->default('Default security policy');
+ $table->id();
+ $table->string('name')->default('Default security policy');
$table->unsignedSmallInteger('minimum_password_length')->default(12);
- $table->boolean('require_mixed_case')->default(true); $table->boolean('require_numbers')->default(true);
- $table->boolean('require_symbols')->default(true); $table->boolean('require_mfa')->default(false);
- $table->unsignedInteger('password_expiry_days')->nullable(); $table->unsignedSmallInteger('password_history')->default(5);
- $table->unsignedSmallInteger('max_failed_logins')->default(10); $table->unsignedSmallInteger('lockout_minutes')->default(15);
- $table->unsignedSmallInteger('session_idle_minutes')->default(120); $table->boolean('single_session')->default(false);
- $table->boolean('is_active')->default(true); $table->timestamps();
+ $table->boolean('require_mixed_case')->default(true);
+ $table->boolean('require_numbers')->default(true);
+ $table->boolean('require_symbols')->default(true);
+ $table->boolean('require_mfa')->default(false);
+ $table->unsignedInteger('password_expiry_days')->nullable();
+ $table->unsignedSmallInteger('password_history')->default(5);
+ $table->unsignedSmallInteger('max_failed_logins')->default(10);
+ $table->unsignedSmallInteger('lockout_minutes')->default(15);
+ $table->unsignedSmallInteger('session_idle_minutes')->default(120);
+ $table->boolean('single_session')->default(false);
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
});
Schema::create('security_login_events', function (Blueprint $table) {
- $table->id(); $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
- $table->string('email')->nullable()->index(); $table->string('event', 30)->index();
- $table->string('ip_address', 45)->nullable()->index(); $table->text('user_agent')->nullable();
- $table->string('session_id')->nullable()->index(); $table->string('reason')->nullable();
- $table->json('metadata')->nullable(); $table->timestamp('occurred_at')->useCurrent(); $table->timestamps();
+ $table->id();
+ $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
+ $table->string('email')->nullable()->index();
+ $table->string('event', 30)->index();
+ $table->string('ip_address', 45)->nullable()->index();
+ $table->text('user_agent')->nullable();
+ $table->string('session_id')->nullable()->index();
+ $table->string('reason')->nullable();
+ $table->json('metadata')->nullable();
+ $table->timestamp('occurred_at')->useCurrent();
+ $table->timestamps();
});
Schema::create('security_devices', function (Blueprint $table) {
- $table->id(); $table->foreignId('user_id')->constrained()->cascadeOnDelete();
- $table->string('name'); $table->string('fingerprint', 64)->index(); $table->string('ip_address', 45)->nullable();
- $table->text('user_agent')->nullable(); $table->timestamp('trusted_until')->nullable();
- $table->timestamp('last_seen_at')->nullable(); $table->timestamp('revoked_at')->nullable(); $table->timestamps();
- $table->unique(['user_id','fingerprint']);
+ $table->id();
+ $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+ $table->string('name');
+ $table->string('fingerprint', 64)->index();
+ $table->string('ip_address', 45)->nullable();
+ $table->text('user_agent')->nullable();
+ $table->timestamp('trusted_until')->nullable();
+ $table->timestamp('last_seen_at')->nullable();
+ $table->timestamp('revoked_at')->nullable();
+ $table->timestamps();
+ $table->unique(['user_id', 'fingerprint']);
});
Schema::create('security_ip_rules', function (Blueprint $table) {
- $table->id(); $table->string('name'); $table->string('type', 10)->default('deny');
- $table->string('address', 64); $table->text('description')->nullable();
- $table->boolean('applies_to_admin')->default(true); $table->boolean('applies_to_login')->default(true);
- $table->boolean('is_active')->default(true); $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
- $table->timestamps(); $table->index(['type','is_active']);
+ $table->id();
+ $table->string('name');
+ $table->string('type', 10)->default('deny');
+ $table->string('address', 64);
+ $table->text('description')->nullable();
+ $table->boolean('applies_to_admin')->default(true);
+ $table->boolean('applies_to_login')->default(true);
+ $table->boolean('is_active')->default(true);
+ $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+ $table->index(['type', 'is_active']);
});
}
+
public function down(): void
{
- Schema::dropIfExists('security_ip_rules'); Schema::dropIfExists('security_devices');
- Schema::dropIfExists('security_login_events'); Schema::dropIfExists('security_policies');
+ Schema::dropIfExists('security_ip_rules');
+ Schema::dropIfExists('security_devices');
+ Schema::dropIfExists('security_login_events');
+ Schema::dropIfExists('security_policies');
}
};
diff --git a/Modules/Security/Http/Controllers/SecurityController.php b/Modules/Security/Http/Controllers/SecurityController.php
index 6277cbc..77e2633 100644
--- a/Modules/Security/Http/Controllers/SecurityController.php
+++ b/Modules/Security/Http/Controllers/SecurityController.php
@@ -1,5 +1,7 @@
SecurityPolicy::first(),'events'=>LoginEvent::latest('occurred_at')->paginate(25),'devices'=>TrustedDevice::whereNull('revoked_at')->latest('last_seen_at')->limit(20)->get(),'rules'=>IpRule::latest()->get(),'failed24h'=>LoginEvent::where('event','failed')->where('occurred_at','>=',now()->subDay())->count()]);
+ return view('security::admin.index', ['policy' => SecurityPolicy::first(), 'events' => LoginEvent::latest('occurred_at')->paginate(25), 'devices' => TrustedDevice::whereNull('revoked_at')->latest('last_seen_at')->limit(20)->get(), 'rules' => IpRule::latest()->get(), 'failed24h' => LoginEvent::where('event', 'failed')->where('occurred_at', '>=', now()->subDay())->count()]);
}
+
public function savePolicy(Request $request): RedirectResponse
{
- $data=$request->validate(['minimum_password_length'=>['required','integer','min:8','max:128'],'password_expiry_days'=>['nullable','integer','min:1'],'password_history'=>['required','integer','min:0','max:24'],'max_failed_logins'=>['required','integer','min:1','max:100'],'lockout_minutes'=>['required','integer','min:1','max:1440'],'session_idle_minutes'=>['required','integer','min:5','max:10080']]);
- foreach(['require_mixed_case','require_numbers','require_symbols','require_mfa','single_session'] as $key) $data[$key]=$request->boolean($key);
- SecurityPolicy::updateOrCreate(['id'=>SecurityPolicy::value('id')],$data+['name'=>'Default security policy','is_active'=>true]);
- return back()->with('status','Security policy updated.');
+ $data = $request->validate(['minimum_password_length' => ['required', 'integer', 'min:8', 'max:128'], 'password_expiry_days' => ['nullable', 'integer', 'min:1'], 'password_history' => ['required', 'integer', 'min:0', 'max:24'], 'max_failed_logins' => ['required', 'integer', 'min:1', 'max:100'], 'lockout_minutes' => ['required', 'integer', 'min:1', 'max:1440'], 'session_idle_minutes' => ['required', 'integer', 'min:5', 'max:10080']]);
+ foreach (['require_mixed_case', 'require_numbers', 'require_symbols', 'require_mfa', 'single_session'] as $key) {
+ $data[$key] = $request->boolean($key);
+ }
+ SecurityPolicy::updateOrCreate(['id' => SecurityPolicy::value('id')], $data + ['name' => 'Default security policy', 'is_active' => true]);
+
+ return back()->with('status', 'Security policy updated.');
}
+
public function storeRule(Request $request): RedirectResponse
{
- IpRule::create($request->validate(['name'=>['required','string','max:255'],'type'=>['required','in:allow,deny'],'address'=>['required','string','max:64'],'description'=>['nullable','string']])+['applies_to_admin'=>$request->boolean('applies_to_admin'),'applies_to_login'=>$request->boolean('applies_to_login'),'is_active'=>true,'created_by'=>$request->user()->id]);
- return back()->with('status','IP rule created.');
+ IpRule::create($request->validate(['name' => ['required', 'string', 'max:255'], 'type' => ['required', 'in:allow,deny'], 'address' => ['required', 'string', 'max:64'], 'description' => ['nullable', 'string']]) + ['applies_to_admin' => $request->boolean('applies_to_admin'), 'applies_to_login' => $request->boolean('applies_to_login'), 'is_active' => true, 'created_by' => $request->user()->id]);
+
+ return back()->with('status', 'IP rule created.');
+ }
+
+ public function destroyRule(IpRule $rule): RedirectResponse
+ {
+ $rule->delete();
+
+ return back()->with('status', 'IP rule deleted.');
+ }
+
+ public function revokeDevice(TrustedDevice $device): RedirectResponse
+ {
+ $device->update(['revoked_at' => now()]);
+
+ return back()->with('status', 'Device trust revoked.');
}
- public function destroyRule(IpRule $rule): RedirectResponse { $rule->delete(); return back()->with('status','IP rule deleted.'); }
- public function revokeDevice(TrustedDevice $device): RedirectResponse { $device->update(['revoked_at'=>now()]); return back()->with('status','Device trust revoked.'); }
}
diff --git a/Modules/Security/Http/Middleware/EnforceIpRestrictions.php b/Modules/Security/Http/Middleware/EnforceIpRestrictions.php
index d6129f1..e009e1e 100644
--- a/Modules/Security/Http/Middleware/EnforceIpRestrictions.php
+++ b/Modules/Security/Http/Middleware/EnforceIpRestrictions.php
@@ -1,15 +1,20 @@
rules->isBlocked((string)$request->ip(),$scope),403,'Access is not permitted from this IP address.');
+ abort_if($this->rules->isBlocked((string) $request->ip(), $scope), 403, 'Access is not permitted from this IP address.');
+
return $next($request);
}
}
diff --git a/Modules/Security/Listeners/RecordFailedLogin.php b/Modules/Security/Listeners/RecordFailedLogin.php
index d93b948..6bcd118 100644
--- a/Modules/Security/Listeners/RecordFailedLogin.php
+++ b/Modules/Security/Listeners/RecordFailedLogin.php
@@ -1,13 +1,19 @@
user:null;
- LoginEvent::create(['user_id'=>$user?->getAuthIdentifier(),'email'=>$user?->email ?? (property_exists($event,'credentials')?($event->credentials['email']??null):null),'event'=>'failed','ip_address'=>$request?->ip(),'user_agent'=>$request?->userAgent(),'session_id'=>$request?->hasSession()?$request->session()->getId():null,'occurred_at'=>now()]);
+ if (! config('security.record_authentication_events', true)) {
+ return;
+ }
+ $request = request();
+ $user = property_exists($event, 'user') ? $event->user : null;
+ LoginEvent::create(['user_id' => $user?->getAuthIdentifier(), 'email' => $user?->email ?? (property_exists($event, 'credentials') ? ($event->credentials['email'] ?? null) : null), 'event' => 'failed', 'ip_address' => $request?->ip(), 'user_agent' => $request?->userAgent(), 'session_id' => $request?->hasSession() ? $request->session()->getId() : null, 'occurred_at' => now()]);
}
}
diff --git a/Modules/Security/Listeners/RecordLogin.php b/Modules/Security/Listeners/RecordLogin.php
index 566499b..83face4 100644
--- a/Modules/Security/Listeners/RecordLogin.php
+++ b/Modules/Security/Listeners/RecordLogin.php
@@ -1,13 +1,19 @@
user:null;
- LoginEvent::create(['user_id'=>$user?->getAuthIdentifier(),'email'=>$user?->email ?? (property_exists($event,'credentials')?($event->credentials['email']??null):null),'event'=>'login','ip_address'=>$request?->ip(),'user_agent'=>$request?->userAgent(),'session_id'=>$request?->hasSession()?$request->session()->getId():null,'occurred_at'=>now()]);
+ if (! config('security.record_authentication_events', true)) {
+ return;
+ }
+ $request = request();
+ $user = property_exists($event, 'user') ? $event->user : null;
+ LoginEvent::create(['user_id' => $user?->getAuthIdentifier(), 'email' => $user?->email ?? (property_exists($event, 'credentials') ? ($event->credentials['email'] ?? null) : null), 'event' => 'login', 'ip_address' => $request?->ip(), 'user_agent' => $request?->userAgent(), 'session_id' => $request?->hasSession() ? $request->session()->getId() : null, 'occurred_at' => now()]);
}
}
diff --git a/Modules/Security/Listeners/RecordLogout.php b/Modules/Security/Listeners/RecordLogout.php
index d5ed8ab..f29feed 100644
--- a/Modules/Security/Listeners/RecordLogout.php
+++ b/Modules/Security/Listeners/RecordLogout.php
@@ -1,13 +1,19 @@
user:null;
- LoginEvent::create(['user_id'=>$user?->getAuthIdentifier(),'email'=>$user?->email ?? (property_exists($event,'credentials')?($event->credentials['email']??null):null),'event'=>'logout','ip_address'=>$request?->ip(),'user_agent'=>$request?->userAgent(),'session_id'=>$request?->hasSession()?$request->session()->getId():null,'occurred_at'=>now()]);
+ if (! config('security.record_authentication_events', true)) {
+ return;
+ }
+ $request = request();
+ $user = property_exists($event, 'user') ? $event->user : null;
+ LoginEvent::create(['user_id' => $user?->getAuthIdentifier(), 'email' => $user?->email ?? (property_exists($event, 'credentials') ? ($event->credentials['email'] ?? null) : null), 'event' => 'logout', 'ip_address' => $request?->ip(), 'user_agent' => $request?->userAgent(), 'session_id' => $request?->hasSession() ? $request->session()->getId() : null, 'occurred_at' => now()]);
}
}
diff --git a/Modules/Security/Models/IpRule.php b/Modules/Security/Models/IpRule.php
index 0136f27..2996dfe 100644
--- a/Modules/Security/Models/IpRule.php
+++ b/Modules/Security/Models/IpRule.php
@@ -1,9 +1,14 @@
'boolean','applies_to_login'=>'boolean','is_active'=>'boolean'];
+ protected $table = 'security_ip_rules';
+
+ protected $fillable = ['name', 'type', 'address', 'description', 'applies_to_admin', 'applies_to_login', 'is_active', 'created_by'];
+
+ protected $casts = ['applies_to_admin' => 'boolean', 'applies_to_login' => 'boolean', 'is_active' => 'boolean'];
}
diff --git a/Modules/Security/Models/LoginEvent.php b/Modules/Security/Models/LoginEvent.php
index 616ddce..1fce0ab 100644
--- a/Modules/Security/Models/LoginEvent.php
+++ b/Modules/Security/Models/LoginEvent.php
@@ -1,9 +1,14 @@
'array','occurred_at'=>'datetime'];
+ protected $table = 'security_login_events';
+
+ protected $fillable = ['user_id', 'email', 'event', 'ip_address', 'user_agent', 'session_id', 'reason', 'metadata', 'occurred_at'];
+
+ protected $casts = ['metadata' => 'array', 'occurred_at' => 'datetime'];
}
diff --git a/Modules/Security/Models/SecurityPolicy.php b/Modules/Security/Models/SecurityPolicy.php
index 5294683..e790c60 100644
--- a/Modules/Security/Models/SecurityPolicy.php
+++ b/Modules/Security/Models/SecurityPolicy.php
@@ -1,8 +1,12 @@
'boolean','require_numbers'=>'boolean','require_symbols'=>'boolean','require_mfa'=>'boolean','single_session'=>'boolean','is_active'=>'boolean'];
+ protected $fillable = ['name', 'minimum_password_length', 'require_mixed_case', 'require_numbers', 'require_symbols', 'require_mfa', 'password_expiry_days', 'password_history', 'max_failed_logins', 'lockout_minutes', 'session_idle_minutes', 'single_session', 'is_active'];
+
+ protected $casts = ['require_mixed_case' => 'boolean', 'require_numbers' => 'boolean', 'require_symbols' => 'boolean', 'require_mfa' => 'boolean', 'single_session' => 'boolean', 'is_active' => 'boolean'];
}
diff --git a/Modules/Security/Models/TrustedDevice.php b/Modules/Security/Models/TrustedDevice.php
index 5a4dad3..6777067 100644
--- a/Modules/Security/Models/TrustedDevice.php
+++ b/Modules/Security/Models/TrustedDevice.php
@@ -1,9 +1,14 @@
'datetime','last_seen_at'=>'datetime','revoked_at'=>'datetime'];
+ protected $table = 'security_devices';
+
+ protected $fillable = ['user_id', 'name', 'fingerprint', 'ip_address', 'user_agent', 'trusted_until', 'last_seen_at', 'revoked_at'];
+
+ protected $casts = ['trusted_until' => 'datetime', 'last_seen_at' => 'datetime', 'revoked_at' => 'datetime'];
}
diff --git a/Modules/Security/Providers/RouteServiceProvider.php b/Modules/Security/Providers/RouteServiceProvider.php
index baca617..ed3f4c3 100644
--- a/Modules/Security/Providers/RouteServiceProvider.php
+++ b/Modules/Security/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Security', 'Config/config.php'), 'security');
}
+
public function boot(Router $router): void
{
$this->loadMigrationsFrom(module_path('Security', 'Database/Migrations'));
diff --git a/Modules/Security/Routes/web.php b/Modules/Security/Routes/web.php
index ce84bd1..b92281e 100644
--- a/Modules/Security/Routes/web.php
+++ b/Modules/Security/Routes/web.php
@@ -1,10 +1,12 @@
name('administration.security.')->middleware(['auth','permission:security.view','security.ip:admin'])->group(function(){
- Route::get('/',[SecurityController::class,'index'])->name('index');
- Route::put('/policy',[SecurityController::class,'savePolicy'])->middleware('permission:security.manage')->name('policy.update');
- Route::post('/ip-rules',[SecurityController::class,'storeRule'])->middleware('permission:security.manage')->name('rules.store');
- Route::delete('/ip-rules/{rule}',[SecurityController::class,'destroyRule'])->middleware('permission:security.manage')->name('rules.destroy');
- Route::delete('/devices/{device}',[SecurityController::class,'revokeDevice'])->middleware('permission:security.manage')->name('devices.revoke');
+
+Route::prefix('administration/security')->name('administration.security.')->middleware(['auth', 'permission:security.view', 'security.ip:admin'])->group(function () {
+ Route::get('/', [SecurityController::class, 'index'])->name('index');
+ Route::put('/policy', [SecurityController::class, 'savePolicy'])->middleware('permission:security.manage')->name('policy.update');
+ Route::post('/ip-rules', [SecurityController::class, 'storeRule'])->middleware('permission:security.manage')->name('rules.store');
+ Route::delete('/ip-rules/{rule}', [SecurityController::class, 'destroyRule'])->middleware('permission:security.manage')->name('rules.destroy');
+ Route::delete('/devices/{device}', [SecurityController::class, 'revokeDevice'])->middleware('permission:security.manage')->name('devices.revoke');
});
diff --git a/Modules/Security/Services/IpRuleService.php b/Modules/Security/Services/IpRuleService.php
index ca2ea30..490f000 100644
--- a/Modules/Security/Services/IpRuleService.php
+++ b/Modules/Security/Services/IpRuleService.php
@@ -1,24 +1,39 @@
where('is_active',true)->where($column,true)->get();
- $allow=$rules->where('type','allow');
- if ($allow->isNotEmpty() && !$allow->contains(fn(IpRule $rule)=>$this->matches($ip,$rule->address))) return true;
- return $rules->where('type','deny')->contains(fn(IpRule $rule)=>$this->matches($ip,$rule->address));
- }
- private function matches(string $ip,string $rule): bool
- {
- if ($ip===$rule) return true;
- if (!str_contains($rule,'/')) return false;
- [$subnet,$bits]=explode('/',$rule,2); $bits=(int)$bits;
- if (filter_var($ip,FILTER_VALIDATE_IP,FILTER_FLAG_IPV4) && filter_var($subnet,FILTER_VALIDATE_IP,FILTER_FLAG_IPV4)) {
- $mask=-1 << (32-$bits); return (ip2long($ip)&$mask)===(ip2long($subnet)&$mask);
+ $column = $scope === 'login' ? 'applies_to_login' : 'applies_to_admin';
+ $rules = IpRule::query()->where('is_active', true)->where($column, true)->get();
+ $allow = $rules->where('type', 'allow');
+ if ($allow->isNotEmpty() && ! $allow->contains(fn (IpRule $rule) => $this->matches($ip, $rule->address))) {
+ return true;
}
+
+ return $rules->where('type', 'deny')->contains(fn (IpRule $rule) => $this->matches($ip, $rule->address));
+ }
+
+ private function matches(string $ip, string $rule): bool
+ {
+ if ($ip === $rule) {
+ return true;
+ }
+ if (! str_contains($rule, '/')) {
+ return false;
+ }
+ [$subnet,$bits] = explode('/', $rule, 2);
+ $bits = (int) $bits;
+ if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
+ $mask = -1 << (32 - $bits);
+
+ return (ip2long($ip) & $mask) === (ip2long($subnet) & $mask);
+ }
+
return false;
}
}
diff --git a/Modules/Security/Tests/Feature/ModuleBootTest.php b/Modules/Security/Tests/Feature/ModuleBootTest.php
index dbea313..7f2e420 100644
--- a/Modules/Security/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Security/Tests/Feature/ModuleBootTest.php
@@ -1,8 +1,18 @@
assertTrue(app('router')->has('administration.security.index')); }
- public function test_security_middleware_alias_is_registered(): void { $this->assertArrayHasKey('security.ip',app('router')->getMiddleware()); }
+ public function test_security_route_is_registered(): void
+ {
+ $this->assertTrue(app('router')->has('administration.security.index'));
+ }
+
+ public function test_security_middleware_alias_is_registered(): void
+ {
+ $this->assertArrayHasKey('security.ip', app('router')->getMiddleware());
+ }
}
diff --git a/Modules/Seo/Database/Migrations/2026_07_23_000400_create_seo_metadata_table.php b/Modules/Seo/Database/Migrations/2026_07_23_000400_create_seo_metadata_table.php
index 7d9f77a..1867c1e 100644
--- a/Modules/Seo/Database/Migrations/2026_07_23_000400_create_seo_metadata_table.php
+++ b/Modules/Seo/Database/Migrations/2026_07_23_000400_create_seo_metadata_table.php
@@ -1,3 +1,36 @@
id(); $table->morphs('seoable'); $table->string('title')->nullable(); $table->text('description')->nullable(); $table->string('canonical_url')->nullable(); $table->string('robots')->default('index,follow'); $table->string('og_title')->nullable(); $table->text('og_description')->nullable(); $table->string('og_image')->nullable(); $table->string('twitter_card')->default('summary_large_image'); $table->string('schema_type')->nullable(); $table->json('schema_data')->nullable(); $table->boolean('include_in_sitemap')->default(true); $table->decimal('sitemap_priority',2,1)->default(0.5); $table->string('sitemap_change_frequency')->default('monthly'); $table->timestamps(); $table->unique(['seoable_type','seoable_id']); }); } public function down(): void { Schema::dropIfExists('seo_metadata'); } };
+
+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('seo_metadata', function (Blueprint $table): void {
+ $table->id();
+ $table->morphs('seoable');
+ $table->string('title')->nullable();
+ $table->text('description')->nullable();
+ $table->string('canonical_url')->nullable();
+ $table->string('robots')->default('index,follow');
+ $table->string('og_title')->nullable();
+ $table->text('og_description')->nullable();
+ $table->string('og_image')->nullable();
+ $table->string('twitter_card')->default('summary_large_image');
+ $table->string('schema_type')->nullable();
+ $table->json('schema_data')->nullable();
+ $table->boolean('include_in_sitemap')->default(true);
+ $table->decimal('sitemap_priority', 2, 1)->default(0.5);
+ $table->string('sitemap_change_frequency')->default('monthly');
+ $table->timestamps();
+ $table->unique(['seoable_type', 'seoable_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('seo_metadata');
+ }
+};
diff --git a/Modules/Seo/Http/Controllers/SeoController.php b/Modules/Seo/Http/Controllers/SeoController.php
index 24090e9..99349c0 100644
--- a/Modules/Seo/Http/Controllers/SeoController.php
+++ b/Modules/Seo/Http/Controllers/SeoController.php
@@ -1,7 +1,27 @@
pluck('value','key'); return view('seo::index',compact('values')); }
- public function update(Request $request){ $data=$request->validate(['site_title'=>['nullable','string','max:255'],'meta_description'=>['nullable','string','max:500'],'robots'=>['required','string','max:100'],'canonical_base'=>['nullable','url','max:255']]); foreach($data as $key=>$value) Setting::updateOrCreate(['group'=>'seo','key'=>$key],['value'=>$value,'type'=>'string','is_public'=>true]); return back()->with('status','SEO defaults saved.'); }
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use Modules\Settings\Models\Setting;
+
+final class SeoController extends Controller
+{
+ public function index()
+ {
+ $values = Setting::where('group', 'seo')->pluck('value', 'key');
+
+ return view('seo::index', compact('values'));
+ }
+
+ public function update(Request $request)
+ {
+ $data = $request->validate(['site_title' => ['nullable', 'string', 'max:255'], 'meta_description' => ['nullable', 'string', 'max:500'], 'robots' => ['required', 'string', 'max:100'], 'canonical_base' => ['nullable', 'url', 'max:255']]);
+ foreach ($data as $key => $value) {
+ Setting::updateOrCreate(['group' => 'seo', 'key' => $key], ['value' => $value, 'type' => 'string', 'is_public' => true]);
+ }
+
+return back()->with('status', 'SEO defaults saved.');
+ }
}
diff --git a/Modules/Seo/Models/SeoMetadata.php b/Modules/Seo/Models/SeoMetadata.php
index 96d44e6..fa17538 100644
--- a/Modules/Seo/Models/SeoMetadata.php
+++ b/Modules/Seo/Models/SeoMetadata.php
@@ -1,3 +1,17 @@
'array','include_in_sitemap'=>'boolean','sitemap_priority'=>'decimal:1']; } }
+
+namespace Modules\Seo\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+final class SeoMetadata extends Model
+{
+ protected $table = 'seo_metadata';
+
+ protected $guarded = [];
+
+ protected function casts(): array
+ {
+ return ['schema_data' => 'array', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1'];
+ }
+}
diff --git a/Modules/Seo/Routes/web.php b/Modules/Seo/Routes/web.php
index 6d8e273..fa3652f 100644
--- a/Modules/Seo/Routes/web.php
+++ b/Modules/Seo/Routes/web.php
@@ -1,3 +1,9 @@
prefix('administration/seo')->name('administration.seo.')->group(function (): void { Route::get('/',[SeoController::class,'index'])->name('index'); Route::put('/',[SeoController::class,'update'])->name('update'); });
+
+use Illuminate\Support\Facades\Route;
+use Modules\Seo\Http\Controllers\SeoController;
+
+Route::middleware(['auth', 'verified'])->prefix('administration/seo')->name('administration.seo.')->group(function (): void {
+ Route::get('/', [SeoController::class, 'index'])->name('index');
+ Route::put('/', [SeoController::class, 'update'])->name('update');
+});
diff --git a/Modules/Seo/Support/ModuleInfo.php b/Modules/Seo/Support/ModuleInfo.php
index 6884b29..ac5145e 100644
--- a/Modules/Seo/Support/ModuleInfo.php
+++ b/Modules/Seo/Support/ModuleInfo.php
@@ -4,6 +4,13 @@ namespace Modules\Seo\Support;
final class ModuleInfo
{
- public static function name(): string { return 'Seo'; }
- public static function nameLower(): string { return 'seo'; }
+ public static function name(): string
+ {
+ return 'Seo';
+ }
+
+ public static function nameLower(): string
+ {
+ return 'seo';
+ }
}
diff --git a/Modules/Settings/Database/Migrations/2026_07_23_000100_create_settings_table.php b/Modules/Settings/Database/Migrations/2026_07_23_000100_create_settings_table.php
index 04de163..62b7134 100644
--- a/Modules/Settings/Database/Migrations/2026_07_23_000100_create_settings_table.php
+++ b/Modules/Settings/Database/Migrations/2026_07_23_000100_create_settings_table.php
@@ -4,13 +4,24 @@ use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
- public function up(): void {
+return new class extends Migration
+{
+ public function up(): void
+ {
Schema::create('settings', function (Blueprint $table): void {
- $table->id(); $table->string('group')->default('general')->index(); $table->string('key');
- $table->longText('value')->nullable(); $table->string('type')->default('string'); $table->boolean('is_public')->default(false);
- $table->timestamps(); $table->unique(['group','key']);
+ $table->id();
+ $table->string('group')->default('general')->index();
+ $table->string('key');
+ $table->longText('value')->nullable();
+ $table->string('type')->default('string');
+ $table->boolean('is_public')->default(false);
+ $table->timestamps();
+ $table->unique(['group', 'key']);
});
}
- public function down(): void { Schema::dropIfExists('settings'); }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('settings');
+ }
};
diff --git a/Modules/Settings/Http/Controllers/SettingController.php b/Modules/Settings/Http/Controllers/SettingController.php
index e4aacfd..503c331 100644
--- a/Modules/Settings/Http/Controllers/SettingController.php
+++ b/Modules/Settings/Http/Controllers/SettingController.php
@@ -1,7 +1,27 @@
orderBy('group')->orderBy('key')->get(); return view('settings::index',compact('settings')); }
- public function update(Request $request){ $data=$request->validate(['settings'=>['array'],'settings.*'=>['nullable','string','max:5000']]); foreach($data['settings']??[] as $id=>$value){ Setting::whereKey($id)->update(['value'=>$value]); } return back()->with('status','Settings saved.'); }
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use Modules\Settings\Models\Setting;
+
+final class SettingController extends Controller
+{
+ public function index()
+ {
+ $settings = Setting::query()->orderBy('group')->orderBy('key')->get();
+
+ return view('settings::index', compact('settings'));
+ }
+
+ public function update(Request $request)
+ {
+ $data = $request->validate(['settings' => ['array'], 'settings.*' => ['nullable', 'string', 'max:5000']]);
+ foreach ($data['settings'] ?? [] as $id => $value) {
+ Setting::whereKey($id)->update(['value' => $value]);
+ }
+
+return back()->with('status', 'Settings saved.');
+ }
}
diff --git a/Modules/Settings/Models/Setting.php b/Modules/Settings/Models/Setting.php
index 53c9cc3..79c0ed6 100644
--- a/Modules/Settings/Models/Setting.php
+++ b/Modules/Settings/Models/Setting.php
@@ -6,6 +6,10 @@ use Illuminate\Database\Eloquent\Model;
final class Setting extends Model
{
- protected $fillable = ['group','key','value','type','is_public'];
- protected function casts(): array { return ['is_public'=>'boolean']; }
+ protected $fillable = ['group', 'key', 'value', 'type', 'is_public'];
+
+ protected function casts(): array
+ {
+ return ['is_public' => 'boolean'];
+ }
}
diff --git a/Modules/Settings/Routes/web.php b/Modules/Settings/Routes/web.php
index fe860fd..3f28a23 100644
--- a/Modules/Settings/Routes/web.php
+++ b/Modules/Settings/Routes/web.php
@@ -1,3 +1,9 @@
prefix('administration/settings')->name('administration.settings.')->group(function (): void { Route::get('/',[SettingController::class,'index'])->name('index'); Route::put('/',[SettingController::class,'update'])->name('update'); });
+
+use Illuminate\Support\Facades\Route;
+use Modules\Settings\Http\Controllers\SettingController;
+
+Route::middleware(['auth', 'verified'])->prefix('administration/settings')->name('administration.settings.')->group(function (): void {
+ Route::get('/', [SettingController::class, 'index'])->name('index');
+ Route::put('/', [SettingController::class, 'update'])->name('update');
+});
diff --git a/Modules/Settings/Services/SettingsRepository.php b/Modules/Settings/Services/SettingsRepository.php
index c31f691..cd520cc 100644
--- a/Modules/Settings/Services/SettingsRepository.php
+++ b/Modules/Settings/Services/SettingsRepository.php
@@ -8,10 +8,15 @@ use Modules\Settings\Models\Setting;
final class SettingsRepository
{
public function get(string $key, mixed $default = null, string $group = 'general'): mixed
- { return Cache::rememberForever("settings.$group.$key", fn () => Setting::query()->where(compact('group','key'))->value('value') ?? $default); }
+ {
+ return Cache::rememberForever("settings.$group.$key", fn () => Setting::query()->where(compact('group', 'key'))->value('value') ?? $default);
+ }
+
public function put(string $key, mixed $value, string $group = 'general', string $type = 'string', bool $public = false): Setting
{
- $setting = Setting::query()->updateOrCreate(compact('group','key'), ['value'=>is_scalar($value)||$value===null?(string)$value:json_encode($value, JSON_THROW_ON_ERROR),'type'=>$type,'is_public'=>$public]);
- Cache::forget("settings.$group.$key"); return $setting;
+ $setting = Setting::query()->updateOrCreate(compact('group', 'key'), ['value' => is_scalar($value) || $value === null ? (string) $value : json_encode($value, JSON_THROW_ON_ERROR), 'type' => $type, 'is_public' => $public]);
+ Cache::forget("settings.$group.$key");
+
+ return $setting;
}
}
diff --git a/Modules/Settings/Support/ModuleInfo.php b/Modules/Settings/Support/ModuleInfo.php
index 212eb3b..6c5d3ee 100644
--- a/Modules/Settings/Support/ModuleInfo.php
+++ b/Modules/Settings/Support/ModuleInfo.php
@@ -4,6 +4,13 @@ namespace Modules\Settings\Support;
final class ModuleInfo
{
- public static function name(): string { return 'Settings'; }
- public static function nameLower(): string { return 'settings'; }
+ public static function name(): string
+ {
+ return 'Settings';
+ }
+
+ public static function nameLower(): string
+ {
+ return 'settings';
+ }
}
diff --git a/Modules/Sitemap/Config/config.php b/Modules/Sitemap/Config/config.php
index 1fcb4d5..02edfd4 100644
--- a/Modules/Sitemap/Config/config.php
+++ b/Modules/Sitemap/Config/config.php
@@ -1,2 +1,3 @@
'Sitemap'];
diff --git a/Modules/Sitemap/Http/Controllers/SitemapController.php b/Modules/Sitemap/Http/Controllers/SitemapController.php
index 004ce05..3486a49 100644
--- a/Modules/Sitemap/Http/Controllers/SitemapController.php
+++ b/Modules/Sitemap/Http/Controllers/SitemapController.php
@@ -1,3 +1,23 @@
push(['loc'=>url('/'),'updated_at'=>now()]); Page::query()->where('status','published')->get()->each(fn($r)=>$urls->push(['loc'=>route('pages.public.show',$r->slug),'updated_at'=>$r->updated_at])); Blog::published()->get()->each(fn($r)=>$urls->push(['loc'=>route('blog.public.show',$r->slug),'updated_at'=>$r->updated_at])); News::published()->get()->each(fn($r)=>$urls->push(['loc'=>route('news.public.show',$r->slug),'updated_at'=>$r->updated_at])); return response()->view('sitemap::index',compact('urls'))->header('Content-Type','application/xml; charset=UTF-8'); } }
+
+namespace Modules\Sitemap\Http\Controllers;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Response;
+use Modules\Blog\Models\Blog;
+use Modules\News\Models\News;
+use Modules\Pages\Models\Page;
+
+final class SitemapController extends Controller
+{
+ public function index(): Response
+ {
+ $urls = collect();
+ $urls->push(['loc' => url('/'), 'updated_at' => now()]);
+ Page::query()->where('status', 'published')->get()->each(fn ($r) => $urls->push(['loc' => route('pages.public.show', $r->slug), 'updated_at' => $r->updated_at]));
+ Blog::published()->get()->each(fn ($r) => $urls->push(['loc' => route('blog.public.show', $r->slug), 'updated_at' => $r->updated_at]));
+ News::published()->get()->each(fn ($r) => $urls->push(['loc' => route('news.public.show', $r->slug), 'updated_at' => $r->updated_at]));
+
+ return response()->view('sitemap::index', compact('urls'))->header('Content-Type', 'application/xml; charset=UTF-8');
+ }
+}
diff --git a/Modules/Sitemap/Providers/RouteServiceProvider.php b/Modules/Sitemap/Providers/RouteServiceProvider.php
index ad8dfc3..d3894cc 100644
--- a/Modules/Sitemap/Providers/RouteServiceProvider.php
+++ b/Modules/Sitemap/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Sitemap', 'Config/config.php'), 'sitemap');
}
+
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Sitemap', 'Database/Migrations'));
diff --git a/Modules/Sitemap/Routes/web.php b/Modules/Sitemap/Routes/web.php
index da78f26..e6ebc68 100644
--- a/Modules/Sitemap/Routes/web.php
+++ b/Modules/Sitemap/Routes/web.php
@@ -1,2 +1,6 @@
name('sitemap.index');
+
+use Illuminate\Support\Facades\Route;
+use Modules\Sitemap\Http\Controllers\SitemapController;
+
+Route::get('/sitemap.xml', [SitemapController::class, 'index'])->name('sitemap.index');
diff --git a/Modules/Sitemap/Tests/Feature/ModuleBootTest.php b/Modules/Sitemap/Tests/Feature/ModuleBootTest.php
index aebeb40..4b1361f 100644
--- a/Modules/Sitemap/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Sitemap/Tests/Feature/ModuleBootTest.php
@@ -1,6 +1,9 @@
morphToMany(Site::class, 'content', 'site_content', 'content_id', 'site_id')->withPivot('is_primary')->withTimestamps();
}
+
public function scopeForCurrentSite(Builder $query): Builder
{
$site = app(SiteManager::class)->current();
+
return $site ? $query->whereHas('sites', fn (Builder $sites) => $sites->whereKey($site->id)) : $query;
}
+
public function assignToSite(Site|int $site, bool $primary = false): void
{
$id = $site instanceof Site ? $site->getKey() : $site;
- if ($primary) { $this->sites()->updateExistingPivot($this->sites()->pluck('sites.id'), ['is_primary' => false]); }
+ if ($primary) {
+ $this->sites()->updateExistingPivot($this->sites()->pluck('sites.id'), ['is_primary' => false]);
+ }
$this->sites()->syncWithoutDetaching([$id => ['is_primary' => $primary]]);
}
}
diff --git a/Modules/Sites/Config/config.php b/Modules/Sites/Config/config.php
index 1b7cee1..4941723 100644
--- a/Modules/Sites/Config/config.php
+++ b/Modules/Sites/Config/config.php
@@ -1,11 +1,16 @@
true,
'session_key' => 'platform_site_id',
'central_domains' => array_values(array_filter(explode(',', (string) env('CENTRAL_DOMAINS', '')))),
'content_types' => [
- 'pages' => Modules\Pages\Models\Page::class,
- 'blog' => Modules\Blog\Models\Blog::class,
- 'news' => Modules\News\Models\News::class,
+ 'pages' => Page::class,
+ 'blog' => Blog::class,
+ 'news' => News::class,
],
];
diff --git a/Modules/Sites/Contracts/SiteAwareModule.php b/Modules/Sites/Contracts/SiteAwareModule.php
index 3566063..61f13f5 100644
--- a/Modules/Sites/Contracts/SiteAwareModule.php
+++ b/Modules/Sites/Contracts/SiteAwareModule.php
@@ -1,7 +1,10 @@
id(); $table->string('name'); $table->string('key')->unique(); $table->string('status')->default('active');
- $table->string('theme')->nullable(); $table->string('default_locale', 20)->default('en'); $table->string('timezone')->default('UTC');
- $table->boolean('is_default')->default(false); $table->timestamps();
+ $table->id();
+ $table->string('name');
+ $table->string('key')->unique();
+ $table->string('status')->default('active');
+ $table->string('theme')->nullable();
+ $table->string('default_locale', 20)->default('en');
+ $table->string('timezone')->default('UTC');
+ $table->boolean('is_default')->default(false);
+ $table->timestamps();
});
Schema::create('site_domains', function (Blueprint $table): void {
- $table->id(); $table->foreignId('site_id')->constrained()->cascadeOnDelete(); $table->string('domain')->unique();
- $table->boolean('is_primary')->default(false); $table->boolean('redirect_to_primary')->default(false); $table->boolean('force_https')->default(true); $table->boolean('is_active')->default(true); $table->timestamps();
+ $table->id();
+ $table->foreignId('site_id')->constrained()->cascadeOnDelete();
+ $table->string('domain')->unique();
+ $table->boolean('is_primary')->default(false);
+ $table->boolean('redirect_to_primary')->default(false);
+ $table->boolean('force_https')->default(true);
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
});
Schema::create('site_settings', function (Blueprint $table): void {
- $table->id(); $table->foreignId('site_id')->constrained()->cascadeOnDelete(); $table->string('group')->default('general'); $table->string('key'); $table->longText('value')->nullable(); $table->string('type')->default('string'); $table->timestamps(); $table->unique(['site_id','key']);
+ $table->id();
+ $table->foreignId('site_id')->constrained()->cascadeOnDelete();
+ $table->string('group')->default('general');
+ $table->string('key');
+ $table->longText('value')->nullable();
+ $table->string('type')->default('string');
+ $table->timestamps();
+ $table->unique(['site_id', 'key']);
});
Schema::create('site_content', function (Blueprint $table): void {
- $table->id(); $table->foreignId('site_id')->constrained()->cascadeOnDelete(); $table->string('content_type'); $table->unsignedBigInteger('content_id'); $table->boolean('is_primary')->default(false); $table->timestamps();
- $table->unique(['site_id','content_type','content_id']); $table->index(['content_type','content_id']);
+ $table->id();
+ $table->foreignId('site_id')->constrained()->cascadeOnDelete();
+ $table->string('content_type');
+ $table->unsignedBigInteger('content_id');
+ $table->boolean('is_primary')->default(false);
+ $table->timestamps();
+ $table->unique(['site_id', 'content_type', 'content_id']);
+ $table->index(['content_type', 'content_id']);
});
Schema::create('site_user', function (Blueprint $table): void {
- $table->foreignId('site_id')->constrained()->cascadeOnDelete(); $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->string('role')->default('editor'); $table->timestamps(); $table->primary(['site_id','user_id']);
+ $table->foreignId('site_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+ $table->string('role')->default('editor');
+ $table->timestamps();
+ $table->primary(['site_id', 'user_id']);
});
}
+
public function down(): void
{
- Schema::dropIfExists('site_user'); Schema::dropIfExists('site_content'); Schema::dropIfExists('site_settings'); Schema::dropIfExists('site_domains'); Schema::dropIfExists('sites');
+ Schema::dropIfExists('site_user');
+ Schema::dropIfExists('site_content');
+ Schema::dropIfExists('site_settings');
+ Schema::dropIfExists('site_domains');
+ Schema::dropIfExists('sites');
}
};
diff --git a/Modules/Sites/Database/Seeders/SiteSeeder.php b/Modules/Sites/Database/Seeders/SiteSeeder.php
index 1908004..905f213 100644
--- a/Modules/Sites/Database/Seeders/SiteSeeder.php
+++ b/Modules/Sites/Database/Seeders/SiteSeeder.php
@@ -1,13 +1,16 @@
updateOrCreate(['key' => 'default'], ['name'=>'Default Site','status'=>'active','default_locale'=>config('app.locale','en'),'timezone'=>config('app.timezone','UTC'),'is_default'=>true]);
- $site->domains()->updateOrCreate(['domain'=>$host], ['is_primary'=>true,'redirect_to_primary'=>false,'force_https'=>str_starts_with((string) config('app.url'), 'https://'),'is_active'=>true]);
+ $site = Site::query()->updateOrCreate(['key' => 'default'], ['name' => 'Default Site', 'status' => 'active', 'default_locale' => config('app.locale', 'en'), 'timezone' => config('app.timezone', 'UTC'), 'is_default' => true]);
+ $site->domains()->updateOrCreate(['domain' => $host], ['is_primary' => true, 'redirect_to_primary' => false, 'force_https' => str_starts_with((string) config('app.url'), 'https://'), 'is_active' => true]);
}
}
diff --git a/Modules/Sites/Http/Controllers/DomainController.php b/Modules/Sites/Http/Controllers/DomainController.php
index 155a886..90e2dca 100644
--- a/Modules/Sites/Http/Controllers/DomainController.php
+++ b/Modules/Sites/Http/Controllers/DomainController.php
@@ -1,19 +1,33 @@
validate(['domain'=>['required','string','max:255','regex:/^[a-z0-9.-]+$/i',Rule::unique('site_domains','domain')],'is_primary'=>['sometimes','boolean'],'redirect_to_primary'=>['sometimes','boolean'],'force_https'=>['sometimes','boolean'],'is_active'=>['sometimes','boolean']]);
- if ($request->boolean('is_primary')) { $site->domains()->update(['is_primary'=>false]); }
- $site->domains()->create($data + ['is_primary'=>$request->boolean('is_primary'),'redirect_to_primary'=>$request->boolean('redirect_to_primary'),'force_https'=>$request->boolean('force_https'),'is_active'=>$request->boolean('is_active', true)]);
- return back()->with('success','Domain added.');
+ $data = $request->validate(['domain' => ['required', 'string', 'max:255', 'regex:/^[a-z0-9.-]+$/i', Rule::unique('site_domains', 'domain')], 'is_primary' => ['sometimes', 'boolean'], 'redirect_to_primary' => ['sometimes', 'boolean'], 'force_https' => ['sometimes', 'boolean'], 'is_active' => ['sometimes', 'boolean']]);
+ if ($request->boolean('is_primary')) {
+ $site->domains()->update(['is_primary' => false]);
+ }
+ $site->domains()->create($data + ['is_primary' => $request->boolean('is_primary'), 'redirect_to_primary' => $request->boolean('redirect_to_primary'), 'force_https' => $request->boolean('force_https'), 'is_active' => $request->boolean('is_active', true)]);
+
+ return back()->with('success', 'Domain added.');
+ }
+
+ public function destroy(Site $site, SiteDomain $domain): RedirectResponse
+ {
+ abort_unless($domain->site_id === $site->id, 404);
+ abort_if($domain->is_primary && $site->domains()->count() === 1, 422, 'A site must retain a domain.');
+ $domain->delete();
+
+ return back()->with('success', 'Domain removed.');
}
- public function destroy(Site $site, SiteDomain $domain): RedirectResponse { abort_unless($domain->site_id===$site->id,404); abort_if($domain->is_primary && $site->domains()->count()===1,422,'A site must retain a domain.'); $domain->delete(); return back()->with('success','Domain removed.'); }
}
diff --git a/Modules/Sites/Http/Controllers/SiteController.php b/Modules/Sites/Http/Controllers/SiteController.php
index 7dc537c..3e719f9 100644
--- a/Modules/Sites/Http/Controllers/SiteController.php
+++ b/Modules/Sites/Http/Controllers/SiteController.php
@@ -1,5 +1,7 @@
Site::query()->with('domains')->orderByDesc('is_default')->orderBy('name')->paginate(20)]); }
- public function create(): View { return view('sites::admin.sites.form', ['site'=>new Site]); }
- public function store(Request $request): RedirectResponse { $site = Site::query()->create($this->validated($request)); return redirect()->route('administration.sites.edit', $site)->with('success','Site created.'); }
- public function edit(Site $site): View { return view('sites::admin.sites.form', ['site'=>$site->load(['domains','settings'])]); }
+ public function index(): View
+ {
+ return view('sites::admin.sites.index', ['sites' => Site::query()->with('domains')->orderByDesc('is_default')->orderBy('name')->paginate(20)]);
+ }
+
+ public function create(): View
+ {
+ return view('sites::admin.sites.form', ['site' => new Site]);
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $site = Site::query()->create($this->validated($request));
+
+ return redirect()->route('administration.sites.edit', $site)->with('success', 'Site created.');
+ }
+
+ public function edit(Site $site): View
+ {
+ return view('sites::admin.sites.form', ['site' => $site->load(['domains', 'settings'])]);
+ }
+
public function update(Request $request, Site $site): RedirectResponse
{
- DB::transaction(function () use ($request,$site): void { $data=$this->validated($request,$site); if ($data['is_default']) { Site::query()->whereKeyNot($site->id)->update(['is_default'=>false]); } $site->update($data); });
- return back()->with('success','Site updated.');
+ DB::transaction(function () use ($request, $site): void {
+ $data = $this->validated($request, $site);
+ if ($data['is_default']) {
+ Site::query()->whereKeyNot($site->id)->update(['is_default' => false]);
+ } $site->update($data);
+ });
+
+ return back()->with('success', 'Site updated.');
}
- public function destroy(Site $site): RedirectResponse { abort_if($site->is_default, 422, 'The default site cannot be deleted.'); $site->delete(); return redirect()->route('administration.sites.index')->with('success','Site deleted.'); }
- public function switch(Request $request, Site $site): RedirectResponse { $request->session()->put(config('sites.session_key'), $site->id); return back()->with('success', "Site context changed to {$site->name}."); }
- private function validated(Request $request, ?Site $site=null): array
+
+ public function destroy(Site $site): RedirectResponse
{
- return $request->validate(['name'=>['required','string','max:150'],'key'=>['required','alpha_dash','max:100',Rule::unique('sites','key')->ignore($site)],'status'=>['required',Rule::in(['active','inactive','maintenance'])],'theme'=>['nullable','string','max:100'],'default_locale'=>['required','string','max:20'],'timezone'=>['required','timezone'],'is_default'=>['sometimes','boolean']]) + ['is_default'=>$request->boolean('is_default')];
+ abort_if($site->is_default, 422, 'The default site cannot be deleted.');
+ $site->delete();
+
+ return redirect()->route('administration.sites.index')->with('success', 'Site deleted.');
+ }
+
+ public function switch(Request $request, Site $site): RedirectResponse
+ {
+ $request->session()->put(config('sites.session_key'), $site->id);
+
+ return back()->with('success', "Site context changed to {$site->name}.");
+ }
+
+ private function validated(Request $request, ?Site $site = null): array
+ {
+ return $request->validate(['name' => ['required', 'string', 'max:150'], 'key' => ['required', 'alpha_dash', 'max:100', Rule::unique('sites', 'key')->ignore($site)], 'status' => ['required', Rule::in(['active', 'inactive', 'maintenance'])], 'theme' => ['nullable', 'string', 'max:100'], 'default_locale' => ['required', 'string', 'max:20'], 'timezone' => ['required', 'timezone'], 'is_default' => ['sometimes', 'boolean']]) + ['is_default' => $request->boolean('is_default')];
}
}
diff --git a/Modules/Sites/Http/Middleware/ResolveSite.php b/Modules/Sites/Http/Middleware/ResolveSite.php
index 780ae24..fc3207d 100644
--- a/Modules/Sites/Http/Middleware/ResolveSite.php
+++ b/Modules/Sites/Http/Middleware/ResolveSite.php
@@ -1,12 +1,16 @@
sites->resolve($request);
@@ -14,6 +18,7 @@ final class ResolveSite
config(['app.url' => $request->getSchemeAndHttpHost(), 'app.timezone' => $site->timezone ?: config('app.timezone')]);
date_default_timezone_set((string) config('app.timezone'));
}
+
return $next($request);
}
}
diff --git a/Modules/Sites/Models/Site.php b/Modules/Sites/Models/Site.php
index d9bedd2..a9c2689 100644
--- a/Modules/Sites/Models/Site.php
+++ b/Modules/Sites/Models/Site.php
@@ -1,19 +1,44 @@
'boolean']; }
- public function domains(): HasMany { return $this->hasMany(SiteDomain::class); }
- public function settings(): HasMany { return $this->hasMany(SiteSetting::class); }
- public function assignments(): HasMany { return $this->hasMany(SiteContent::class); }
- public function scopeActive(Builder $query): Builder { return $query->where('status', 'active'); }
+ protected $fillable = ['name', 'key', 'status', 'theme', 'default_locale', 'timezone', 'is_default'];
+
+ protected function casts(): array
+ {
+ return ['is_default' => 'boolean'];
+ }
+
+ public function domains(): HasMany
+ {
+ return $this->hasMany(SiteDomain::class);
+ }
+
+ public function settings(): HasMany
+ {
+ return $this->hasMany(SiteSetting::class);
+ }
+
+ public function assignments(): HasMany
+ {
+ return $this->hasMany(SiteContent::class);
+ }
+
+ public function scopeActive(Builder $query): Builder
+ {
+ return $query->where('status', 'active');
+ }
+
public function setting(string $key, mixed $default = null): mixed
{
$setting = $this->relationLoaded('settings') ? $this->settings->firstWhere('key', $key) : $this->settings()->where('key', $key)->first();
+
return $setting?->typedValue() ?? $default;
}
}
diff --git a/Modules/Sites/Models/SiteContent.php b/Modules/Sites/Models/SiteContent.php
index e142b2b..c359fbb 100644
--- a/Modules/Sites/Models/SiteContent.php
+++ b/Modules/Sites/Models/SiteContent.php
@@ -1,13 +1,29 @@
'boolean']; }
- public function site(): BelongsTo { return $this->belongsTo(Site::class); }
- public function content(): MorphTo { return $this->morphTo(__FUNCTION__, 'content_type', 'content_id'); }
+
+ protected $fillable = ['site_id', 'content_type', 'content_id', 'is_primary'];
+
+ protected function casts(): array
+ {
+ return ['is_primary' => 'boolean'];
+ }
+
+ public function site(): BelongsTo
+ {
+ return $this->belongsTo(Site::class);
+ }
+
+ public function content(): MorphTo
+ {
+ return $this->morphTo(__FUNCTION__, 'content_type', 'content_id');
+ }
}
diff --git a/Modules/Sites/Models/SiteDomain.php b/Modules/Sites/Models/SiteDomain.php
index 1011ea4..b92e2cf 100644
--- a/Modules/Sites/Models/SiteDomain.php
+++ b/Modules/Sites/Models/SiteDomain.php
@@ -1,10 +1,21 @@
'boolean','redirect_to_primary'=>'boolean','force_https'=>'boolean','is_active'=>'boolean']; }
- public function site(): BelongsTo { return $this->belongsTo(Site::class); }
+ protected $fillable = ['site_id', 'domain', 'is_primary', 'redirect_to_primary', 'force_https', 'is_active'];
+
+ protected function casts(): array
+ {
+ return ['is_primary' => 'boolean', 'redirect_to_primary' => 'boolean', 'force_https' => 'boolean', 'is_active' => 'boolean'];
+ }
+
+ public function site(): BelongsTo
+ {
+ return $this->belongsTo(Site::class);
+ }
}
diff --git a/Modules/Sites/Models/SiteSetting.php b/Modules/Sites/Models/SiteSetting.php
index bf1b0b4..249314a 100644
--- a/Modules/Sites/Models/SiteSetting.php
+++ b/Modules/Sites/Models/SiteSetting.php
@@ -1,11 +1,19 @@
belongsTo(Site::class); }
+ protected $fillable = ['site_id', 'group', 'key', 'value', 'type'];
+
+ public function site(): BelongsTo
+ {
+ return $this->belongsTo(Site::class);
+ }
+
public function typedValue(): mixed
{
return match ($this->type) {
diff --git a/Modules/Sites/Providers/RouteServiceProvider.php b/Modules/Sites/Providers/RouteServiceProvider.php
index 2b6e293..f4820fa 100644
--- a/Modules/Sites/Providers/RouteServiceProvider.php
+++ b/Modules/Sites/Providers/RouteServiceProvider.php
@@ -1,8 +1,15 @@
group(module_path('Sites', 'Routes/web.php')); }
+ public function boot(): void
+ {
+ parent::boot();
+ Route::middleware('web')->group(module_path('Sites', 'Routes/web.php'));
+ }
}
diff --git a/Modules/Sites/Providers/SitesServiceProvider.php b/Modules/Sites/Providers/SitesServiceProvider.php
index 5153092..fbfd18f 100644
--- a/Modules/Sites/Providers/SitesServiceProvider.php
+++ b/Modules/Sites/Providers/SitesServiceProvider.php
@@ -1,7 +1,10 @@
app->singleton(SiteManager::class);
$this->app->register(RouteServiceProvider::class);
}
+
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Sites', 'Database/Migrations'));
diff --git a/Modules/Sites/Routes/web.php b/Modules/Sites/Routes/web.php
index d01b09f..78ebd55 100644
--- a/Modules/Sites/Routes/web.php
+++ b/Modules/Sites/Routes/web.php
@@ -1,10 +1,12 @@
prefix('administration')->name('administration.')->group(function (): void {
- Route::post('sites/{site}/switch', [SiteController::class,'switch'])->name('sites.switch');
+
+Route::middleware(['auth', 'verified'])->prefix('administration')->name('administration.')->group(function (): void {
+ Route::post('sites/{site}/switch', [SiteController::class, 'switch'])->name('sites.switch');
Route::resource('sites', SiteController::class)->except('show');
- Route::post('sites/{site}/domains', [DomainController::class,'store'])->name('sites.domains.store');
- Route::delete('sites/{site}/domains/{domain}', [DomainController::class,'destroy'])->name('sites.domains.destroy');
+ Route::post('sites/{site}/domains', [DomainController::class, 'store'])->name('sites.domains.store');
+ Route::delete('sites/{site}/domains/{domain}', [DomainController::class, 'destroy'])->name('sites.domains.destroy');
});
diff --git a/Modules/Sites/Tests/Feature/SitesModuleBootTest.php b/Modules/Sites/Tests/Feature/SitesModuleBootTest.php
index d31be8a..94916ed 100644
--- a/Modules/Sites/Tests/Feature/SitesModuleBootTest.php
+++ b/Modules/Sites/Tests/Feature/SitesModuleBootTest.php
@@ -1,6 +1,9 @@
argument('key')));
if (! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $key)) {
$this->error('Theme keys may contain lowercase letters, numbers, dashes, and underscores only.');
+
return self::FAILURE;
}
$path = rtrim((string) config('stylist.themes_path'), '/\\').DIRECTORY_SEPARATOR.$key;
if ($this->files->exists($path)) {
$this->error("Theme [{$key}] already exists.");
+
return self::FAILURE;
}
@@ -55,6 +58,7 @@ final class CreateThemeCommand extends Command
$this->info("Theme [{$key}] created at {$path}.");
$this->line('Shared views will resolve through its parent; only add genuine overrides.');
+
return self::SUCCESS;
}
}
diff --git a/Modules/Stylist/Console/FindDuplicateViewsCommand.php b/Modules/Stylist/Console/FindDuplicateViewsCommand.php
index 6fd1093..2304ca1 100644
--- a/Modules/Stylist/Console/FindDuplicateViewsCommand.php
+++ b/Modules/Stylist/Console/FindDuplicateViewsCommand.php
@@ -8,6 +8,7 @@ use Modules\Stylist\Diagnostics\ThemeInspector;
final class FindDuplicateViewsCommand extends Command
{
protected $signature = 'stylist:duplicates';
+
protected $description = 'Find Blade task paths owned by more than one theme.';
public function __construct(private readonly ThemeInspector $inspector)
@@ -20,6 +21,7 @@ final class FindDuplicateViewsCommand extends Command
$duplicates = $this->inspector->duplicateViews();
if ($duplicates === []) {
$this->info('No duplicate theme views found.');
+
return self::SUCCESS;
}
@@ -32,6 +34,7 @@ final class FindDuplicateViewsCommand extends Command
$this->table(['View', 'Theme', 'Path'], $rows);
$this->error(count($duplicates).' duplicate view task(s) found.');
+
return self::FAILURE;
}
}
diff --git a/Modules/Stylist/Console/InspectThemesCommand.php b/Modules/Stylist/Console/InspectThemesCommand.php
index 6441970..0764a1f 100644
--- a/Modules/Stylist/Console/InspectThemesCommand.php
+++ b/Modules/Stylist/Console/InspectThemesCommand.php
@@ -8,6 +8,7 @@ use Modules\Stylist\Diagnostics\ThemeInspector;
final class InspectThemesCommand extends Command
{
protected $signature = 'stylist:inspect';
+
protected $description = 'Display installed themes, inheritance, entry views, and view counts.';
public function __construct(private readonly ThemeInspector $inspector)
diff --git a/Modules/Stylist/Console/PublishAssetsCommand.php b/Modules/Stylist/Console/PublishAssetsCommand.php
index 24f11f1..ab661fa 100644
--- a/Modules/Stylist/Console/PublishAssetsCommand.php
+++ b/Modules/Stylist/Console/PublishAssetsCommand.php
@@ -7,6 +7,7 @@ use Illuminate\Console\Command;
final class PublishAssetsCommand extends Command
{
protected $signature = 'stylist:publish-assets {--force : Overwrite the published configuration}';
+
protected $description = 'Publish the Stylist configuration and ensure resources/themes exists.';
public function handle(): int
diff --git a/Modules/Stylist/Console/ThemeDoctorCommand.php b/Modules/Stylist/Console/ThemeDoctorCommand.php
index 393c465..d59cb3d 100644
--- a/Modules/Stylist/Console/ThemeDoctorCommand.php
+++ b/Modules/Stylist/Console/ThemeDoctorCommand.php
@@ -8,6 +8,7 @@ use Modules\Stylist\Diagnostics\ThemeInspector;
final class ThemeDoctorCommand extends Command
{
protected $signature = 'stylist:doctor';
+
protected $description = 'Diagnose manifests, inheritance, duplicate views, and theme configuration.';
public function __construct(private readonly ThemeInspector $inspector)
@@ -20,6 +21,7 @@ final class ThemeDoctorCommand extends Command
$issues = $this->inspector->issues();
if ($issues === []) {
$this->info('Stylist is healthy. No theme issues found.');
+
return self::SUCCESS;
}
diff --git a/Modules/Stylist/Console/ValidateThemeCommand.php b/Modules/Stylist/Console/ValidateThemeCommand.php
index 5feed39..5e21f93 100644
--- a/Modules/Stylist/Console/ValidateThemeCommand.php
+++ b/Modules/Stylist/Console/ValidateThemeCommand.php
@@ -37,6 +37,7 @@ final class ValidateThemeCommand extends Command
if ($issues === []) {
$this->info('Theme validation passed with no issues.');
+
return self::SUCCESS;
}
@@ -56,6 +57,7 @@ final class ValidateThemeCommand extends Command
return $errors > 0 ? self::FAILURE : self::SUCCESS;
} catch (\Throwable $exception) {
$this->error($exception->getMessage());
+
return self::FAILURE;
} finally {
if ($temporary !== null) {
@@ -75,7 +77,7 @@ final class ValidateThemeCommand extends Command
$temporary = storage_path('app/stylist/validate-'.bin2hex(random_bytes(8)));
$this->files->ensureDirectoryExists($temporary);
- $zip = new ZipArchive();
+ $zip = new ZipArchive;
if ($zip->open($input) !== true) {
throw new RuntimeException('The theme ZIP could not be opened.');
}
diff --git a/Modules/Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php b/Modules/Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php
index 5b0c2d4..57b6955 100644
--- a/Modules/Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php
+++ b/Modules/Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php
@@ -4,7 +4,21 @@ 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('stylist_theme_settings', function (Blueprint $table) { $table->id(); $table->string('theme_key')->unique(); $table->string('variant')->default('light'); $table->json('settings')->nullable(); $table->timestamps(); }); }
- public function down(): void { Schema::dropIfExists('stylist_theme_settings'); }
+return new class extends Migration
+{
+ public function up(): void
+ {
+ Schema::create('stylist_theme_settings', function (Blueprint $table) {
+ $table->id();
+ $table->string('theme_key')->unique();
+ $table->string('variant')->default('light');
+ $table->json('settings')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('stylist_theme_settings');
+ }
};
diff --git a/Modules/Stylist/Diagnostics/ThemeInspector.php b/Modules/Stylist/Diagnostics/ThemeInspector.php
index e26c6ec..371d543 100644
--- a/Modules/Stylist/Diagnostics/ThemeInspector.php
+++ b/Modules/Stylist/Diagnostics/ThemeInspector.php
@@ -120,6 +120,7 @@ final class ThemeInspector
}
ksort($views);
+
return $views;
}
diff --git a/Modules/Stylist/Helpers/helpers.php b/Modules/Stylist/Helpers/helpers.php
index a6ea4ec..9260a86 100644
--- a/Modules/Stylist/Helpers/helpers.php
+++ b/Modules/Stylist/Helpers/helpers.php
@@ -1,6 +1,7 @@
switcher();
}
diff --git a/Modules/Stylist/Html/ThemeHtmlBuilder.php b/Modules/Stylist/Html/ThemeHtmlBuilder.php
index 724c769..b7b3c16 100644
--- a/Modules/Stylist/Html/ThemeHtmlBuilder.php
+++ b/Modules/Stylist/Html/ThemeHtmlBuilder.php
@@ -13,14 +13,20 @@ final class ThemeHtmlBuilder
public function stylesheet(?string $path = null): HtmlString
{
$path ??= $this->stylist->current()->stylesheet();
- if (! $this->stylist->current()->has($path)) return new HtmlString('');
+ if (! $this->stylist->current()->has($path)) {
+ return new HtmlString('');
+ }
+
return new HtmlString('');
}
public function script(?string $path = null): HtmlString
{
$path ??= $this->stylist->current()->script();
- if (! $this->stylist->current()->has($path)) return new HtmlString('');
+ if (! $this->stylist->current()->has($path)) {
+ return new HtmlString('');
+ }
+
return new HtmlString('');
}
@@ -32,11 +38,15 @@ final class ThemeHtmlBuilder
foreach ($theme->appearances() as $name => $definition) {
$vars = (array) data_get($definition, 'variables', []);
- if ($vars) $rules[] = ':root[data-theme="'.$this->selector($name).'"]{'.$this->declarations($vars).'}';
+ if ($vars) {
+ $rules[] = ':root[data-theme="'.$this->selector($name).'"]{'.$this->declarations($vars).'}';
+ }
}
foreach ($theme->accents() as $name => $definition) {
$vars = (array) data_get($definition, 'variables', []);
- if ($vars) $rules[] = ':root[data-accent="'.$this->selector($name).'"]{'.$this->declarations($vars).'}';
+ if ($vars) {
+ $rules[] = ':root[data-accent="'.$this->selector($name).'"]{'.$this->declarations($vars).'}';
+ }
}
if ($theme->appearances() === [] && $theme->accents() === []) {
diff --git a/Modules/Stylist/Html/ThemeSeoBuilder.php b/Modules/Stylist/Html/ThemeSeoBuilder.php
index 6e7d4ec..28d5840 100644
--- a/Modules/Stylist/Html/ThemeSeoBuilder.php
+++ b/Modules/Stylist/Html/ThemeSeoBuilder.php
@@ -9,15 +9,36 @@ use Modules\Stylist\Theme\UrlGenerator;
final class ThemeSeoBuilder
{
public function __construct(private readonly Stylist $stylist, private readonly UrlGenerator $urls) {}
- public function render(array $overrides=[]): HtmlString
+
+ public function render(array $overrides = []): HtmlString
{
- $theme=$this->stylist->current(); $seo=array_replace($theme->seo(),$overrides); $settings=$this->stylist->settings();
- $title=(string)($seo['title']??$settings['site_title']??$theme->name()); $description=(string)($seo['description']??$settings['site_description']??$theme->description());
- $image=$seo['image']??$theme->preview(); if($image && $theme->has($image))$image=$this->urls->asset($image);
- $tags=[''.e($title).'']; if($description!=='')$tags[]='';
- foreach(['og:title'=>$title,'og:description'=>$description,'twitter:card'=>'summary_large_image','twitter:title'=>$title,'twitter:description'=>$description] as $property=>$content){if($content==='')continue;$attribute=str_starts_with($property,'og:')?'property':'name';$tags[]='';}
- if($image){$tags[]='';$tags[]='';}
- if(isset($seo['json_ld'])&&is_array($seo['json_ld']))$tags[]='';
- return new HtmlString(implode("\n",$tags));
+ $theme = $this->stylist->current();
+ $seo = array_replace($theme->seo(), $overrides);
+ $settings = $this->stylist->settings();
+ $title = (string) ($seo['title'] ?? $settings['site_title'] ?? $theme->name());
+ $description = (string) ($seo['description'] ?? $settings['site_description'] ?? $theme->description());
+ $image = $seo['image'] ?? $theme->preview();
+ if ($image && $theme->has($image)) {
+ $image = $this->urls->asset($image);
+ }
+ $tags = [''.e($title).''];
+ if ($description !== '') {
+ $tags[] = '';
+ }
+ foreach (['og:title' => $title, 'og:description' => $description, 'twitter:card' => 'summary_large_image', 'twitter:title' => $title, 'twitter:description' => $description] as $property => $content) {
+ if ($content === '') {
+ continue;
+ }$attribute = str_starts_with($property, 'og:') ? 'property' : 'name';
+ $tags[] = '';
+ }
+ if ($image) {
+ $tags[] = '';
+ $tags[] = '';
+ }
+ if (isset($seo['json_ld']) && is_array($seo['json_ld'])) {
+ $tags[] = '';
+ }
+
+ return new HtmlString(implode("\n", $tags));
}
}
diff --git a/Modules/Stylist/Http/Controllers/ThemeManagementController.php b/Modules/Stylist/Http/Controllers/ThemeManagementController.php
index 0888265..8918072 100644
--- a/Modules/Stylist/Http/Controllers/ThemeManagementController.php
+++ b/Modules/Stylist/Http/Controllers/ThemeManagementController.php
@@ -22,29 +22,42 @@ final class ThemeManagementController extends Controller
$request->string('appearance')->toString() ?: null,
$request->string('accent')->toString() ?: null,
);
+
return back()->with('status', 'Theme settings saved.');
}
+
public function install(Request $request, ThemePackageManager $packages)
{
- $request->validate(['theme' => ['required','file','mimes:zip','max:51200']]);
+ $request->validate(['theme' => ['required', 'file', 'mimes:zip', 'max:51200']]);
$theme = $packages->install($request->file('theme'));
+
return back()->with('status', $theme->name().' installed.');
}
+
public function uninstall(string $theme, Loader $loader, ThemePackageManager $packages)
{
- $item = $loader->find($theme); $packages->uninstall($item);
+ $item = $loader->find($theme);
+ $packages->uninstall($item);
+
return back()->with('status', $item->name().' uninstalled.');
}
+
public function export(string $theme, Loader $loader, ThemePackageManager $packages)
{
- $item = $loader->find($theme); $path = $packages->export($item);
+ $item = $loader->find($theme);
+ $path = $packages->export($item);
+
return response()->download($path)->deleteFileAfterSend();
}
+
public function optimize(string $theme, Loader $loader, ThemeOptimizer $optimizer)
{
- $item = $loader->find($theme); $result = $optimizer->optimize($item);
+ $item = $loader->find($theme);
+ $result = $optimizer->optimize($item);
+
return back()->with('status', "Optimised {$result['css']} CSS, {$result['js']} JS and {$result['images']} images.");
}
+
public function validateTheme(string $theme, Loader $loader, ThemeAccessibilityValidator $validator)
{
return response()->json(['issues' => $validator->validate($loader->find($theme))]);
diff --git a/Modules/Stylist/Models/ThemeSetting.php b/Modules/Stylist/Models/ThemeSetting.php
index a50e2f7..fb01e6f 100644
--- a/Modules/Stylist/Models/ThemeSetting.php
+++ b/Modules/Stylist/Models/ThemeSetting.php
@@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Model;
final class ThemeSetting extends Model
{
protected $table = 'stylist_theme_settings';
+
protected $fillable = ['theme_key', 'variant', 'settings'];
+
protected $casts = ['settings' => 'array'];
}
diff --git a/Modules/Stylist/Providers/StylistServiceProvider.php b/Modules/Stylist/Providers/StylistServiceProvider.php
index 7d3ecad..c4ccfea 100644
--- a/Modules/Stylist/Providers/StylistServiceProvider.php
+++ b/Modules/Stylist/Providers/StylistServiceProvider.php
@@ -19,6 +19,7 @@ use Modules\Stylist\Theme\Stylist;
final class StylistServiceProvider extends ServiceProvider
{
protected string $moduleName = 'Stylist';
+
protected string $moduleNameLower = 'stylist';
public function register(): void
@@ -135,7 +136,6 @@ final class StylistServiceProvider extends ServiceProvider
}
}
-
private function normalisePath(string $path): string
{
return rtrim(
diff --git a/Modules/Stylist/Services/ThemeAccessibilityValidator.php b/Modules/Stylist/Services/ThemeAccessibilityValidator.php
index d95ca38..70b68bb 100644
--- a/Modules/Stylist/Services/ThemeAccessibilityValidator.php
+++ b/Modules/Stylist/Services/ThemeAccessibilityValidator.php
@@ -26,6 +26,7 @@ final class ThemeAccessibilityValidator
$issues[] = ['severity' => 'warning', 'message' => count($matches[0]).' button(s) may lack an accessible name.', 'file' => $relative];
}
}
+
return $issues;
}
}
diff --git a/Modules/Stylist/Services/ThemeOptimizer.php b/Modules/Stylist/Services/ThemeOptimizer.php
index db52760..def6927 100644
--- a/Modules/Stylist/Services/ThemeOptimizer.php
+++ b/Modules/Stylist/Services/ThemeOptimizer.php
@@ -14,9 +14,13 @@ final class ThemeOptimizer
$result = ['css' => 0, 'js' => 0, 'images' => 0];
foreach (['css' => 'css', 'js' => 'js'] as $type => $folder) {
$directory = $theme->path().'/'.$folder;
- if (! $this->files->isDirectory($directory)) continue;
+ if (! $this->files->isDirectory($directory)) {
+ continue;
+ }
foreach ($this->files->files($directory) as $file) {
- if ($file->getExtension() !== $type || str_ends_with($file->getFilename(), '.min.'.$type)) continue;
+ if ($file->getExtension() !== $type || str_ends_with($file->getFilename(), '.min.'.$type)) {
+ continue;
+ }
$content = $file->getContents();
$content = preg_replace('!/\*.*?\*/!s', '', $content) ?? $content;
$content = preg_replace('/\s+/', ' ', $content) ?? $content;
@@ -29,15 +33,25 @@ final class ThemeOptimizer
if (function_exists('imagewebp')) {
foreach (['images', 'icons'] as $folder) {
$directory = $theme->path().'/'.$folder;
- if (! $this->files->isDirectory($directory)) continue;
+ if (! $this->files->isDirectory($directory)) {
+ continue;
+ }
foreach ($this->files->files($directory) as $file) {
$ext = strtolower($file->getExtension());
- if (! in_array($ext, ['jpg', 'jpeg', 'png'], true)) continue;
+ if (! in_array($ext, ['jpg', 'jpeg', 'png'], true)) {
+ continue;
+ }
$image = $ext === 'png' ? @imagecreatefrompng($file->getPathname()) : @imagecreatefromjpeg($file->getPathname());
- if ($image) { imagepalettetotruecolor($image); imagewebp($image, $file->getPath().'/'.$file->getBasename('.'.$ext).'.webp', 82); imagedestroy($image); $result['images']++; }
+ if ($image) {
+ imagepalettetotruecolor($image);
+ imagewebp($image, $file->getPath().'/'.$file->getBasename('.'.$ext).'.webp', 82);
+ imagedestroy($image);
+ $result['images']++;
+ }
}
}
}
+
return $result;
}
}
diff --git a/Modules/Stylist/Services/ThemePackageManager.php b/Modules/Stylist/Services/ThemePackageManager.php
index 01e4deb..0319b03 100644
--- a/Modules/Stylist/Services/ThemePackageManager.php
+++ b/Modules/Stylist/Services/ThemePackageManager.php
@@ -15,28 +15,46 @@ final class ThemePackageManager
public function install(UploadedFile $upload): Theme
{
- if (strtolower($upload->getClientOriginalExtension()) !== 'zip') throw new RuntimeException('Only ZIP theme packages may be installed.');
+ if (strtolower($upload->getClientOriginalExtension()) !== 'zip') {
+ throw new RuntimeException('Only ZIP theme packages may be installed.');
+ }
$temporary = storage_path('app/stylist/install-'.bin2hex(random_bytes(8)));
$this->files->ensureDirectoryExists($temporary);
- $zip = new ZipArchive();
- if ($zip->open($upload->getRealPath()) !== true) throw new RuntimeException('The uploaded ZIP could not be opened.');
- for ($i=0; $i<$zip->numFiles; $i++) {
- $name = $zip->getNameIndex($i);
- if (str_contains($name, '..') || str_starts_with($name, '/') || str_contains($name, "\0")) { $zip->close(); throw new RuntimeException('The ZIP contains an unsafe path.'); }
+ $zip = new ZipArchive;
+ if ($zip->open($upload->getRealPath()) !== true) {
+ throw new RuntimeException('The uploaded ZIP could not be opened.');
}
- $zip->extractTo($temporary); $zip->close();
+ for ($i = 0; $i < $zip->numFiles; $i++) {
+ $name = $zip->getNameIndex($i);
+ if (str_contains($name, '..') || str_starts_with($name, '/') || str_contains($name, "\0")) {
+ $zip->close();
+ throw new RuntimeException('The ZIP contains an unsafe path.');
+ }
+ }
+ $zip->extractTo($temporary);
+ $zip->close();
$manifest = collect($this->files->allFiles($temporary))->first(fn ($file) => $file->getFilename() === 'theme.json');
- if (! $manifest) { $this->files->deleteDirectory($temporary); throw new RuntimeException('The package does not contain theme.json.'); }
+ if (! $manifest) {
+ $this->files->deleteDirectory($temporary);
+ throw new RuntimeException('The package does not contain theme.json.');
+ }
$source = $manifest->getPath();
$theme = $this->loader->fromPath($source);
- if ($theme->parent() && ! $this->loader->all()->has($theme->parent())) throw new RuntimeException('Parent theme '.$theme->parent().' is not installed.');
+ if ($theme->parent() && ! $this->loader->all()->has($theme->parent())) {
+ throw new RuntimeException('Parent theme '.$theme->parent().' is not installed.');
+ }
$issues = $this->validator->validate($theme);
- if (collect($issues)->contains('severity', 'error')) throw new RuntimeException('Accessibility validation failed: '.collect($issues)->where('severity', 'error')->pluck('message')->implode(' '));
+ if (collect($issues)->contains('severity', 'error')) {
+ throw new RuntimeException('Accessibility validation failed: '.collect($issues)->where('severity', 'error')->pluck('message')->implode(' '));
+ }
$target = rtrim((string) config('stylist.themes_path'), '/').'/'.$theme->key();
- if ($this->files->isDirectory($target)) $this->files->deleteDirectory($target);
+ if ($this->files->isDirectory($target)) {
+ $this->files->deleteDirectory($target);
+ }
$this->files->copyDirectory($source, $target);
$this->files->deleteDirectory($temporary);
$this->loader->flush();
+
return $this->loader->find($theme->key());
}
@@ -44,17 +62,30 @@ final class ThemePackageManager
{
$projectRoot = realpath((string) config('stylist.themes_path'));
$themePath = realpath($theme->path());
- if (! $projectRoot || ! $themePath || ! str_starts_with($themePath, $projectRoot.DIRECTORY_SEPARATOR)) throw new RuntimeException('Bundled themes cannot be uninstalled.');
- if (collect($this->loader->all())->contains(fn (Theme $candidate) => $candidate->parent() === $theme->key())) throw new RuntimeException('This theme has installed child themes.');
- $this->files->deleteDirectory($themePath); $this->loader->flush();
+ if (! $projectRoot || ! $themePath || ! str_starts_with($themePath, $projectRoot.DIRECTORY_SEPARATOR)) {
+ throw new RuntimeException('Bundled themes cannot be uninstalled.');
+ }
+ if (collect($this->loader->all())->contains(fn (Theme $candidate) => $candidate->parent() === $theme->key())) {
+ throw new RuntimeException('This theme has installed child themes.');
+ }
+ $this->files->deleteDirectory($themePath);
+ $this->loader->flush();
}
public function export(Theme $theme): string
{
- $directory = storage_path('app/stylist/exports'); $this->files->ensureDirectoryExists($directory);
+ $directory = storage_path('app/stylist/exports');
+ $this->files->ensureDirectoryExists($directory);
$path = $directory.'/'.$theme->key().'-'.$theme->version().'.zip';
- $zip = new ZipArchive(); if ($zip->open($path, ZipArchive::CREATE|ZipArchive::OVERWRITE) !== true) throw new RuntimeException('Unable to create theme archive.');
- foreach ($this->files->allFiles($theme->path()) as $file) $zip->addFile($file->getPathname(), $theme->key().'/'.$file->getRelativePathname());
- $zip->close(); return $path;
+ $zip = new ZipArchive;
+ if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
+ throw new RuntimeException('Unable to create theme archive.');
+ }
+ foreach ($this->files->allFiles($theme->path()) as $file) {
+ $zip->addFile($file->getPathname(), $theme->key().'/'.$file->getRelativePathname());
+ }
+ $zip->close();
+
+ return $path;
}
}
diff --git a/Modules/Stylist/Services/ThemeSettings.php b/Modules/Stylist/Services/ThemeSettings.php
index cccf8cd..0a1f253 100644
--- a/Modules/Stylist/Services/ThemeSettings.php
+++ b/Modules/Stylist/Services/ThemeSettings.php
@@ -9,6 +9,7 @@ use Modules\Stylist\Theme\Theme;
final class ThemeSettings
{
private const APPEARANCE_KEY = '__stylist_appearance';
+
private const ACCENT_KEY = '__stylist_accent';
public function get(Theme $theme): array
@@ -24,6 +25,7 @@ final class ThemeSettings
public function variant(Theme $theme): string
{
$variant = (string) ($this->stored($theme)?->variant ?? array_key_first($theme->variants()) ?? 'light');
+
return array_key_exists($variant, $theme->variants()) ? $variant : (array_key_first($theme->variants()) ?? 'light');
}
@@ -31,8 +33,11 @@ final class ThemeSettings
{
$stored = (array) ($this->stored($theme)?->settings ?? []);
$allowed = array_keys($theme->appearances());
- if ($allowed === []) return '';
+ if ($allowed === []) {
+ return '';
+ }
$appearance = (string) ($stored[self::APPEARANCE_KEY] ?? $theme->defaultAppearance());
+
return in_array($appearance, $allowed, true) ? $appearance : (string) array_key_first($theme->appearances());
}
@@ -40,8 +45,11 @@ final class ThemeSettings
{
$stored = (array) ($this->stored($theme)?->settings ?? []);
$allowed = array_keys($theme->accents());
- if ($allowed === []) return '';
+ if ($allowed === []) {
+ return '';
+ }
$accent = (string) ($stored[self::ACCENT_KEY] ?? $theme->defaultAccent());
+
return in_array($accent, $allowed, true) ? $accent : (string) array_key_first($theme->accents());
}
@@ -53,18 +61,24 @@ final class ThemeSettings
$allowedAppearances = array_keys($theme->appearances());
if ($allowedAppearances !== []) {
$appearance ??= (string) ($existing[self::APPEARANCE_KEY] ?? $theme->defaultAppearance());
- if (! in_array($appearance, $allowedAppearances, true)) $appearance = (string) array_key_first($theme->appearances());
+ if (! in_array($appearance, $allowedAppearances, true)) {
+ $appearance = (string) array_key_first($theme->appearances());
+ }
$settings[self::APPEARANCE_KEY] = $appearance;
}
$allowedAccents = array_keys($theme->accents());
if ($allowedAccents !== []) {
$accent ??= (string) ($existing[self::ACCENT_KEY] ?? $theme->defaultAccent());
- if (! in_array($accent, $allowedAccents, true)) $accent = (string) array_key_first($theme->accents());
+ if (! in_array($accent, $allowedAccents, true)) {
+ $accent = (string) array_key_first($theme->accents());
+ }
$settings[self::ACCENT_KEY] = $accent;
}
- if ($theme->variants() && ! array_key_exists($variant, $theme->variants())) $variant = array_key_first($theme->variants());
+ if ($theme->variants() && ! array_key_exists($variant, $theme->variants())) {
+ $variant = array_key_first($theme->variants());
+ }
return ThemeSetting::query()->updateOrCreate(
['theme_key' => $theme->key()],
@@ -74,7 +88,10 @@ final class ThemeSettings
private function stored(Theme $theme): ?ThemeSetting
{
- try { return ThemeSetting::query()->where('theme_key', $theme->key())->first(); }
- catch (QueryException) { return null; }
+ try {
+ return ThemeSetting::query()->where('theme_key', $theme->key())->first();
+ } catch (QueryException) {
+ return null;
+ }
}
}
diff --git a/Modules/Stylist/Tests/Feature/ThemeManifestTest.php b/Modules/Stylist/Tests/Feature/ThemeManifestTest.php
index 011738e..11d681e 100644
--- a/Modules/Stylist/Tests/Feature/ThemeManifestTest.php
+++ b/Modules/Stylist/Tests/Feature/ThemeManifestTest.php
@@ -54,6 +54,7 @@ final class ThemeManifestTest extends TestCase
$this->expectException(InvalidThemeManifestException::class);
app(Loader::class)->fromPath($path);
}
+
public function test_phase_one_manifest_optional_capabilities_are_normalised(): void
{
$path = $this->themesPath.'/design-gallery';
@@ -153,5 +154,4 @@ final class ThemeManifestTest extends TestCase
$this->assertSame(['anything' => true], $theme->meta()['future_builder_metadata']);
}
-
}
diff --git a/Modules/Stylist/Tests/Feature/ThemePackageRoundTripTest.php b/Modules/Stylist/Tests/Feature/ThemePackageRoundTripTest.php
index 5fa902e..633b3dc 100644
--- a/Modules/Stylist/Tests/Feature/ThemePackageRoundTripTest.php
+++ b/Modules/Stylist/Tests/Feature/ThemePackageRoundTripTest.php
@@ -12,6 +12,7 @@ use ZipArchive;
final class ThemePackageRoundTripTest extends TestCase
{
private string $themesPath;
+
private string $workspace;
protected function setUp(): void
@@ -58,7 +59,7 @@ final class ThemePackageRoundTripTest extends TestCase
file_put_contents($source.'/views/home.blade.php', 'Design Gallery works');
$zipPath = $this->workspace.'/design-gallery.zip';
- $zip = new ZipArchive();
+ $zip = new ZipArchive;
$this->assertTrue($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE));
foreach (app(Filesystem::class)->allFiles($source) as $file) {
$zip->addFile($file->getPathname(), 'design-gallery/'.$file->getRelativePathname());
diff --git a/Modules/Stylist/Theme/ManifestValidator.php b/Modules/Stylist/Theme/ManifestValidator.php
index 3612420..3e33821 100644
--- a/Modules/Stylist/Theme/ManifestValidator.php
+++ b/Modules/Stylist/Theme/ManifestValidator.php
@@ -104,8 +104,8 @@ final class ManifestValidator
* Compact lists such as ["light", "dark"] are accepted and converted to
* keyed empty definitions. Object definitions remain unchanged.
*
- * @param array $value
- * @param array $errors
+ * @param array $value
+ * @param array $errors
* @return array>
*/
private function normaliseDefinitions(array $value, string $field, array &$errors, bool $allowCompact = true): array
@@ -125,10 +125,12 @@ final class ManifestValidator
foreach ($value as $name) {
if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name)) {
$errors[] = "Every {$field} entry must be a valid lowercase key.";
+
continue;
}
$normalised[$name] = [];
}
+
return $normalised;
}
@@ -136,6 +138,7 @@ final class ManifestValidator
foreach ($value as $name => $definition) {
if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name) || ! is_array($definition)) {
$errors[] = "Every {$field} definition must use a valid lowercase key and an object value.";
+
continue;
}
$normalised[$name] = $definition;
@@ -145,8 +148,8 @@ final class ManifestValidator
}
/**
- * @param array $value
- * @param array $errors
+ * @param array $value
+ * @param array $errors
* @return array
*/
private function normaliseCapabilities(array $value, array &$errors): array
@@ -156,6 +159,7 @@ final class ManifestValidator
foreach ($value as $name => $enabled) {
if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name) || ! is_bool($enabled)) {
$errors[] = 'Every capability must use a valid lowercase key and a boolean value.';
+
continue;
}
diff --git a/Modules/Stylist/Theme/Stylist.php b/Modules/Stylist/Theme/Stylist.php
index 608bb93..dbc7114 100644
--- a/Modules/Stylist/Theme/Stylist.php
+++ b/Modules/Stylist/Theme/Stylist.php
@@ -13,10 +13,13 @@ final class Stylist
public function current(): Theme
{
$requested = $this->resolver->resolve();
- try { return $this->loader->find($requested); }
- catch (ThemeNotFoundException) {
+ try {
+ return $this->loader->find($requested);
+ } catch (ThemeNotFoundException) {
$fallback = (string) config('stylist.default_theme', 'default');
- if ($requested !== $fallback) return $this->loader->find($fallback);
+ if ($requested !== $fallback) {
+ return $this->loader->find($fallback);
+ }
throw ThemeNotFoundException::forKey($requested);
}
}
@@ -44,21 +47,45 @@ final class Stylist
);
}
- public function settings(): array { return $this->settings->get($this->current()); }
- public function variant(): string { return $this->settings->variant($this->current()); }
- public function appearance(): string { return $this->settings->appearance($this->current()); }
- public function accent(): string { return $this->settings->accent($this->current()); }
- public function all() { return $this->loader->all(); }
+ public function settings(): array
+ {
+ return $this->settings->get($this->current());
+ }
+
+ public function variant(): string
+ {
+ return $this->settings->variant($this->current());
+ }
+
+ public function appearance(): string
+ {
+ return $this->settings->appearance($this->current());
+ }
+
+ public function accent(): string
+ {
+ return $this->settings->accent($this->current());
+ }
+
+ public function all()
+ {
+ return $this->loader->all();
+ }
private function inheritedVariables(Theme $theme, array $seen = []): array
{
- if (in_array($theme->key(), $seen, true)) return [];
+ if (in_array($theme->key(), $seen, true)) {
+ return [];
+ }
$seen[] = $theme->key();
$parent = [];
if ($theme->parent()) {
- try { $parent = $this->inheritedVariables($this->loader->find($theme->parent()), $seen); }
- catch (ThemeNotFoundException) {}
+ try {
+ $parent = $this->inheritedVariables($this->loader->find($theme->parent()), $seen);
+ } catch (ThemeNotFoundException) {
+ }
}
+
return array_replace($parent, $theme->variables());
}
}
diff --git a/Modules/Stylist/Theme/Theme.php b/Modules/Stylist/Theme/Theme.php
index c2d1205..f78f463 100644
--- a/Modules/Stylist/Theme/Theme.php
+++ b/Modules/Stylist/Theme/Theme.php
@@ -14,33 +14,143 @@ final class Theme
private readonly array $meta = [],
) {}
- public function key(): string { return $this->key; }
- public function name(): string { return $this->name; }
- public function path(): string { return $this->path; }
- public function stylesheet(): string { return $this->stylesheet; }
- public function script(): string { return $this->script; }
- public function variables(): array { return $this->variables; }
- public function meta(): array { return $this->meta; }
- public function manifestVersion(): int { return (int) ($this->meta['manifest_version'] ?? 1); }
- public function version(): string { return (string) ($this->meta['version'] ?? '1.0.0'); }
- public function description(): string { return (string) ($this->meta['description'] ?? ''); }
- public function author(): string { return (string) ($this->meta['author'] ?? ''); }
- public function parent(): ?string { $parent = $this->meta['extends'] ?? null; return is_string($parent) && $parent !== '' ? $parent : null; }
- public function preview(): ?string { $preview = $this->meta['preview'] ?? null; return is_string($preview) && $preview !== '' ? $preview : null; }
- public function variants(): array { return (array) ($this->meta['variants'] ?? []); }
- public function appearances(): array { return (array) ($this->meta['appearances'] ?? []); }
- public function accents(): array { return (array) ($this->meta['accents'] ?? []); }
- public function defaultAppearance(): string { return (string) ($this->meta['default_appearance'] ?? array_key_first($this->appearances()) ?? ''); }
- public function defaultAccent(): string { return (string) ($this->meta['default_accent'] ?? array_key_first($this->accents()) ?? ''); }
- public function settingsSchema(): array { return (array) ($this->meta['settings'] ?? []); }
- public function seo(): array { return (array) ($this->meta['seo'] ?? []); }
- public function entryView(): string { return (string) ($this->meta['entry_view'] ?? 'home'); }
- public function capabilities(): array { return (array) ($this->meta['capabilities'] ?? []); }
- public function hasCapability(string $feature): bool { return (bool) ($this->capabilities()[$feature] ?? false); }
- public function supports(string $feature): bool { return (bool) data_get($this->meta, 'supports.'.$feature, $this->hasCapability($feature)); }
- public function has(string $relativePath): bool { return is_file($this->path.DIRECTORY_SEPARATOR.ltrim($relativePath, '/')); }
- public function absolutePath(string $relativePath): string { return $this->path.DIRECTORY_SEPARATOR.ltrim($relativePath, '/'); }
+ public function key(): string
+ {
+ return $this->key;
+ }
+
+ public function name(): string
+ {
+ return $this->name;
+ }
+
+ public function path(): string
+ {
+ return $this->path;
+ }
+
+ public function stylesheet(): string
+ {
+ return $this->stylesheet;
+ }
+
+ public function script(): string
+ {
+ return $this->script;
+ }
+
+ public function variables(): array
+ {
+ return $this->variables;
+ }
+
+ public function meta(): array
+ {
+ return $this->meta;
+ }
+
+ public function manifestVersion(): int
+ {
+ return (int) ($this->meta['manifest_version'] ?? 1);
+ }
+
+ public function version(): string
+ {
+ return (string) ($this->meta['version'] ?? '1.0.0');
+ }
+
+ public function description(): string
+ {
+ return (string) ($this->meta['description'] ?? '');
+ }
+
+ public function author(): string
+ {
+ return (string) ($this->meta['author'] ?? '');
+ }
+
+ public function parent(): ?string
+ {
+ $parent = $this->meta['extends'] ?? null;
+
+ return is_string($parent) && $parent !== '' ? $parent : null;
+ }
+
+ public function preview(): ?string
+ {
+ $preview = $this->meta['preview'] ?? null;
+
+ return is_string($preview) && $preview !== '' ? $preview : null;
+ }
+
+ public function variants(): array
+ {
+ return (array) ($this->meta['variants'] ?? []);
+ }
+
+ public function appearances(): array
+ {
+ return (array) ($this->meta['appearances'] ?? []);
+ }
+
+ public function accents(): array
+ {
+ return (array) ($this->meta['accents'] ?? []);
+ }
+
+ public function defaultAppearance(): string
+ {
+ return (string) ($this->meta['default_appearance'] ?? array_key_first($this->appearances()) ?? '');
+ }
+
+ public function defaultAccent(): string
+ {
+ return (string) ($this->meta['default_accent'] ?? array_key_first($this->accents()) ?? '');
+ }
+
+ public function settingsSchema(): array
+ {
+ return (array) ($this->meta['settings'] ?? []);
+ }
+
+ public function seo(): array
+ {
+ return (array) ($this->meta['seo'] ?? []);
+ }
+
+ public function entryView(): string
+ {
+ return (string) ($this->meta['entry_view'] ?? 'home');
+ }
+
+ public function capabilities(): array
+ {
+ return (array) ($this->meta['capabilities'] ?? []);
+ }
+
+ public function hasCapability(string $feature): bool
+ {
+ return (bool) ($this->capabilities()[$feature] ?? false);
+ }
+
+ public function supports(string $feature): bool
+ {
+ return (bool) data_get($this->meta, 'supports.'.$feature, $this->hasCapability($feature));
+ }
+
+ public function has(string $relativePath): bool
+ {
+ return is_file($this->path.DIRECTORY_SEPARATOR.ltrim($relativePath, '/'));
+ }
+
+ public function absolutePath(string $relativePath): string
+ {
+ return $this->path.DIRECTORY_SEPARATOR.ltrim($relativePath, '/');
+ }
/** @deprecated Theme assets are served through UrlGenerator. */
- public function assetsPath(): string { return 'stylist/theme-assets/'.$this->key; }
+ public function assetsPath(): string
+ {
+ return 'stylist/theme-assets/'.$this->key;
+ }
}
diff --git a/Modules/Stylist/Theme/UrlGenerator.php b/Modules/Stylist/Theme/UrlGenerator.php
index e762710..77d08f8 100644
--- a/Modules/Stylist/Theme/UrlGenerator.php
+++ b/Modules/Stylist/Theme/UrlGenerator.php
@@ -5,10 +5,16 @@ namespace Modules\Stylist\Theme;
final class UrlGenerator
{
public function __construct(private readonly Stylist $stylist) {}
- public function asset(string $path): string { return $this->forTheme($this->stylist->current(), $path); }
+
+ public function asset(string $path): string
+ {
+ return $this->forTheme($this->stylist->current(), $path);
+ }
+
public function forTheme(Theme|string $theme, string $path): string
{
$key = $theme instanceof Theme ? $theme->key() : $theme;
+
return route('stylist.theme.asset', ['theme' => $key, 'path' => ltrim($path, '/')]);
}
}
diff --git a/Modules/Tags/Config/config.php b/Modules/Tags/Config/config.php
index 75e8829..4f63319 100644
--- a/Modules/Tags/Config/config.php
+++ b/Modules/Tags/Config/config.php
@@ -1,2 +1,3 @@
'Tags'];
diff --git a/Modules/Tags/Database/Migrations/2026_07_24_030200_create_tags_table.php b/Modules/Tags/Database/Migrations/2026_07_24_030200_create_tags_table.php
index b4fc39a..8a969e7 100644
--- a/Modules/Tags/Database/Migrations/2026_07_24_030200_create_tags_table.php
+++ b/Modules/Tags/Database/Migrations/2026_07_24_030200_create_tags_table.php
@@ -1,3 +1,25 @@
id(); $table->string('name'); $table->string('slug')->unique(); $table->text('description')->nullable(); $table->unsignedInteger('usage_count')->default(0); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('tags'); } };
+
+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('tags', function (Blueprint $table): void {
+ $table->id();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->text('description')->nullable();
+ $table->unsignedInteger('usage_count')->default(0);
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('tags');
+ }
+};
diff --git a/Modules/Tags/Http/Controllers/TagController.php b/Modules/Tags/Http/Controllers/TagController.php
index a32d431..08e0a08 100644
--- a/Modules/Tags/Http/Controllers/TagController.php
+++ b/Modules/Tags/Http/Controllers/TagController.php
@@ -1,4 +1,48 @@
Tag::orderByDesc('usage_count')->paginate(30)]); } public function create(): View { return view('tags::admin.form'); } public function store(Request $request): RedirectResponse { Tag::create($request->validate(['name'=>['required','string','max:255'],'slug'=>['required','string','max:255','unique:tags,slug'],'description'=>['nullable','string']])); return redirect()->route('administration.tags.index')->with('status','Tag created.'); } public function edit(Tag $tag): View { return view('tags::admin.form',['record'=>$tag]); } public function update(Request $request, Tag $tag): RedirectResponse { $tag->update($request->validate(['name'=>['required','string','max:255'],'slug'=>['required','string','max:255','unique:tags,slug,'.$tag->id],'description'=>['nullable','string']])); return redirect()->route('administration.tags.index')->with('status','Tag updated.'); } public function destroy(Tag $tag): RedirectResponse { $tag->delete(); return back()->with('status','Tag deleted.'); } }
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\View\View;
+use Modules\Tags\Models\Tag;
+
+final class TagController extends Controller
+{
+ public function index(): View
+ {
+ return view('tags::admin.index', ['records' => Tag::orderByDesc('usage_count')->paginate(30)]);
+ }
+
+ public function create(): View
+ {
+ return view('tags::admin.form');
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ Tag::create($request->validate(['name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255', 'unique:tags,slug'], 'description' => ['nullable', 'string']]));
+
+ return redirect()->route('administration.tags.index')->with('status', 'Tag created.');
+ }
+
+ public function edit(Tag $tag): View
+ {
+ return view('tags::admin.form', ['record' => $tag]);
+ }
+
+ public function update(Request $request, Tag $tag): RedirectResponse
+ {
+ $tag->update($request->validate(['name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255', 'unique:tags,slug,'.$tag->id], 'description' => ['nullable', 'string']]));
+
+ return redirect()->route('administration.tags.index')->with('status', 'Tag updated.');
+ }
+
+ public function destroy(Tag $tag): RedirectResponse
+ {
+ $tag->delete();
+
+ return back()->with('status', 'Tag deleted.');
+ }
+}
diff --git a/Modules/Tags/Models/Tag.php b/Modules/Tags/Models/Tag.php
index 3077f09..da7b39c 100644
--- a/Modules/Tags/Models/Tag.php
+++ b/Modules/Tags/Models/Tag.php
@@ -1,3 +1,15 @@
'integer']; } }
+
+namespace Modules\Tags\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+final class Tag extends Model
+{
+ protected $fillable = ['name', 'slug', 'description', 'usage_count'];
+
+ protected function casts(): array
+ {
+ return ['usage_count' => 'integer'];
+ }
+}
diff --git a/Modules/Tags/Providers/RouteServiceProvider.php b/Modules/Tags/Providers/RouteServiceProvider.php
index 4d09871..3605636 100644
--- a/Modules/Tags/Providers/RouteServiceProvider.php
+++ b/Modules/Tags/Providers/RouteServiceProvider.php
@@ -1,7 +1,10 @@
app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Tags', 'Config/config.php'), 'tags');
}
+
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Tags', 'Database/Migrations'));
diff --git a/Modules/Tags/Routes/web.php b/Modules/Tags/Routes/web.php
index 3face93..ca502b4 100644
--- a/Modules/Tags/Routes/web.php
+++ b/Modules/Tags/Routes/web.php
@@ -1,3 +1,8 @@
prefix('administration/tags')->name('administration.tags.')->group(function (): void { Route::resource('/', TagController::class)->parameters([''=>'tag'])->except('show'); });
+
+use Illuminate\Support\Facades\Route;
+use Modules\Tags\Http\Controllers\TagController;
+
+Route::middleware(['auth', 'verified'])->prefix('administration/tags')->name('administration.tags.')->group(function (): void {
+ Route::resource('/', TagController::class)->parameters(['' => 'tag'])->except('show');
+});
diff --git a/Modules/Tags/Tests/Feature/ModuleBootTest.php b/Modules/Tags/Tests/Feature/ModuleBootTest.php
index 2d9807c..1bc2dd9 100644
--- a/Modules/Tags/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Tags/Tests/Feature/ModuleBootTest.php
@@ -1,6 +1,9 @@
id();
+return new class extends Migration
+{
+ public function up(): void
+ {
+ Schema::create('team_members', function (Blueprint $table): void {
+ $table->id();
$table->string('name');
$table->string('job_title')->nullable();
$table->text('biography')->nullable();
$table->string('email')->nullable();
$table->unsignedInteger('sort_order')->default(0);
$table->boolean('enabled')->default(true);
- $table->timestamps(); $table->softDeletes(); }); }
- public function down(): void { Schema::dropIfExists('team_members'); }
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('team_members');
+ }
};
diff --git a/Modules/Team/Http/Controllers/TeamMemberController.php b/Modules/Team/Http/Controllers/TeamMemberController.php
index 2de2eb8..bd62536 100644
--- a/Modules/Team/Http/Controllers/TeamMemberController.php
+++ b/Modules/Team/Http/Controllers/TeamMemberController.php
@@ -10,20 +10,53 @@ use Modules\Team\Models\TeamMember;
final class TeamMemberController extends Controller
{
- public function index(): View { return view('team::admin.index', ['records' => TeamMember::query()->latest()->paginate(20)]); }
- public function create(): View { return view('team::admin.form'); }
- public function store(Request $request): RedirectResponse { $data = $request->validate(['name' => ['required', 'string', 'max:255'],
+ public function index(): View
+ {
+ return view('team::admin.index', ['records' => TeamMember::query()->latest()->paginate(20)]);
+ }
+
+ public function create(): View
+ {
+ return view('team::admin.form');
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'],
'job_title' => ['nullable', 'string'],
'biography' => ['nullable', 'string'],
'email' => ['nullable', 'string'],
'sort_order' => ['nullable', 'integer', 'min:0'],
- 'enabled' => ['sometimes', 'boolean']]); $data['enabled'] = $request->boolean('enabled'); TeamMember::create($data); return redirect()->route('team.index')->with('status', 'TeamMember created.'); }
- public function edit(TeamMember $teamMember): View { return view('team::admin.form', ['record' => $teamMember]); }
- public function update(Request $request, TeamMember $teamMember): RedirectResponse { $data = $request->validate(['name' => ['required', 'string', 'max:255'],
+ 'enabled' => ['sometimes', 'boolean']]);
+ $data['enabled'] = $request->boolean('enabled');
+ TeamMember::create($data);
+
+ return redirect()->route('team.index')->with('status', 'TeamMember created.');
+ }
+
+ public function edit(TeamMember $teamMember): View
+ {
+ return view('team::admin.form', ['record' => $teamMember]);
+ }
+
+ public function update(Request $request, TeamMember $teamMember): RedirectResponse
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'],
'job_title' => ['nullable', 'string'],
'biography' => ['nullable', 'string'],
'email' => ['nullable', 'string'],
'sort_order' => ['nullable', 'integer', 'min:0'],
- 'enabled' => ['sometimes', 'boolean']]); $data['enabled'] = $request->boolean('enabled'); $teamMember->update($data); return redirect()->route('team.index')->with('status', 'TeamMember updated.'); }
- public function destroy(TeamMember $teamMember): RedirectResponse { $teamMember->delete(); return back()->with('status', 'TeamMember deleted.'); }
+ 'enabled' => ['sometimes', 'boolean']]);
+ $data['enabled'] = $request->boolean('enabled');
+ $teamMember->update($data);
+
+ return redirect()->route('team.index')->with('status', 'TeamMember updated.');
+ }
+
+ public function destroy(TeamMember $teamMember): RedirectResponse
+ {
+ $teamMember->delete();
+
+ return back()->with('status', 'TeamMember deleted.');
+ }
}
diff --git a/Modules/Team/Models/TeamMember.php b/Modules/Team/Models/TeamMember.php
index fd7b518..1ada6eb 100644
--- a/Modules/Team/Models/TeamMember.php
+++ b/Modules/Team/Models/TeamMember.php
@@ -9,12 +9,16 @@ use Illuminate\Database\Eloquent\SoftDeletes;
final class TeamMember extends Model
{
use HasFactory, SoftDeletes;
- protected $table = 'team_members';
- protected $fillable = ['name', 'job_title', 'biography', 'email', 'sort_order', 'enabled'];
-
- protected function casts(): array { return [
- 'sort_order' => 'integer',
- 'enabled' => 'boolean'
- ]; }
+ protected $table = 'team_members';
+
+ protected $fillable = ['name', 'job_title', 'biography', 'email', 'sort_order', 'enabled'];
+
+ protected function casts(): array
+ {
+ return [
+ 'sort_order' => 'integer',
+ 'enabled' => 'boolean',
+ ];
+ }
}
diff --git a/Modules/Team/Support/ModuleInfo.php b/Modules/Team/Support/ModuleInfo.php
index 3189d8a..4fcd387 100644
--- a/Modules/Team/Support/ModuleInfo.php
+++ b/Modules/Team/Support/ModuleInfo.php
@@ -2,4 +2,15 @@
namespace Modules\Team\Support;
-final class ModuleInfo { public static function name(): string { return 'Team'; } public static function nameLower(): string { return 'team'; } }
+final class ModuleInfo
+{
+ public static function name(): string
+ {
+ return 'Team';
+ }
+
+ public static function nameLower(): string
+ {
+ return 'team';
+ }
+}
diff --git a/Modules/Team/Tests/Feature/ModuleBootTest.php b/Modules/Team/Tests/Feature/ModuleBootTest.php
index 0a12a2e..296ba82 100644
--- a/Modules/Team/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Team/Tests/Feature/ModuleBootTest.php
@@ -2,4 +2,6 @@
use Illuminate\Support\Facades\Route;
-it('registers the Team administration route', function (): void { expect(Route::has('administration.team.index'))->toBeTrue(); });
+it('registers the Team administration route', function (): void {
+ expect(Route::has('administration.team.index'))->toBeTrue();
+});
diff --git a/Modules/Testimonials/Database/Migrations/2026_07_24_000900_create_testimonials_table.php b/Modules/Testimonials/Database/Migrations/2026_07_24_000900_create_testimonials_table.php
index 43bed44..bebe283 100644
--- a/Modules/Testimonials/Database/Migrations/2026_07_24_000900_create_testimonials_table.php
+++ b/Modules/Testimonials/Database/Migrations/2026_07_24_000900_create_testimonials_table.php
@@ -4,13 +4,24 @@ 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('testimonials', function (Blueprint $table): void { $table->id();
+return new class extends Migration
+{
+ public function up(): void
+ {
+ Schema::create('testimonials', function (Blueprint $table): void {
+ $table->id();
$table->string('name');
$table->string('company')->nullable();
-
+
$table->unsignedInteger('rating')->default(5);
$table->boolean('enabled')->default(true);
- $table->timestamps(); $table->softDeletes(); }); }
- public function down(): void { Schema::dropIfExists('testimonials'); }
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('testimonials');
+ }
};
diff --git a/Modules/Testimonials/Http/Controllers/TestimonialController.php b/Modules/Testimonials/Http/Controllers/TestimonialController.php
index b5d44a3..676166e 100644
--- a/Modules/Testimonials/Http/Controllers/TestimonialController.php
+++ b/Modules/Testimonials/Http/Controllers/TestimonialController.php
@@ -10,18 +10,51 @@ use Modules\Testimonials\Models\Testimonial;
final class TestimonialController extends Controller
{
- public function index(): View { return view('testimonials::admin.index', ['records' => Testimonial::query()->latest()->paginate(20)]); }
- public function create(): View { return view('testimonials::admin.form'); }
- public function store(Request $request): RedirectResponse { $data = $request->validate(['name' => ['required', 'string', 'max:255'],
+ public function index(): View
+ {
+ return view('testimonials::admin.index', ['records' => Testimonial::query()->latest()->paginate(20)]);
+ }
+
+ public function create(): View
+ {
+ return view('testimonials::admin.form');
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'],
'company' => ['nullable', 'string'],
'content' => ['required', 'string'],
'rating' => ['nullable', 'integer', 'min:0'],
- 'enabled' => ['sometimes', 'boolean']]); $data['enabled'] = $request->boolean('enabled'); Testimonial::create($data); return redirect()->route('testimonials.index')->with('status', 'Testimonial created.'); }
- public function edit(Testimonial $testimonial): View { return view('testimonials::admin.form', ['record' => $testimonial]); }
- public function update(Request $request, Testimonial $testimonial): RedirectResponse { $data = $request->validate(['name' => ['required', 'string', 'max:255'],
+ 'enabled' => ['sometimes', 'boolean']]);
+ $data['enabled'] = $request->boolean('enabled');
+ Testimonial::create($data);
+
+ return redirect()->route('testimonials.index')->with('status', 'Testimonial created.');
+ }
+
+ public function edit(Testimonial $testimonial): View
+ {
+ return view('testimonials::admin.form', ['record' => $testimonial]);
+ }
+
+ public function update(Request $request, Testimonial $testimonial): RedirectResponse
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'],
'company' => ['nullable', 'string'],
'content' => ['required', 'string'],
'rating' => ['nullable', 'integer', 'min:0'],
- 'enabled' => ['sometimes', 'boolean']]); $data['enabled'] = $request->boolean('enabled'); $testimonial->update($data); return redirect()->route('testimonials.index')->with('status', 'Testimonial updated.'); }
- public function destroy(Testimonial $testimonial): RedirectResponse { $testimonial->delete(); return back()->with('status', 'Testimonial deleted.'); }
+ 'enabled' => ['sometimes', 'boolean']]);
+ $data['enabled'] = $request->boolean('enabled');
+ $testimonial->update($data);
+
+ return redirect()->route('testimonials.index')->with('status', 'Testimonial updated.');
+ }
+
+ public function destroy(Testimonial $testimonial): RedirectResponse
+ {
+ $testimonial->delete();
+
+ return back()->with('status', 'Testimonial deleted.');
+ }
}
diff --git a/Modules/Testimonials/Models/Testimonial.php b/Modules/Testimonials/Models/Testimonial.php
index 642d9a2..590160f 100644
--- a/Modules/Testimonials/Models/Testimonial.php
+++ b/Modules/Testimonials/Models/Testimonial.php
@@ -9,12 +9,16 @@ use Illuminate\Database\Eloquent\SoftDeletes;
final class Testimonial extends Model
{
use HasFactory, SoftDeletes;
- protected $table = 'testimonials';
- protected $fillable = ['name', 'company', 'content', 'rating', 'enabled'];
-
- protected function casts(): array { return [
- 'rating' => 'integer',
- 'enabled' => 'boolean'
- ]; }
+ protected $table = 'testimonials';
+
+ protected $fillable = ['name', 'company', 'content', 'rating', 'enabled'];
+
+ protected function casts(): array
+ {
+ return [
+ 'rating' => 'integer',
+ 'enabled' => 'boolean',
+ ];
+ }
}
diff --git a/Modules/Testimonials/Support/ModuleInfo.php b/Modules/Testimonials/Support/ModuleInfo.php
index 57474b2..afc0468 100644
--- a/Modules/Testimonials/Support/ModuleInfo.php
+++ b/Modules/Testimonials/Support/ModuleInfo.php
@@ -2,4 +2,15 @@
namespace Modules\Testimonials\Support;
-final class ModuleInfo { public static function name(): string { return 'Testimonials'; } public static function nameLower(): string { return 'testimonials'; } }
+final class ModuleInfo
+{
+ public static function name(): string
+ {
+ return 'Testimonials';
+ }
+
+ public static function nameLower(): string
+ {
+ return 'testimonials';
+ }
+}
diff --git a/Modules/Testimonials/Tests/Feature/ModuleBootTest.php b/Modules/Testimonials/Tests/Feature/ModuleBootTest.php
index 583e063..3caefc7 100644
--- a/Modules/Testimonials/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Testimonials/Tests/Feature/ModuleBootTest.php
@@ -2,4 +2,6 @@
use Illuminate\Support\Facades\Route;
-it('registers the Testimonials administration route', function (): void { expect(Route::has('administration.testimonials.index'))->toBeTrue(); });
+it('registers the Testimonials administration route', function (): void {
+ expect(Route::has('administration.testimonials.index'))->toBeTrue();
+});
diff --git a/Modules/Users/Http/Controllers/UserController.php b/Modules/Users/Http/Controllers/UserController.php
index 6bf2b99..79191a5 100644
--- a/Modules/Users/Http/Controllers/UserController.php
+++ b/Modules/Users/Http/Controllers/UserController.php
@@ -10,22 +10,47 @@ use Spatie\Permission\Models\Role;
final class UserController extends Controller
{
- public function index() { return view('stylist-theme-admin::administration.users.index', ['users' => User::with('roles')->latest()->paginate(20)]); }
- public function create() { return view('stylist-theme-admin::administration.users.create', ['roles' => Role::orderBy('name')->pluck('name')]); }
- public function store(Request $request) {
- $data=$request->validate(['name'=>['required','string','max:255'],'email'=>['required','email','max:255','unique:users,email'],'password'=>['required','string','min:8','confirmed'],'roles'=>['array'],'roles.*'=>['string','exists:roles,name']]);
- $user=User::create($data); $user->syncRoles($data['roles'] ?? []);
- return redirect()->route('administration.users.index')->with('status','User created.');
+ public function index()
+ {
+ return view('stylist-theme-admin::administration.users.index', ['users' => User::with('roles')->latest()->paginate(20)]);
}
- public function edit(User $user) { return view('stylist-theme-admin::administration.users.edit', ['user'=>$user->load('roles'),'roles'=>Role::orderBy('name')->pluck('name')]); }
- public function update(Request $request, User $user) {
- $data=$request->validate(['name'=>['required','string','max:255'],'email'=>['required','email','max:255',Rule::unique('users','email')->ignore($user)],'password'=>['nullable','string','min:8','confirmed'],'roles'=>['array'],'roles.*'=>['string','exists:roles,name']]);
- if (blank($data['password'] ?? null)) unset($data['password']);
- $user->update($data); $user->syncRoles($data['roles'] ?? []);
- return redirect()->route('administration.users.index')->with('status','User updated.');
+
+ public function create()
+ {
+ return view('stylist-theme-admin::administration.users.create', ['roles' => Role::orderBy('name')->pluck('name')]);
}
- public function destroy(User $user) {
- abort_if($user->is(auth()->user()),422,'You cannot delete your own account.');
- $user->delete(); return redirect()->route('administration.users.index')->with('status','User deleted.');
+
+ public function store(Request $request)
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'password' => ['required', 'string', 'min:8', 'confirmed'], 'roles' => ['array'], 'roles.*' => ['string', 'exists:roles,name']]);
+ $user = User::create($data);
+ $user->syncRoles($data['roles'] ?? []);
+
+ return redirect()->route('administration.users.index')->with('status', 'User created.');
+ }
+
+ public function edit(User $user)
+ {
+ return view('stylist-theme-admin::administration.users.edit', ['user' => $user->load('roles'), 'roles' => Role::orderBy('name')->pluck('name')]);
+ }
+
+ public function update(Request $request, User $user)
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user)], 'password' => ['nullable', 'string', 'min:8', 'confirmed'], 'roles' => ['array'], 'roles.*' => ['string', 'exists:roles,name']]);
+ if (blank($data['password'] ?? null)) {
+ unset($data['password']);
+ }
+ $user->update($data);
+ $user->syncRoles($data['roles'] ?? []);
+
+ return redirect()->route('administration.users.index')->with('status', 'User updated.');
+ }
+
+ public function destroy(User $user)
+ {
+ abort_if($user->is(auth()->user()), 422, 'You cannot delete your own account.');
+ $user->delete();
+
+ return redirect()->route('administration.users.index')->with('status', 'User deleted.');
}
}
diff --git a/Modules/Users/Routes/web.php b/Modules/Users/Routes/web.php
index d8939b0..d4d0dc8 100644
--- a/Modules/Users/Routes/web.php
+++ b/Modules/Users/Routes/web.php
@@ -1,3 +1,8 @@
prefix('administration/users')->name('administration.users.')->group(function (): void { Route::resource('', UserController::class)->parameters([''=>'user'])->except(['show']); });
+
+use Illuminate\Support\Facades\Route;
+use Modules\Users\Http\Controllers\UserController;
+
+Route::middleware(['auth', 'verified'])->prefix('administration/users')->name('administration.users.')->group(function (): void {
+ Route::resource('', UserController::class)->parameters(['' => 'user'])->except(['show']);
+});
diff --git a/Modules/Users/Support/ModuleInfo.php b/Modules/Users/Support/ModuleInfo.php
index 8586949..6122d1c 100644
--- a/Modules/Users/Support/ModuleInfo.php
+++ b/Modules/Users/Support/ModuleInfo.php
@@ -4,6 +4,13 @@ namespace Modules\Users\Support;
final class ModuleInfo
{
- public static function name(): string { return 'Users'; }
- public static function nameLower(): string { return 'users'; }
+ public static function name(): string
+ {
+ return 'Users';
+ }
+
+ public static function nameLower(): string
+ {
+ return 'users';
+ }
}
diff --git a/Modules/Workflow/Concerns/HasWorkflow.php b/Modules/Workflow/Concerns/HasWorkflow.php
index 34ab708..d018a87 100644
--- a/Modules/Workflow/Concerns/HasWorkflow.php
+++ b/Modules/Workflow/Concerns/HasWorkflow.php
@@ -1,4 +1,19 @@
morphOne(WorkflowInstance::class,'subject'); } public function workflowLabel(): string { return (string) ($this->title ?? $this->name ?? class_basename($this).' #'.$this->getKey()); } }
+
+use Illuminate\Database\Eloquent\Relations\MorphOne;
+use Modules\Workflow\Models\WorkflowInstance;
+
+trait HasWorkflow
+{
+ public function workflowInstance(): MorphOne
+ {
+ return $this->morphOne(WorkflowInstance::class, 'subject');
+ }
+
+ public function workflowLabel(): string
+ {
+ return (string) ($this->title ?? $this->name ?? class_basename($this).' #'.$this->getKey());
+ }
+}
diff --git a/Modules/Workflow/Config/config.php b/Modules/Workflow/Config/config.php
index 33f0c2c..c61a662 100644
--- a/Modules/Workflow/Config/config.php
+++ b/Modules/Workflow/Config/config.php
@@ -1,2 +1,3 @@
'Workflow', 'default_due_days' => 3];
diff --git a/Modules/Workflow/Contracts/Workflowable.php b/Modules/Workflow/Contracts/Workflowable.php
index 5661475..9d76383 100644
--- a/Modules/Workflow/Contracts/Workflowable.php
+++ b/Modules/Workflow/Contracts/Workflowable.php
@@ -1,4 +1,12 @@
id(); $t->string('name'); $t->string('slug')->unique(); $t->text('description')->nullable(); $t->string('subject_type')->nullable()->index(); $t->boolean('is_active')->default(true); $t->timestamps(); });
- Schema::create('workflow_stages', function(Blueprint $t){ $t->id(); $t->foreignId('workflow_definition_id')->constrained()->cascadeOnDelete(); $t->string('name'); $t->string('slug'); $t->unsignedInteger('position')->default(0); $t->string('required_permission')->nullable(); $t->boolean('is_initial')->default(false); $t->boolean('is_terminal')->default(false); $t->boolean('allows_rejection')->default(true); $t->unsignedInteger('due_days')->nullable(); $t->timestamps(); $t->unique(['workflow_definition_id','slug']); });
- Schema::create('workflow_transitions', function(Blueprint $t){ $t->id(); $t->foreignId('workflow_definition_id')->constrained()->cascadeOnDelete(); $t->foreignId('from_stage_id')->constrained('workflow_stages')->cascadeOnDelete(); $t->foreignId('to_stage_id')->constrained('workflow_stages')->cascadeOnDelete(); $t->string('name'); $t->string('action'); $t->string('required_permission')->nullable(); $t->boolean('requires_comment')->default(false); $t->timestamps(); });
- Schema::create('workflow_instances', function(Blueprint $t){ $t->id(); $t->foreignId('workflow_definition_id')->constrained()->cascadeOnDelete(); $t->foreignId('workflow_stage_id')->constrained('workflow_stages'); $t->string('subject_type'); $t->unsignedBigInteger('subject_id'); $t->string('status')->default('active'); $t->foreignId('started_by')->nullable()->constrained('users')->nullOnDelete(); $t->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); $t->timestamp('due_at')->nullable(); $t->timestamp('completed_at')->nullable(); $t->timestamps(); $t->unique(['subject_type','subject_id']); $t->index(['status','workflow_stage_id']); });
- Schema::create('workflow_history', function(Blueprint $t){ $t->id(); $t->foreignId('workflow_instance_id')->constrained()->cascadeOnDelete(); $t->foreignId('from_stage_id')->nullable()->constrained('workflow_stages')->nullOnDelete(); $t->foreignId('to_stage_id')->nullable()->constrained('workflow_stages')->nullOnDelete(); $t->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); $t->string('action'); $t->text('comment')->nullable(); $t->json('metadata')->nullable(); $t->timestamps(); });
- }
- public function down(): void { Schema::dropIfExists('workflow_history'); Schema::dropIfExists('workflow_instances'); Schema::dropIfExists('workflow_transitions'); Schema::dropIfExists('workflow_stages'); Schema::dropIfExists('workflow_definitions'); }
+
+return new class extends Migration
+{
+ public function up(): void
+ {
+ Schema::create('workflow_definitions', function (Blueprint $t) {
+ $t->id();
+ $t->string('name');
+ $t->string('slug')->unique();
+ $t->text('description')->nullable();
+ $t->string('subject_type')->nullable()->index();
+ $t->boolean('is_active')->default(true);
+ $t->timestamps();
+ });
+ Schema::create('workflow_stages', function (Blueprint $t) {
+ $t->id();
+ $t->foreignId('workflow_definition_id')->constrained()->cascadeOnDelete();
+ $t->string('name');
+ $t->string('slug');
+ $t->unsignedInteger('position')->default(0);
+ $t->string('required_permission')->nullable();
+ $t->boolean('is_initial')->default(false);
+ $t->boolean('is_terminal')->default(false);
+ $t->boolean('allows_rejection')->default(true);
+ $t->unsignedInteger('due_days')->nullable();
+ $t->timestamps();
+ $t->unique(['workflow_definition_id', 'slug']);
+ });
+ Schema::create('workflow_transitions', function (Blueprint $t) {
+ $t->id();
+ $t->foreignId('workflow_definition_id')->constrained()->cascadeOnDelete();
+ $t->foreignId('from_stage_id')->constrained('workflow_stages')->cascadeOnDelete();
+ $t->foreignId('to_stage_id')->constrained('workflow_stages')->cascadeOnDelete();
+ $t->string('name');
+ $t->string('action');
+ $t->string('required_permission')->nullable();
+ $t->boolean('requires_comment')->default(false);
+ $t->timestamps();
+ });
+ Schema::create('workflow_instances', function (Blueprint $t) {
+ $t->id();
+ $t->foreignId('workflow_definition_id')->constrained()->cascadeOnDelete();
+ $t->foreignId('workflow_stage_id')->constrained('workflow_stages');
+ $t->string('subject_type');
+ $t->unsignedBigInteger('subject_id');
+ $t->string('status')->default('active');
+ $t->foreignId('started_by')->nullable()->constrained('users')->nullOnDelete();
+ $t->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete();
+ $t->timestamp('due_at')->nullable();
+ $t->timestamp('completed_at')->nullable();
+ $t->timestamps();
+ $t->unique(['subject_type', 'subject_id']);
+ $t->index(['status', 'workflow_stage_id']);
+ });
+ Schema::create('workflow_history', function (Blueprint $t) {
+ $t->id();
+ $t->foreignId('workflow_instance_id')->constrained()->cascadeOnDelete();
+ $t->foreignId('from_stage_id')->nullable()->constrained('workflow_stages')->nullOnDelete();
+ $t->foreignId('to_stage_id')->nullable()->constrained('workflow_stages')->nullOnDelete();
+ $t->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
+ $t->string('action');
+ $t->text('comment')->nullable();
+ $t->json('metadata')->nullable();
+ $t->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('workflow_history');
+ Schema::dropIfExists('workflow_instances');
+ Schema::dropIfExists('workflow_transitions');
+ Schema::dropIfExists('workflow_stages');
+ Schema::dropIfExists('workflow_definitions');
+ }
};
diff --git a/Modules/Workflow/Http/Controllers/WorkflowController.php b/Modules/Workflow/Http/Controllers/WorkflowController.php
index cface5c..62479b1 100644
--- a/Modules/Workflow/Http/Controllers/WorkflowController.php
+++ b/Modules/Workflow/Http/Controllers/WorkflowController.php
@@ -1,8 +1,44 @@
user()->can('workflow.view'),403); return view('workflow::admin.index',['definitions'=>WorkflowDefinition::with('stages')->latest()->get(),'queue'=>WorkflowInstance::with(['definition','stage','subject'])->where('status','active')->latest()->paginate(20),'completed'=>WorkflowInstance::where('status','completed')->count()]); }
- public function store(Request $request): RedirectResponse { abort_unless($request->user()->can('workflow.manage'),403); $data=$request->validate(['name'=>'required|string|max:120','description'=>'nullable|string','subject_type'=>'nullable|string|max:255']); $definition=WorkflowDefinition::create($data+['slug'=>Str::slug($data['name']),'is_active'=>true]); $draft=$definition->stages()->create(['name'=>'Draft','slug'=>'draft','position'=>0,'is_initial'=>true,'due_days'=>null]); $review=$definition->stages()->create(['name'=>'Review','slug'=>'review','position'=>10,'required_permission'=>'workflow.review','due_days'=>3]); $published=$definition->stages()->create(['name'=>'Approved','slug'=>'approved','position'=>20,'is_terminal'=>true,'allows_rejection'=>false]); $definition->transitions()->createMany([['from_stage_id'=>$draft->id,'to_stage_id'=>$review->id,'name'=>'Submit for review','action'=>'submit'],['from_stage_id'=>$review->id,'to_stage_id'=>$published->id,'name'=>'Approve','action'=>'approve','required_permission'=>'workflow.review'],['from_stage_id'=>$review->id,'to_stage_id'=>$draft->id,'name'=>'Request changes','action'=>'reject','required_permission'=>'workflow.review','requires_comment'=>true]]); return back()->with('success','Workflow created.'); }
- public function transition(Request $request, WorkflowInstance $instance, WorkflowManager $manager): RedirectResponse { abort_unless($request->user()->can('workflow.review'),403); $data=$request->validate(['action'=>'required|string','comment'=>'nullable|string|max:2000']); $manager->transition($instance,$data['action'],$request->user(),$data['comment']??null); return back()->with('success','Workflow updated.'); }
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use Illuminate\View\View;
+use Modules\Workflow\Models\WorkflowDefinition;
+use Modules\Workflow\Models\WorkflowInstance;
+use Modules\Workflow\Services\WorkflowManager;
+
+final class WorkflowController extends Controller
+{
+ public function index(): View
+ {
+ abort_unless(auth()->user()->can('workflow.view'), 403);
+
+ return view('workflow::admin.index', ['definitions' => WorkflowDefinition::with('stages')->latest()->get(), 'queue' => WorkflowInstance::with(['definition', 'stage', 'subject'])->where('status', 'active')->latest()->paginate(20), 'completed' => WorkflowInstance::where('status', 'completed')->count()]);
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ abort_unless($request->user()->can('workflow.manage'), 403);
+ $data = $request->validate(['name' => 'required|string|max:120', 'description' => 'nullable|string', 'subject_type' => 'nullable|string|max:255']);
+ $definition = WorkflowDefinition::create($data + ['slug' => Str::slug($data['name']), 'is_active' => true]);
+ $draft = $definition->stages()->create(['name' => 'Draft', 'slug' => 'draft', 'position' => 0, 'is_initial' => true, 'due_days' => null]);
+ $review = $definition->stages()->create(['name' => 'Review', 'slug' => 'review', 'position' => 10, 'required_permission' => 'workflow.review', 'due_days' => 3]);
+ $published = $definition->stages()->create(['name' => 'Approved', 'slug' => 'approved', 'position' => 20, 'is_terminal' => true, 'allows_rejection' => false]);
+ $definition->transitions()->createMany([['from_stage_id' => $draft->id, 'to_stage_id' => $review->id, 'name' => 'Submit for review', 'action' => 'submit'], ['from_stage_id' => $review->id, 'to_stage_id' => $published->id, 'name' => 'Approve', 'action' => 'approve', 'required_permission' => 'workflow.review'], ['from_stage_id' => $review->id, 'to_stage_id' => $draft->id, 'name' => 'Request changes', 'action' => 'reject', 'required_permission' => 'workflow.review', 'requires_comment' => true]]);
+
+ return back()->with('success', 'Workflow created.');
+ }
+
+ public function transition(Request $request, WorkflowInstance $instance, WorkflowManager $manager): RedirectResponse
+ {
+ abort_unless($request->user()->can('workflow.review'), 403);
+ $data = $request->validate(['action' => 'required|string', 'comment' => 'nullable|string|max:2000']);
+ $manager->transition($instance, $data['action'], $request->user(), $data['comment'] ?? null);
+
+ return back()->with('success', 'Workflow updated.');
+ }
}
diff --git a/Modules/Workflow/Models/WorkflowDefinition.php b/Modules/Workflow/Models/WorkflowDefinition.php
index d7140ee..2316842 100644
--- a/Modules/Workflow/Models/WorkflowDefinition.php
+++ b/Modules/Workflow/Models/WorkflowDefinition.php
@@ -1,4 +1,26 @@
'boolean']; } public function stages(): HasMany { return $this->hasMany(WorkflowStage::class)->orderBy('position'); } public function transitions(): HasMany { return $this->hasMany(WorkflowTransition::class); } }
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+
+final class WorkflowDefinition extends Model
+{
+ protected $fillable = ['name', 'slug', 'description', 'subject_type', 'is_active'];
+
+ protected function casts(): array
+ {
+ return ['is_active' => 'boolean'];
+ }
+
+ public function stages(): HasMany
+ {
+ return $this->hasMany(WorkflowStage::class)->orderBy('position');
+ }
+
+ public function transitions(): HasMany
+ {
+ return $this->hasMany(WorkflowTransition::class);
+ }
+}
diff --git a/Modules/Workflow/Models/WorkflowHistory.php b/Modules/Workflow/Models/WorkflowHistory.php
index 15f3fae..2b8786f 100644
--- a/Modules/Workflow/Models/WorkflowHistory.php
+++ b/Modules/Workflow/Models/WorkflowHistory.php
@@ -1,4 +1,17 @@
'array']; } }
+
+final class WorkflowHistory extends Model
+{
+ protected $table = 'workflow_history';
+
+ protected $fillable = ['workflow_instance_id', 'from_stage_id', 'to_stage_id', 'user_id', 'action', 'comment', 'metadata'];
+
+ protected function casts(): array
+ {
+ return ['metadata' => 'array'];
+ }
+}
diff --git a/Modules/Workflow/Models/WorkflowInstance.php b/Modules/Workflow/Models/WorkflowInstance.php
index 2d05a36..b3d1af4 100644
--- a/Modules/Workflow/Models/WorkflowInstance.php
+++ b/Modules/Workflow/Models/WorkflowInstance.php
@@ -1,4 +1,38 @@
'datetime','completed_at'=>'datetime']; } public function definition(): BelongsTo { return $this->belongsTo(WorkflowDefinition::class,'workflow_definition_id'); } public function stage(): BelongsTo { return $this->belongsTo(WorkflowStage::class,'workflow_stage_id'); } public function subject(): MorphTo { return $this->morphTo(); } public function history(): HasMany { return $this->hasMany(WorkflowHistory::class)->latest(); } }
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
+
+final class WorkflowInstance extends Model
+{
+ protected $fillable = ['workflow_definition_id', 'workflow_stage_id', 'subject_type', 'subject_id', 'status', 'started_by', 'assigned_to', 'due_at', 'completed_at'];
+
+ protected function casts(): array
+ {
+ return ['due_at' => 'datetime', 'completed_at' => 'datetime'];
+ }
+
+ public function definition(): BelongsTo
+ {
+ return $this->belongsTo(WorkflowDefinition::class, 'workflow_definition_id');
+ }
+
+ public function stage(): BelongsTo
+ {
+ return $this->belongsTo(WorkflowStage::class, 'workflow_stage_id');
+ }
+
+ public function subject(): MorphTo
+ {
+ return $this->morphTo();
+ }
+
+ public function history(): HasMany
+ {
+ return $this->hasMany(WorkflowHistory::class)->latest();
+ }
+}
diff --git a/Modules/Workflow/Models/WorkflowStage.php b/Modules/Workflow/Models/WorkflowStage.php
index 530c564..3d8a8ad 100644
--- a/Modules/Workflow/Models/WorkflowStage.php
+++ b/Modules/Workflow/Models/WorkflowStage.php
@@ -1,4 +1,21 @@
'boolean','is_terminal'=>'boolean','allows_rejection'=>'boolean']; } public function definition(): BelongsTo { return $this->belongsTo(WorkflowDefinition::class,'workflow_definition_id'); } }
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+final class WorkflowStage extends Model
+{
+ protected $fillable = ['workflow_definition_id', 'name', 'slug', 'position', 'required_permission', 'is_initial', 'is_terminal', 'allows_rejection', 'due_days'];
+
+ protected function casts(): array
+ {
+ return ['is_initial' => 'boolean', 'is_terminal' => 'boolean', 'allows_rejection' => 'boolean'];
+ }
+
+ public function definition(): BelongsTo
+ {
+ return $this->belongsTo(WorkflowDefinition::class, 'workflow_definition_id');
+ }
+}
diff --git a/Modules/Workflow/Models/WorkflowTransition.php b/Modules/Workflow/Models/WorkflowTransition.php
index dc0cc37..66bb87f 100644
--- a/Modules/Workflow/Models/WorkflowTransition.php
+++ b/Modules/Workflow/Models/WorkflowTransition.php
@@ -1,4 +1,15 @@
'boolean']; } }
+
+final class WorkflowTransition extends Model
+{
+ protected $fillable = ['workflow_definition_id', 'from_stage_id', 'to_stage_id', 'name', 'action', 'required_permission', 'requires_comment'];
+
+ protected function casts(): array
+ {
+ return ['requires_comment' => 'boolean'];
+ }
+}
diff --git a/Modules/Workflow/Providers/RouteServiceProvider.php b/Modules/Workflow/Providers/RouteServiceProvider.php
index b8c4fcf..4197f68 100644
--- a/Modules/Workflow/Providers/RouteServiceProvider.php
+++ b/Modules/Workflow/Providers/RouteServiceProvider.php
@@ -1,5 +1,15 @@
group(module_path('Workflow', 'Routes/web.php')); } }
+
+final class RouteServiceProvider extends ServiceProvider
+{
+ public function boot(): void
+ {
+ parent::boot();
+ Route::middleware('web')->group(module_path('Workflow', 'Routes/web.php'));
+ }
+}
diff --git a/Modules/Workflow/Providers/WorkflowServiceProvider.php b/Modules/Workflow/Providers/WorkflowServiceProvider.php
index b6d0f2c..ab10b81 100644
--- a/Modules/Workflow/Providers/WorkflowServiceProvider.php
+++ b/Modules/Workflow/Providers/WorkflowServiceProvider.php
@@ -1,8 +1,22 @@
mergeConfigFrom(module_path('Workflow','Config/config.php'),'workflow'); $this->app->singleton(WorkflowManager::class); $this->app->register(RouteServiceProvider::class); }
- public function boot(): void { $this->loadMigrationsFrom(module_path('Workflow','Database/Migrations')); $this->loadViewsFrom(module_path('Workflow','Resources/views'),'workflow'); }
+
+final class WorkflowServiceProvider extends ServiceProvider
+{
+ public function register(): void
+ {
+ $this->mergeConfigFrom(module_path('Workflow', 'Config/config.php'), 'workflow');
+ $this->app->singleton(WorkflowManager::class);
+ $this->app->register(RouteServiceProvider::class);
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Workflow', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Workflow', 'Resources/views'), 'workflow');
+ }
}
diff --git a/Modules/Workflow/Routes/web.php b/Modules/Workflow/Routes/web.php
index 24773fd..6653370 100644
--- a/Modules/Workflow/Routes/web.php
+++ b/Modules/Workflow/Routes/web.php
@@ -1,3 +1,10 @@
prefix('administration/workflows')->name('administration.workflows.')->group(function(){ Route::get('/',[WorkflowController::class,'index'])->name('index'); Route::post('/',[WorkflowController::class,'store'])->name('store'); Route::post('/{instance}/transition',[WorkflowController::class,'transition'])->name('transition'); });
+
+use Illuminate\Support\Facades\Route;
+use Modules\Workflow\Http\Controllers\WorkflowController;
+
+Route::middleware(['auth', 'verified'])->prefix('administration/workflows')->name('administration.workflows.')->group(function () {
+ Route::get('/', [WorkflowController::class, 'index'])->name('index');
+ Route::post('/', [WorkflowController::class, 'store'])->name('store');
+ Route::post('/{instance}/transition', [WorkflowController::class, 'transition'])->name('transition');
+});
diff --git a/Modules/Workflow/Services/WorkflowManager.php b/Modules/Workflow/Services/WorkflowManager.php
index 4033643..7b8dc1b 100644
--- a/Modules/Workflow/Services/WorkflowManager.php
+++ b/Modules/Workflow/Services/WorkflowManager.php
@@ -1,7 +1,46 @@
stages()->where('is_initial',true)->first() ?? $definition->stages()->firstOrFail(); $instance=WorkflowInstance::updateOrCreate(['subject_type'=>$subject->getMorphClass(),'subject_id'=>$subject->getKey()],['workflow_definition_id'=>$definition->getKey(),'workflow_stage_id'=>$stage->getKey(),'status'=>$stage->is_terminal?'completed':'active','started_by'=>$user?->getAuthIdentifier(),'due_at'=>$stage->due_days?now()->addDays($stage->due_days):null,'completed_at'=>$stage->is_terminal?now():null]); WorkflowHistory::create(['workflow_instance_id'=>$instance->id,'to_stage_id'=>$stage->id,'user_id'=>$user?->getAuthIdentifier(),'action'=>'started']); return $instance->fresh(['definition','stage']); }); }
- public function transition(WorkflowInstance $instance, string $action, ?Authenticatable $user=null, ?string $comment=null): WorkflowInstance { return DB::transaction(function() use($instance,$action,$user,$comment){ $transition=WorkflowTransition::where('workflow_definition_id',$instance->workflow_definition_id)->where('from_stage_id',$instance->workflow_stage_id)->where('action',$action)->first(); if(!$transition) throw ValidationException::withMessages(['action'=>'This transition is not available.']); if($transition->requires_comment && blank($comment)) throw ValidationException::withMessages(['comment'=>'A comment is required.']); if($transition->required_permission && (!$user || !method_exists($user,'can') || !$user->can($transition->required_permission))) abort(403); $target=\Modules\Workflow\Models\WorkflowStage::findOrFail($transition->to_stage_id); $from=$instance->workflow_stage_id; $instance->update(['workflow_stage_id'=>$target->id,'status'=>$target->is_terminal?'completed':'active','due_at'=>$target->due_days?now()->addDays($target->due_days):null,'completed_at'=>$target->is_terminal?now():null]); WorkflowHistory::create(['workflow_instance_id'=>$instance->id,'from_stage_id'=>$from,'to_stage_id'=>$target->id,'user_id'=>$user?->getAuthIdentifier(),'action'=>$action,'comment'=>$comment]); return $instance->fresh(['definition','stage','subject']); }); }
+
+use Illuminate\Contracts\Auth\Authenticatable;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\ValidationException;
+use Modules\Workflow\Models\WorkflowDefinition;
+use Modules\Workflow\Models\WorkflowHistory;
+use Modules\Workflow\Models\WorkflowInstance;
+use Modules\Workflow\Models\WorkflowStage;
+use Modules\Workflow\Models\WorkflowTransition;
+
+final class WorkflowManager
+{
+ public function start(Model $subject, WorkflowDefinition $definition, ?Authenticatable $user = null): WorkflowInstance
+ {
+ return DB::transaction(function () use ($subject, $definition, $user) {
+ $stage = $definition->stages()->where('is_initial', true)->first() ?? $definition->stages()->firstOrFail();
+ $instance = WorkflowInstance::updateOrCreate(['subject_type' => $subject->getMorphClass(), 'subject_id' => $subject->getKey()], ['workflow_definition_id' => $definition->getKey(), 'workflow_stage_id' => $stage->getKey(), 'status' => $stage->is_terminal ? 'completed' : 'active', 'started_by' => $user?->getAuthIdentifier(), 'due_at' => $stage->due_days ? now()->addDays($stage->due_days) : null, 'completed_at' => $stage->is_terminal ? now() : null]);
+ WorkflowHistory::create(['workflow_instance_id' => $instance->id, 'to_stage_id' => $stage->id, 'user_id' => $user?->getAuthIdentifier(), 'action' => 'started']);
+
+ return $instance->fresh(['definition', 'stage']);
+ });
+ }
+
+ public function transition(WorkflowInstance $instance, string $action, ?Authenticatable $user = null, ?string $comment = null): WorkflowInstance
+ {
+ return DB::transaction(function () use ($instance, $action, $user, $comment) {
+ $transition = WorkflowTransition::where('workflow_definition_id', $instance->workflow_definition_id)->where('from_stage_id', $instance->workflow_stage_id)->where('action', $action)->first();
+ if (! $transition) {
+ throw ValidationException::withMessages(['action' => 'This transition is not available.']);
+ } if ($transition->requires_comment && blank($comment)) {
+ throw ValidationException::withMessages(['comment' => 'A comment is required.']);
+ } if ($transition->required_permission && (! $user || ! method_exists($user, 'can') || ! $user->can($transition->required_permission))) {
+ abort(403);
+ } $target = WorkflowStage::findOrFail($transition->to_stage_id);
+ $from = $instance->workflow_stage_id;
+ $instance->update(['workflow_stage_id' => $target->id, 'status' => $target->is_terminal ? 'completed' : 'active', 'due_at' => $target->due_days ? now()->addDays($target->due_days) : null, 'completed_at' => $target->is_terminal ? now() : null]);
+ WorkflowHistory::create(['workflow_instance_id' => $instance->id, 'from_stage_id' => $from, 'to_stage_id' => $target->id, 'user_id' => $user?->getAuthIdentifier(), 'action' => $action, 'comment' => $comment]);
+
+ return $instance->fresh(['definition', 'stage', 'subject']);
+ });
+ }
}
diff --git a/Modules/Workflow/Tests/Feature/ModuleBootTest.php b/Modules/Workflow/Tests/Feature/ModuleBootTest.php
index 763e90d..e489747 100644
--- a/Modules/Workflow/Tests/Feature/ModuleBootTest.php
+++ b/Modules/Workflow/Tests/Feature/ModuleBootTest.php
@@ -1,4 +1,14 @@
assertTrue(app()->providerIsLoaded(\Modules\Workflow\Providers\WorkflowServiceProvider::class)); } }
+
+final class ModuleBootTest extends TestCase
+{
+ public function test_workflow_module_is_registered(): void
+ {
+ $this->assertTrue(app()->providerIsLoaded(WorkflowServiceProvider::class));
+ }
+}
diff --git a/app/Livewire/Settings/Appearance.php b/app/Livewire/Settings/Appearance.php
index 417b894..11a0025 100644
--- a/app/Livewire/Settings/Appearance.php
+++ b/app/Livewire/Settings/Appearance.php
@@ -16,5 +16,4 @@ class Appearance extends Component
return view('stylist-theme-common::livewire.settings.appearance')
->layout('stylist-theme-common::components.layouts.app');
}
-
}
diff --git a/app/Livewire/Settings/DeleteUserForm.php b/app/Livewire/Settings/DeleteUserForm.php
index c914080..0dcb1c8 100644
--- a/app/Livewire/Settings/DeleteUserForm.php
+++ b/app/Livewire/Settings/DeleteUserForm.php
@@ -2,9 +2,9 @@
namespace App\Livewire\Settings;
-use Illuminate\Contracts\View\View;
use App\Concerns\PasswordValidationRules;
use App\Livewire\Actions\Logout;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
@@ -32,5 +32,4 @@ class DeleteUserForm extends Component
{
return view('stylist-theme-common::livewire.settings.delete-user-form');
}
-
}
diff --git a/app/Livewire/Settings/Profile.php b/app/Livewire/Settings/Profile.php
index 721a09c..9780f8e 100644
--- a/app/Livewire/Settings/Profile.php
+++ b/app/Livewire/Settings/Profile.php
@@ -2,10 +2,10 @@
namespace App\Livewire\Settings;
-use Illuminate\Contracts\View\View;
use App\Concerns\ProfileValidationRules;
use Flux\Flux;
use Illuminate\Contracts\Auth\MustVerifyEmail;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
@@ -88,5 +88,4 @@ class Profile extends Component
return view('stylist-theme-common::livewire.settings.profile')
->layout('stylist-theme-common::components.layouts.app');
}
-
}
diff --git a/app/Livewire/Settings/Security.php b/app/Livewire/Settings/Security.php
index d2f781b..5bcb1ad 100644
--- a/app/Livewire/Settings/Security.php
+++ b/app/Livewire/Settings/Security.php
@@ -2,10 +2,10 @@
namespace App\Livewire\Settings;
-use Illuminate\Contracts\View\View;
use App\Concerns\PasswordValidationRules;
use Exception;
use Flux\Flux;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
@@ -316,5 +316,4 @@ class Security extends Component
return view('stylist-theme-common::livewire.settings.security')
->layout('stylist-theme-common::components.layouts.app');
}
-
}
diff --git a/app/Livewire/Settings/TwoFactor/RecoveryCodes.php b/app/Livewire/Settings/TwoFactor/RecoveryCodes.php
index 821b689..ac1c18c 100644
--- a/app/Livewire/Settings/TwoFactor/RecoveryCodes.php
+++ b/app/Livewire/Settings/TwoFactor/RecoveryCodes.php
@@ -2,8 +2,8 @@
namespace App\Livewire\Settings\TwoFactor;
-use Illuminate\Contracts\View\View;
use Exception;
+use Illuminate\Contracts\View\View;
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -54,5 +54,4 @@ class RecoveryCodes extends Component
{
return view('stylist-theme-common::livewire.settings.two-factor.recovery-codes');
}
-
}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 264dd38..217638d 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -10,7 +10,6 @@ use Illuminate\Validation\Rules\Password;
class AppServiceProvider extends ServiceProvider
{
-
/**
* Bootstrap any application services.
*/
diff --git a/bootstrap/app.php b/bootstrap/app.php
index 4936841..1276f26 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -4,6 +4,8 @@ use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
+use Modules\Localization\Http\Middleware\SetLocale;
+use Modules\Sites\Http\Middleware\ResolveSite;
use Spatie\Permission\Middleware\PermissionMiddleware;
use Spatie\Permission\Middleware\RoleMiddleware;
use Spatie\Permission\Middleware\RoleOrPermissionMiddleware;
@@ -22,8 +24,8 @@ return Application::configure(basePath: dirname(__DIR__))
]);
$middleware->web(append: [
- \Modules\Sites\Http\Middleware\ResolveSite::class,
- \Modules\Localization\Http\Middleware\SetLocale::class,
+ ResolveSite::class,
+ SetLocale::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
diff --git a/config/modules.php b/config/modules.php
index 0ba1d01..6f62140 100644
--- a/config/modules.php
+++ b/config/modules.php
@@ -3,7 +3,6 @@
use mpba\Modules\Activators\DatabaseActivator;
use mpba\Modules\Activators\FileActivator;
-
return [
/*
@@ -28,7 +27,7 @@ return [
'stubs' => [
'enabled' => false,
- 'path' => base_path() . '/Vendors/mpba/laravel-modules/src/Commands/stubs',
+ 'path' => base_path().'/Vendors/mpba/laravel-modules/src/Commands/stubs',
'files' => [
'routes/web' => 'Routes/web.php',
'routes/api' => 'Routes/api.php',
@@ -121,7 +120,7 @@ return [
'views' => ['path' => 'Resources/views', 'generate' => true],
'test' => ['path' => 'Tests/Unit', 'generate' => true],
'test-feature' => ['path' => 'Tests/Feature', 'generate' => true],
- 'test-browser' => ['path' => 'Tests/Browser', 'generate' => true,],
+ 'test-browser' => ['path' => 'Tests/Browser', 'generate' => true],
'repository' => ['path' => 'Repositories', 'generate' => false],
'event' => ['path' => 'Events', 'generate' => false],
'listener' => ['path' => 'Listeners', 'generate' => false],
diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php
index ebbe046..2948fc8 100644
--- a/tests/Feature/Auth/AuthenticationTest.php
+++ b/tests/Feature/Auth/AuthenticationTest.php
@@ -64,4 +64,4 @@ test('users can logout', function () {
$response->assertRedirect(route('home'));
$this->assertGuest();
-});
\ No newline at end of file
+});
diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php
index e10227f..ce043aa 100644
--- a/tests/Feature/Auth/EmailVerificationTest.php
+++ b/tests/Feature/Auth/EmailVerificationTest.php
@@ -69,4 +69,4 @@ test('already verified user visiting verification link is redirected without fir
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
Event::assertNotDispatched(Verified::class);
-});
\ No newline at end of file
+});
diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php
index 514115a..76b5e34 100644
--- a/tests/Feature/Auth/PasswordConfirmationTest.php
+++ b/tests/Feature/Auth/PasswordConfirmationTest.php
@@ -8,4 +8,4 @@ test('confirm password screen can be rendered', function () {
$response = $this->actingAs($user)->get(route('password.confirm'));
$response->assertOk();
-});
\ No newline at end of file
+});
diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php
index 586f8a7..9f55aba 100644
--- a/tests/Feature/Auth/PasswordResetTest.php
+++ b/tests/Feature/Auth/PasswordResetTest.php
@@ -62,4 +62,4 @@ test('password can be reset with valid token', function () {
return true;
});
-});
\ No newline at end of file
+});
diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php
index 874e6fa..f9c6493 100644
--- a/tests/Feature/Auth/RegistrationTest.php
+++ b/tests/Feature/Auth/RegistrationTest.php
@@ -24,4 +24,4 @@ test('new users can register', function () {
->assertRedirect(route('dashboard', absolute: false));
$this->assertAuthenticated();
-});
\ No newline at end of file
+});
diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php
index 2dc7a6a..70a4fae 100644
--- a/tests/Feature/Auth/TwoFactorChallengeTest.php
+++ b/tests/Feature/Auth/TwoFactorChallengeTest.php
@@ -25,4 +25,4 @@ test('two factor challenge can be rendered', function () {
'email' => $user->email,
'password' => 'password',
])->assertRedirect(route('two-factor.login'));
-});
\ No newline at end of file
+});
diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php
index d59d2d7..95c6a8b 100644
--- a/tests/Feature/DashboardTest.php
+++ b/tests/Feature/DashboardTest.php
@@ -13,4 +13,4 @@ test('authenticated users can visit the dashboard', function () {
$response = $this->get(route('dashboard'));
$response->assertOk();
-});
\ No newline at end of file
+});
diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php
index a576279..ef7b533 100644
--- a/tests/Feature/ExampleTest.php
+++ b/tests/Feature/ExampleTest.php
@@ -4,4 +4,4 @@ test('returns a successful response', function () {
$response = $this->get(route('home'));
$response->assertOk();
-});
\ No newline at end of file
+});
diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php
index 6d30fee..7dc4899 100644
--- a/tests/Feature/Settings/ProfileUpdateTest.php
+++ b/tests/Feature/Settings/ProfileUpdateTest.php
@@ -73,4 +73,4 @@ test('correct password must be provided to delete account', function () {
$response->assertHasErrors(['password']);
expect($user->fresh())->not->toBeNull();
-});
\ No newline at end of file
+});
diff --git a/tests/Feature/Settings/SecurityTest.php b/tests/Feature/Settings/SecurityTest.php
index eaf1563..beb1171 100644
--- a/tests/Feature/Settings/SecurityTest.php
+++ b/tests/Feature/Settings/SecurityTest.php
@@ -111,4 +111,4 @@ test('correct password must be provided to update password', function () {
->call('updatePassword');
$response->assertHasErrors(['current_password']);
-});
\ No newline at end of file
+});