move into sonardesk

This commit is contained in:
Mark Gregory
2026-10-12 14:50:13 +01:00
parent 5f0d2ceb72
commit 38b4a7406f
879 changed files with 24021 additions and 1 deletions
+9
View File
@@ -0,0 +1,9 @@
<?php
return [
'default_provider' => env('AI_DEFAULT_PROVIDER'),
'timeout' => (int) env('AI_TIMEOUT', 60),
'max_output_tokens' => (int) env('AI_MAX_OUTPUT_TOKENS', 2048),
'temperature' => (float) env('AI_TEMPERATURE', 0.3),
'retain_executions_days' => (int) env('AI_RETAIN_EXECUTIONS_DAYS', 90),
];
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\AI\Console;
use Illuminate\Console\Command;
use Modules\AI\Models\AIExecution;
final class PruneAIExecutions extends Command
{
protected $signature = 'ai:prune {--days=}';
protected $description = 'Remove old AI execution records.';
public function handle(): int
{
$days = (int) ($this->option('days') ?: config('ai.retain_executions_days', 90));
$count = AIExecution::where('created_at', '<', now()->subDays($days))->delete();
$this->info("Deleted {$count} AI executions.");
return self::SUCCESS;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Modules\AI\Console;
use Illuminate\Console\Command;
use Modules\AI\Contracts\AIManager;
final class TestAIProvider extends Command
{
protected $signature = 'ai:test {provider}';
protected $description = 'Test an AI provider connection.';
public function handle(AIManager $manager): int
{
$result = $manager->testProvider((int) $this->argument('provider'));
$this->{$result['ok'] ? 'info' : 'error'}($result['message']);
return $result['ok'] ? self::SUCCESS : self::FAILURE;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\AI\Contracts;
use Modules\AI\Models\AIExecution;
use Modules\AI\Models\AIPrompt;
interface AIManager
{
public function execute(AIPrompt|string $prompt, array $variables = [], array $options = []): AIExecution;
public function testProvider(int $providerId): array;
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Modules\AI\Contracts;
use Modules\AI\Models\AIProvider;
interface AIProviderDriver
{
public function generate(AIProvider $provider, array $messages, array $options = []): array;
public function test(AIProvider $provider): array;
}
@@ -0,0 +1,90 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ai_providers', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('driver', 40)->default('openai_compatible');
$table->string('base_url')->nullable();
$table->text('api_key')->nullable();
$table->string('default_model')->nullable();
$table->json('models')->nullable();
$table->json('options')->nullable();
$table->boolean('is_active')->default(true)->index();
$table->boolean('is_default')->default(false)->index();
$table->timestamp('last_tested_at')->nullable();
$table->string('last_test_status', 20)->nullable();
$table->text('last_test_message')->nullable();
$table->timestamps();
});
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();
});
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();
});
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();
});
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']);
});
}
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');
}
};
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace Modules\AI\Drivers;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Modules\AI\Contracts\AIProviderDriver;
use Modules\AI\Models\AIProvider;
final class OpenAICompatibleDriver implements AIProviderDriver
{
private function client(AIProvider $provider): PendingRequest
{
$client = Http::acceptJson()->timeout((int) config('ai.timeout', 60));
return filled($provider->api_key) ? $client->withToken($provider->api_key) : $client;
}
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];
}
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.'];
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\AI\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\AI\Contracts\AIManager;
use Modules\AI\Models\AIPrompt;
final class AIApiController extends Controller
{
public function execute(Request $request, AIPrompt $prompt, AIManager $manager): JsonResponse
{
$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);
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace Modules\AI\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Modules\AI\Contracts\AIManager;
use Modules\AI\Models\AIExecution;
use Modules\AI\Models\AIKnowledgeSource;
use Modules\AI\Models\AIPrompt;
use Modules\AI\Models\AIProvider;
use Modules\AI\Services\KnowledgeIndexer;
final class AIController extends Controller
{
public function index(): View
{
return view('ai::admin.index', ['providers' => AIProvider::latest()->get(), 'prompts' => AIPrompt::latest()->paginate(15, '*', 'prompts'), 'executions' => AIExecution::latest()->limit(15)->get(), 'sources' => AIKnowledgeSource::latest()->limit(15)->get()]);
}
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.');
}
public function testProvider(AIProvider $provider, AIManager $manager): RedirectResponse
{
$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.');
}
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);
}
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.');
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Modules\AI\Models;
use Illuminate\Database\Eloquent\Model;
final class AIExecution extends Model
{
protected $fillable = ['execution_id', 'ai_provider_id', 'ai_prompt_id', 'user_id', 'model', 'purpose', 'input', 'output', 'context', 'usage', 'status', 'error', 'duration_ms', 'completed_at'];
protected $casts = ['context' => 'array', 'usage' => 'array', 'completed_at' => 'datetime'];
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Modules\AI\Models;
use Illuminate\Database\Eloquent\Model;
final class AIKnowledgeChunk extends Model
{
protected $fillable = ['ai_knowledge_source_id', 'position', 'content', 'metadata'];
protected $casts = ['metadata' => 'array'];
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Modules\AI\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
final class AIKnowledgeSource extends Model
{
protected $fillable = ['name', 'source_kind', 'source_type', 'source_id', 'content', 'checksum', 'metadata', 'status', 'chunk_count', 'indexed_at'];
protected $casts = ['metadata' => 'array', 'indexed_at' => 'datetime'];
public function source(): MorphTo
{
return $this->morphTo();
}
public function chunks(): HasMany
{
return $this->hasMany(AIKnowledgeChunk::class);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace Modules\AI\Models;
use Illuminate\Database\Eloquent\Model;
final class AIPrompt extends Model
{
protected $fillable = ['name', 'slug', 'purpose', 'system_prompt', 'user_template', 'variables', 'version', 'is_active', 'created_by'];
protected $casts = ['variables' => 'array', 'is_active' => 'boolean'];
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;
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Modules\AI\Models;
use Illuminate\Database\Eloquent\Model;
final class AIProvider extends Model
{
protected $fillable = ['name', 'driver', 'base_url', 'api_key', 'default_model', 'models', 'options', 'is_active', 'is_default', 'last_tested_at', 'last_test_status', 'last_test_message'];
protected $casts = ['api_key' => 'encrypted', 'models' => 'array', 'options' => 'array', 'is_active' => 'boolean', 'is_default' => 'boolean', 'last_tested_at' => 'datetime'];
protected $hidden = ['api_key'];
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\AI\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\AI\Console\PruneAIExecutions;
use Modules\AI\Console\TestAIProvider;
use Modules\AI\Contracts\AIManager as AIManagerContract;
use Modules\AI\Services\AIManager;
final class AIServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('AI', 'Config/config.php'), 'ai');
$this->app->singleton(AIManagerContract::class, AIManager::class);
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('AI', 'Database/Migrations'));
$this->loadViewsFrom(module_path('AI', 'Resources/views'), 'ai');
if ($this->app->runningInConsole()) {
$this->commands([TestAIProvider::class, PruneAIExecutions::class]);
}
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Modules\AI\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
Route::middleware('web')->group(module_path('AI', 'Routes/web.php'));
Route::middleware('api')->prefix('api/v1')->group(module_path('AI', 'Routes/api.php'));
}
}
+21
View File
@@ -0,0 +1,21 @@
<x-layouts.app :title="__('AI Platform')">
<div class="space-y-6">
<div><flux:heading size="xl">AI Platform</flux:heading><flux:text>Configure hosted or local models, reusable prompts, execution history and knowledge sources.</flux:text></div>
@if(session('status'))<flux:callout variant="success">{{ session('status') }}</flux:callout>@endif
@if(session('error'))<flux:callout variant="danger">{{ session('error') }}</flux:callout>@endif
<div class="grid gap-4 md:grid-cols-4">
<flux:card><flux:text>Providers</flux:text><flux:heading size="xl">{{ $providers->count() }}</flux:heading></flux:card>
<flux:card><flux:text>Prompts</flux:text><flux:heading size="xl">{{ $prompts->total() }}</flux:heading></flux:card>
<flux:card><flux:text>Executions</flux:text><flux:heading size="xl">{{ \Modules\AI\Models\AIExecution::count() }}</flux:heading></flux:card>
<flux:card><flux:text>Knowledge sources</flux:text><flux:heading size="xl">{{ \Modules\AI\Models\AIKnowledgeSource::count() }}</flux:heading></flux:card>
</div>
<div class="grid gap-6 xl:grid-cols-2">
<flux:card class="space-y-4"><flux:heading>Provider</flux:heading><form method="POST" action="{{ route('administration.ai.providers.store') }}" class="space-y-3">@csrf
<flux:input name="name" label="Name" required/><flux:select name="driver" label="Driver"><option value="openai_compatible">OpenAI compatible</option><option value="openai">OpenAI</option><option value="lm_studio">LM Studio</option><option value="ollama">Ollama</option></flux:select>
<flux:input name="base_url" label="Base URL" placeholder="http://127.0.0.1:1234/v1"/><flux:input name="api_key" type="password" label="API key"/><flux:input name="default_model" label="Default model" required/><flux:checkbox name="is_default" value="1" label="Default provider"/><flux:button type="submit" variant="primary">Add provider</flux:button></form>
<div class="divide-y">@foreach($providers as $provider)<div class="flex items-center justify-between py-3"><div><strong>{{ $provider->name }}</strong><div class="text-sm opacity-70">{{ $provider->driver }} · {{ $provider->default_model }}</div></div><form method="POST" action="{{ route('administration.ai.providers.test',$provider) }}">@csrf<flux:button size="sm">Test</flux:button></form></div>@endforeach</div></flux:card>
<flux:card class="space-y-4"><flux:heading>Reusable prompt</flux:heading><form method="POST" action="{{ route('administration.ai.prompts.store') }}" class="space-y-3">@csrf
<flux:input name="name" label="Name" required/><flux:input name="slug" label="Slug"/><flux:input name="purpose" label="Purpose" placeholder="summarisation"/><flux:textarea name="system_prompt" label="System prompt"/><flux:textarea name="user_template" label="User template" placeholder="Summarise: [input]" required/><flux:button type="submit" variant="primary">Add prompt</flux:button></form></flux:card>
<flux:card class="space-y-4"><flux:heading>Knowledge source</flux:heading><form method="POST" action="{{ route('administration.ai.knowledge.store') }}" class="space-y-3">@csrf<flux:input name="name" label="Name" required/><flux:textarea name="content" label="Content" rows="10" required/><flux:button type="submit" variant="primary">Index source</flux:button></form><div class="divide-y">@foreach($sources as $source)<div class="py-2"><strong>{{ $source->name }}</strong><div class="text-sm opacity-70">{{ $source->status }} · {{ $source->chunk_count }} chunks</div></div>@endforeach</div></flux:card>
<flux:card class="space-y-4"><flux:heading>Recent executions</flux:heading><div class="divide-y">@forelse($executions as $execution)<div class="py-3"><div class="flex justify-between"><strong>{{ $execution->purpose ?: 'Prompt execution' }}</strong><span>{{ $execution->status }}</span></div><div class="text-sm opacity-70">{{ $execution->model }} · {{ $execution->duration_ms }} ms · {{ $execution->created_at?->diffForHumans() }}</div></div>@empty<flux:text>No executions yet.</flux:text>@endforelse</div></flux:card>
</div></div></x-layouts.app>
+6
View File
@@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\AI\Http\Controllers\AIApiController;
Route::post('/ai/prompts/{prompt:slug}/execute', [AIApiController::class, 'execute'])->middleware(['auth.api', 'ability:ai.execute'])->name('api.ai.execute');
+13
View File
@@ -0,0 +1,13 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\AI\Http\Controllers\AIController;
Route::middleware(['auth', 'permission:ai.view'])->prefix('administration/ai')->name('administration.ai.')->group(function () {
Route::get('/', [AIController::class, 'index'])->name('index');
Route::post('/providers', [AIController::class, 'storeProvider'])->middleware('permission:ai.manage')->name('providers.store');
Route::post('/providers/{provider}/test', [AIController::class, 'testProvider'])->middleware('permission:ai.manage')->name('providers.test');
Route::post('/prompts', [AIController::class, 'storePrompt'])->middleware('permission:ai.manage')->name('prompts.store');
Route::post('/prompts/{prompt}/execute', [AIController::class, 'execute'])->middleware('permission:ai.execute')->name('prompts.execute');
Route::post('/knowledge', [AIController::class, 'storeSource'])->middleware('permission:ai.manage')->name('knowledge.store');
});
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace Modules\AI\Services;
use Illuminate\Support\Str;
use Modules\AI\Contracts\AIManager as AIManagerContract;
use Modules\AI\Contracts\AIProviderDriver;
use Modules\AI\Drivers\OpenAICompatibleDriver;
use Modules\AI\Models\AIExecution;
use Modules\AI\Models\AIPrompt;
use Modules\AI\Models\AIProvider;
final class AIManager implements AIManagerContract
{
public function execute(AIPrompt|string $prompt, array $variables = [], array $options = []): AIExecution
{
$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()]);
} catch (\Throwable $e) {
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()];
}
}
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();
}
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}].")
};
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace Modules\AI\Services;
use Modules\AI\Models\AIKnowledgeSource;
final class KnowledgeIndexer
{
public function index(AIKnowledgeSource $source, int $chunkSize = 1800): AIKnowledgeSource
{
$content = trim((string) $source->content);
$source->chunks()->delete();
$chunks = $content === '' ? [] : $this->split($content, max(500, $chunkSize));
foreach ($chunks as $position => $chunk) {
$source->chunks()->create(['position' => $position + 1, 'content' => $chunk]);
}
$source->update(['checksum' => hash('sha256', $content), 'status' => 'indexed', 'chunk_count' => count($chunks), 'indexed_at' => now()]);
return $source->refresh();
}
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;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Modules\AI\Tests\Feature;
use Modules\AI\Contracts\AIManager;
use Modules\AI\Providers\AIServiceProvider;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_ai_module_boots(): void
{
$this->assertTrue(class_exists(AIServiceProvider::class));
$this->assertTrue(app()->bound(AIManager::class));
}
}
+1
View File
@@ -0,0 +1 @@
{"name":"AI","alias":"ai","version":"1.0.0","description":"Provider-neutral artificial intelligence services, reusable prompts, execution history and knowledge sources.","keywords":["ai","llm","prompts","knowledge","rag"],"priority":140,"providers":["Modules\\AI\\Providers\\AIServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Audit","Media","Documents","Automation"]}
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'name' => 'Administration',
'enabled' => true,
];
@@ -0,0 +1,82 @@
<?php
namespace Modules\Administration\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Schema;
use Modules\Audit\Models\AuditLog;
use Modules\Media\Models\Media;
use Spatie\Permission\Models\Role;
final class DashboardController extends Controller
{
public function __invoke()
{
return stylist_backend_view(stylist()->backend()->entryView(), [
'userCount' => User::count(),
'roleCount' => Schema::hasTable('roles') ? Role::count() : 0,
'mediaCount' => Schema::hasTable('media') ? Media::count() : 0,
'auditCount' => Schema::hasTable('audit_logs') ? AuditLog::count() : 0,
'recentAudit' => $this->recentAudit(),
'modules' => $this->modules(),
]);
}
private function recentAudit(): LengthAwarePaginator
{
if (! Schema::hasTable('audit_logs')) {
return new LengthAwarePaginator(
items: [],
total: 0,
perPage: 8,
currentPage: Paginator::resolveCurrentPage('audit_page'),
options: [
'path' => request()->url(),
'pageName' => 'audit_page',
],
);
}
return AuditLog::query()
->latest('created_at')
->paginate(8, ['*'], 'audit_page')
->withQueryString();
}
private function modules(): LengthAwarePaginator
{
$statuses = json_decode((string) file_get_contents(base_path('modules_statuses.json')), true) ?: [];
$modules = [];
foreach (glob(base_path('Modules/*/module.json')) ?: [] as $manifest) {
$data = json_decode((string) file_get_contents($manifest), true) ?: [];
$name = $data['name'] ?? basename(dirname($manifest));
$modules[] = [
'name' => $name,
'version' => $data['version'] ?? '1.0.0',
'enabled' => (bool) ($statuses[$name] ?? false),
];
}
usort($modules, fn (array $a, array $b): int => strcasecmp($a['name'], $b['name']));
$pageName = 'modules_page';
$perPage = 8;
$currentPage = Paginator::resolveCurrentPage($pageName);
return (new LengthAwarePaginator(
items: array_slice($modules, ($currentPage - 1) * $perPage, $perPage),
total: count($modules),
perPage: $perPage,
currentPage: $currentPage,
options: [
'path' => request()->url(),
'pageName' => $pageName,
],
))->withQueryString();
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Administration\Http\Controllers;
use App\Http\Controllers\Controller;
final class ModuleController extends Controller
{
public function index()
{
$statuses = json_decode((string) file_get_contents(base_path('modules_statuses.json')), true) ?: [];
$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.',
'version' => $data['version'] ?? '1.0.0',
'enabled' => (bool) ($statuses[$name] ?? false),
'providers' => $data['providers'] ?? [],
];
})->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)->values();
return view('administration::modules.index', compact('modules'));
}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Administration\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Administration\Support\ModuleInfo;
final class AdministrationServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower());
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
$this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower());
}
}
@@ -0,0 +1,17 @@
<?php
namespace Modules\Administration\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
parent::boot();
$this->routes(function (): void {
Route::middleware('web')->group(module_path('Administration', 'Routes/web.php'));
});
}
}
@@ -0,0 +1 @@
<x-layouts.app :title="__('Modules')"><div class="space-y-6"><div><h1 class="text-2xl font-semibold">Modules</h1><p class="mt-1 text-sm text-zinc-500">Installed platform modules and runtime status.</p></div><div class="overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"><table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700"><thead class="bg-zinc-50 text-left text-xs uppercase text-zinc-500 dark:bg-zinc-950"><tr><th class="px-4 py-3">Module</th><th class="px-4 py-3">Version</th><th class="px-4 py-3">Status</th><th class="px-4 py-3">Providers</th></tr></thead><tbody class="divide-y divide-zinc-200 dark:divide-zinc-800">@foreach($modules as $module)<tr><td class="px-4 py-3"><div class="font-medium">{{ $module['name'] }}</div><div class="text-xs text-zinc-500">{{ $module['description'] }}</div></td><td class="px-4 py-3">{{ $module['version'] }}</td><td class="px-4 py-3">{{ $module['enabled'] ? 'Enabled' : 'Disabled' }}</td><td class="px-4 py-3 text-xs text-zinc-500">{{ implode(', ', $module['providers']) ?: '—' }}</td></tr>@endforeach</tbody></table></div></div></x-layouts.app>
+8
View File
@@ -0,0 +1,8 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Administration\Http\Controllers\ModuleController;
Route::middleware(['auth', 'verified'])->prefix('administration')->name('administration.')->group(function (): void {
Route::get('/modules', [ModuleController::class, 'index'])->name('modules.index');
});
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Modules\Administration\Support;
final class ModuleInfo
{
public static function name(): string
{
return 'Administration';
}
public static function nameLower(): string
{
return 'administration';
}
}
@@ -0,0 +1,17 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Administration\Http\Controllers\DashboardController;
it('boots the Administration module', function (): void {
expect(config('administration.name'))->toBe('Administration');
});
it('uses the primary dashboard for administration overview content', function (): void {
$dashboard = Route::getRoutes()->getByName('dashboard');
expect($dashboard)->not->toBeNull()
->and($dashboard->getActionName())->toBe(DashboardController::class)
->and(view()->exists('stylist-theme-admin::dashboard'))->toBeTrue()
->and(Route::has('administration.dashboard'))->toBeFalse();
});
+10
View File
@@ -0,0 +1,10 @@
{
"name": "absdev/administration-module",
"description": "Administration foundation module for the ABSDEV Laravel application platform.",
"type": "laravel-module",
"autoload": {
"psr-4": {
"Modules\\Administration\\": ""
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "Administration",
"alias": "administration",
"version": "1.0.0",
"description": "Administration foundation module for the ABSDEV Laravel application platform.",
"keywords": [
"laravel",
"modules",
"administration"
],
"priority": 10,
"providers": [
"Modules\\Administration\\Providers\\AdministrationServiceProvider"
],
"aliases": {},
"files": [],
"requires": [
"Core"
]
}
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
'enabled' => (bool) env('ANALYTICS_ENABLED', true),
'respect_dnt' => (bool) env('ANALYTICS_RESPECT_DNT', true),
'anonymize_ip' => (bool) env('ANALYTICS_ANONYMIZE_IP', true),
'retention_days' => (int) env('ANALYTICS_RETENTION_DAYS', 365),
'exclude_paths' => array_values(array_filter(array_map('trim', explode(',', (string) env('ANALYTICS_EXCLUDE_PATHS', 'administration,api'))))),
'queue' => env('ANALYTICS_QUEUE', 'default'),
];
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Analytics\Console;
use Illuminate\Console\Command;
use Modules\Analytics\Services\AnalyticsAggregator;
final class AggregateAnalytics extends Command
{
protected $signature = 'analytics:aggregate {--date=}';
protected $description = 'Aggregate raw analytics events into daily metrics.';
public function handle(AnalyticsAggregator $aggregator): int
{
$this->info('Metrics written: '.$aggregator->aggregate($this->option('date')));
return self::SUCCESS;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Modules\Analytics\Console;
use Illuminate\Console\Command;
use Modules\Analytics\Models\AnalyticsEvent;
final class PruneAnalytics extends Command
{
protected $signature = 'analytics:prune';
protected $description = 'Remove analytics events older than the configured retention period.';
public function handle(): int
{
$count = AnalyticsEvent::where('occurred_at', '<', now()->subDays(config('analytics.retention_days', 365)))->delete();
$this->info("Deleted {$count} events.");
return self::SUCCESS;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Modules\Analytics\Console;
use Illuminate\Console\Command;
use Modules\Analytics\Models\AnalyticsReport;
final class RunScheduledReports extends Command
{
protected $signature = 'analytics:reports';
protected $description = 'Queue analytics reports that are due.';
public function handle(): int
{
$count = AnalyticsReport::where('is_active', true)->whereNotNull('next_run_at')->where('next_run_at', '<=', now())->update(['last_run_at' => now(), 'next_run_at' => now()->addDay()]);
$this->info("Processed {$count} scheduled reports.");
return self::SUCCESS;
}
}
@@ -0,0 +1,80 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('analytics_events', function (Blueprint $table) {
$table->id();
$table->uuid('event_id')->unique();
$table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();
$table->string('event_type', 40)->index();
$table->string('path', 2048)->nullable();
$table->string('title')->nullable();
$table->string('referrer', 2048)->nullable();
$table->string('visitor_hash', 64)->nullable()->index();
$table->string('session_hash', 64)->nullable()->index();
$table->string('ip_hash', 64)->nullable();
$table->string('country_code', 2)->nullable()->index();
$table->string('device_type', 20)->nullable()->index();
$table->string('browser', 40)->nullable();
$table->string('operating_system', 40)->nullable();
$table->nullableMorphs('subject');
$table->json('properties')->nullable();
$table->timestamp('occurred_at')->index();
$table->timestamps();
$table->index(['site_id', 'event_type', 'occurred_at']);
});
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');
});
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();
});
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();
});
}
public function down(): void
{
Schema::dropIfExists('analytics_report_runs');
Schema::dropIfExists('analytics_reports');
Schema::dropIfExists('analytics_daily_metrics');
Schema::dropIfExists('analytics_events');
}
};
@@ -0,0 +1,43 @@
<?php
namespace Modules\Analytics\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\View\View;
use Modules\Analytics\Models\AnalyticsEvent;
use Modules\Analytics\Models\AnalyticsReport;
final class AnalyticsController extends Controller
{
public function index(Request $request): View
{
$days = max(1, min(365, (int) $request->integer('days', 30)));
$from = now()->subDays($days - 1)->startOfDay();
$events = AnalyticsEvent::query()->where('occurred_at', '>=', $from);
return view('analytics::index', [
'days' => $days, 'pageViews' => $events->clone()->where('event_type', 'page_view')->count(),
'visitors' => $events->clone()->whereNotNull('visitor_hash')->distinct('visitor_hash')->count('visitor_hash'),
'conversions' => $events->clone()->where('event_type', 'conversion')->count(),
'searches' => $events->clone()->where('event_type', 'search')->count(),
'topPages' => $events->clone()->where('event_type', 'page_view')->selectRaw('path, count(*) as total')->groupBy('path')->orderByDesc('total')->limit(10)->get(),
'recentEvents' => $events->clone()->latest('occurred_at')->limit(20)->get(), 'reports' => AnalyticsReport::latest()->limit(20)->get(),
]);
}
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.');
}
public function destroyReport(AnalyticsReport $report): RedirectResponse
{
$report->delete();
return back()->with('status','Report deleted.');
}
}
@@ -0,0 +1,25 @@
<?php
namespace Modules\Analytics\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\Rule;
use Modules\Analytics\Services\AnalyticsTracker;
final class TrackingController extends Controller
{
public function store(Request $request, AnalyticsTracker $tracker): JsonResponse
{
$data = $request->validate([
'event_type' => ['required', Rule::in(['page_view', 'search', 'conversion', 'content_view', 'form_submit', 'commerce'])],
'path' => ['nullable', 'string', 'max:2048'], 'title' => ['nullable', 'string', 'max:255'], 'referrer' => ['nullable', 'string', 'max:2048'],
'visitor_id' => ['nullable', 'string', 'max:255'], 'session_id' => ['nullable', 'string', 'max:255'],
'subject_type' => ['nullable', 'string', 'max:255'], 'subject_id' => ['nullable', 'integer'], 'properties' => ['nullable', 'array'],
]);
$event = $tracker->track($request, $data);
return response()->json(['recorded' => (bool) $event], $event ? 201 : 202);
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Modules\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
final class AnalyticsEvent extends Model
{
protected $fillable = ['event_id', 'site_id', 'event_type', 'path', 'title', 'referrer', 'visitor_hash', 'session_hash', 'ip_hash', 'country_code', 'device_type', 'browser', 'operating_system', 'subject_type', 'subject_id', 'properties', 'occurred_at'];
protected $casts = ['properties' => 'array', 'occurred_at' => 'datetime'];
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Modules\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
final class AnalyticsReport extends Model
{
protected $fillable = ['site_id', 'name', 'report_type', 'filters', 'columns', 'schedule', 'format', 'recipients', 'is_active', 'last_run_at', 'next_run_at', 'created_by'];
protected $casts = ['filters' => 'array', 'columns' => 'array', 'recipients' => 'array', 'is_active' => 'boolean', 'last_run_at' => 'datetime', 'next_run_at' => 'datetime'];
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Modules\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
final class AnalyticsReportRun extends Model
{
protected $fillable = ['analytics_report_id', 'status', 'disk', 'path', 'row_count', 'error', 'started_at', 'completed_at'];
protected $casts = ['started_at' => 'datetime', 'completed_at' => 'datetime'];
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Modules\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
final class DailyMetric extends Model
{
protected $table = 'analytics_daily_metrics';
protected $fillable = ['site_id', 'metric_date', 'metric', 'dimension', 'value', 'metadata'];
protected $casts = ['metric_date' => 'date', 'metadata' => 'array'];
}
@@ -0,0 +1,32 @@
<?php
namespace Modules\Analytics\Providers;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\ServiceProvider;
use Modules\Analytics\Console\AggregateAnalytics;
use Modules\Analytics\Console\PruneAnalytics;
use Modules\Analytics\Console\RunScheduledReports;
final class AnalyticsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Analytics', 'Config/config.php'), 'analytics');
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Analytics', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Analytics', 'Resources/views'), 'analytics');
if ($this->app->runningInConsole()) {
$this->commands([AggregateAnalytics::class, PruneAnalytics::class, RunScheduledReports::class]);
$this->callAfterResolving(Schedule::class, function (Schedule $schedule): void {
$schedule->command('analytics:aggregate')->hourly()->withoutOverlapping();
$schedule->command('analytics:reports')->everyFifteenMinutes()->withoutOverlapping();
$schedule->command('analytics:prune')->dailyAt('02:30')->withoutOverlapping();
});
}
}
}
@@ -0,0 +1,15 @@
<?php
namespace Modules\Analytics\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
Route::middleware('web')->group(module_path('Analytics', 'Routes/web.php'));
Route::middleware('api')->prefix('api/v1')->group(module_path('Analytics', 'Routes/api.php'));
}
}
+14
View File
@@ -0,0 +1,14 @@
<x-layouts.app :title="__('Analytics')">
<div class="flex h-full w-full flex-1 flex-col gap-6 rounded-xl">
<div class="flex flex-wrap items-center justify-between gap-3"><div><flux:heading size="xl">Analytics</flux:heading><flux:subheading>Visitor, content, search and conversion performance.</flux:subheading></div><form method="GET"><flux:select name="days" onchange="this.form.submit()"><option value="7" @selected($days===7)>7 days</option><option value="30" @selected($days===30)>30 days</option><option value="90" @selected($days===90)>90 days</option></flux:select></form></div>
@if(session('status'))<flux:callout variant="success">{{ session('status') }}</flux:callout>@endif
<div class="grid gap-4 md:grid-cols-4">
@foreach([['Page views',$pageViews],['Visitors',$visitors],['Conversions',$conversions],['Searches',$searches]] as [$label,$value])<div class="rounded-xl border border-neutral-200 p-5 dark:border-neutral-700"><div class="text-sm text-neutral-500">{{ $label }}</div><div class="mt-2 text-3xl font-semibold">{{ number_format($value) }}</div></div>@endforeach
</div>
<div class="grid gap-6 lg:grid-cols-2">
<div class="rounded-xl border border-neutral-200 p-5 dark:border-neutral-700"><flux:heading>Top pages</flux:heading><div class="mt-4 divide-y divide-neutral-200 dark:divide-neutral-700">@forelse($topPages as $page)<div class="flex justify-between gap-4 py-3"><span class="truncate">{{ $page->path ?: '/' }}</span><strong>{{ number_format($page->total) }}</strong></div>@empty<p class="py-4 text-sm text-neutral-500">No page views recorded.</p>@endforelse</div></div>
<div class="rounded-xl border border-neutral-200 p-5 dark:border-neutral-700"><flux:heading>Recent events</flux:heading><div class="mt-4 divide-y divide-neutral-200 dark:divide-neutral-700">@forelse($recentEvents as $event)<div class="py-3"><div class="flex justify-between gap-4"><strong>{{ str($event->event_type)->replace('_',' ')->title() }}</strong><span class="text-xs text-neutral-500">{{ $event->occurred_at?->diffForHumans() }}</span></div><p class="truncate text-sm text-neutral-500">{{ $event->path ?: data_get($event->properties,'query','—') }}</p></div>@empty<p class="py-4 text-sm text-neutral-500">No events recorded.</p>@endforelse</div></div>
</div>
@can('analytics.manage')<div class="rounded-xl border border-neutral-200 p-5 dark:border-neutral-700"><flux:heading>Scheduled reports</flux:heading><form method="POST" action="{{ route('administration.analytics.reports.store') }}" class="mt-4 grid gap-4 md:grid-cols-5">@csrf<flux:input name="name" label="Name" required/><flux:select name="report_type" label="Type"><option value="overview">Overview</option><option value="content">Content</option><option value="search">Search</option><option value="conversion">Conversion</option><option value="commerce">Commerce</option></flux:select><flux:input name="schedule" label="Schedule" placeholder="daily"/><flux:select name="format" label="Format"><option value="csv">CSV</option><option value="json">JSON</option></flux:select><div class="flex items-end"><flux:button type="submit" variant="primary">Create report</flux:button></div></form><div class="mt-4 divide-y divide-neutral-200 dark:divide-neutral-700">@forelse($reports as $report)<div class="flex items-center justify-between py-3"><div><strong>{{ $report->name }}</strong><p class="text-sm text-neutral-500">{{ ucfirst($report->report_type) }} · {{ strtoupper($report->format) }}</p></div><form method="POST" action="{{ route('administration.analytics.reports.destroy',$report) }}">@csrf @method('DELETE')<flux:button type="submit" variant="danger" size="sm">Delete</flux:button></form></div>@empty<p class="py-4 text-sm text-neutral-500">No reports configured.</p>@endforelse</div></div>@endcan
</div>
</x-layouts.app>
+6
View File
@@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Analytics\Http\Controllers\TrackingController;
Route::post('/analytics/events', [TrackingController::class, 'store'])->middleware('throttle:60,1')->name('api.analytics.events.store');
+10
View File
@@ -0,0 +1,10 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Analytics\Http\Controllers\AnalyticsController;
Route::middleware(['auth', 'permission:analytics.view'])->prefix('administration/analytics')->name('administration.analytics.')->group(function () {
Route::get('/', [AnalyticsController::class, 'index'])->name('index');
Route::post('/reports', [AnalyticsController::class, 'storeReport'])->middleware('permission:analytics.manage')->name('reports.store');
Route::delete('/reports/{report}', [AnalyticsController::class, 'destroyReport'])->middleware('permission:analytics.manage')->name('reports.destroy');
});
@@ -0,0 +1,32 @@
<?php
namespace Modules\Analytics\Services;
use Modules\Analytics\Models\AnalyticsEvent;
use Modules\Analytics\Models\DailyMetric;
final class AnalyticsAggregator
{
public function aggregate(?string $date = null): int
{
$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()],
];
foreach ($metrics as [$metric,$dimension,$value]) {
DailyMetric::updateOrCreate(['site_id' => $siteId, 'metric_date' => $date, 'metric' => $metric, 'dimension' => $dimension], ['value' => $value]);
$written++;
}
}
return $written;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace Modules\Analytics\Services;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Modules\Analytics\Models\AnalyticsEvent;
final class AnalyticsTracker
{
public function track(Request $request, array $payload): ?AnalyticsEvent
{
if (! config('analytics.enabled')) {
return null;
}
if (config('analytics.respect_dnt') && $request->header('DNT') === '1') {
return null;
}
$path = ltrim((string) ($payload['path'] ?? ''), '/');
foreach (config('analytics.exclude_paths', []) as $excluded) {
if ($excluded !== '' && str_starts_with($path, trim($excluded, '/'))) {
return null;
}
}
$ip = (string) $request->ip();
$visitorIdentifier = (string) ($payload['visitor_id'] ?? $request->cookie('analytics_visitor', 'guest'));
$sessionIdentifier = (string) ($payload['session_id'] ?? $this->sessionIdentifier($request, $visitorIdentifier));
return AnalyticsEvent::create([
'event_id' => (string) Str::uuid(),
'site_id' => app()->bound('currentSite') ? app('currentSite')?->id : null,
'event_type' => $payload['event_type'] ?? 'page_view',
'path' => '/'.$path,
'title' => $payload['title'] ?? null,
'referrer' => $payload['referrer'] ?? $request->headers->get('referer'),
'visitor_hash' => hash('sha256', $visitorIdentifier),
'session_hash' => hash('sha256', $sessionIdentifier),
'ip_hash' => config('analytics.anonymize_ip') ? hash('sha256', $ip.config('app.key')) : $ip,
'device_type' => $payload['device_type'] ?? null,
'browser' => $payload['browser'] ?? null,
'operating_system' => $payload['operating_system'] ?? null,
'subject_type' => $payload['subject_type'] ?? null,
'subject_id' => $payload['subject_id'] ?? null,
'properties' => $payload['properties'] ?? [],
'occurred_at' => now(),
]);
}
private function sessionIdentifier(Request $request, string $visitorIdentifier): string
{
if ($request->hasSession()) {
return $request->session()->getId();
}
return (string) ($request->header('X-Analytics-Session') ?: $visitorIdentifier ?: 'stateless');
}
}
@@ -0,0 +1,17 @@
<?php
namespace Modules\Analytics\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class AnalyticsModuleTest extends TestCase
{
use RefreshDatabase;
public function test_tracking_endpoint_records_page_view(): void
{
$this->postJson('/api/v1/analytics/events', ['event_type' => 'page_view', 'path' => '/about'])->assertCreated()->assertJson(['recorded' => true]);
$this->assertDatabaseHas('analytics_events', ['event_type' => 'page_view', 'path' => '/about']);
}
}
+1
View File
@@ -0,0 +1 @@
{"name":"Analytics","alias":"analytics","version":"1.0.0","description":"Privacy-conscious visitor, content, search, conversion and report analytics.","keywords":["analytics","reports","metrics","visitors","conversions"],"priority":150,"providers":["Modules\\Analytics\\Providers\\AnalyticsServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Sites","Search","Forms","Newsletter","Commerce"]}
+3
View File
@@ -0,0 +1,3 @@
<?php
return ['prefix' => 'api/v1', 'token_prefix' => 'cms_', 'pagination' => 20, 'webhook_timeout' => 10, 'openapi' => ['title' => env('APP_NAME', 'Content Manager').' API', 'version' => '1.0.0']];
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('api_tokens', function (Blueprint $t): void {
$t->id();
$t->foreignId('user_id')->constrained()->cascadeOnDelete();
$t->string('name');
$t->string('token', 64)->unique();
$t->json('abilities')->nullable();
$t->timestamp('last_used_at')->nullable();
$t->timestamp('expires_at')->nullable();
$t->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('api_tokens');
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('webhooks', function (Blueprint $t): void {
$t->id();
$t->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();
$t->string('name');
$t->string('url');
$t->string('secret');
$t->json('events');
$t->boolean('is_active')->default(true);
$t->unsignedSmallInteger('failure_count')->default(0);
$t->timestamp('last_triggered_at')->nullable();
$t->timestamps();
});
Schema::create('webhook_deliveries', function (Blueprint $t): void {
$t->id();
$t->foreignId('webhook_id')->constrained()->cascadeOnDelete();
$t->uuid('uuid')->unique();
$t->string('event');
$t->json('payload');
$t->unsignedSmallInteger('response_status')->nullable();
$t->text('response_body')->nullable();
$t->unsignedSmallInteger('attempts')->default(0);
$t->timestamp('delivered_at')->nullable();
$t->timestamp('failed_at')->nullable();
$t->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('webhook_deliveries');
Schema::dropIfExists('webhooks');
}
};
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace Modules\Api\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Modules\Api\Models\ApiToken;
use Modules\Api\Models\Webhook;
final class ApiController
{
public function index(): View
{
return view('api::admin.index', ['tokens' => ApiToken::with('user')->latest()->paginate(20, '*', 'tokens'), 'webhooks' => Webhook::latest()->paginate(20, '*', 'webhooks')]);
}
public function storeToken(Request $r): RedirectResponse
{
$d = $r->validate(['name' => 'required|string|max:255', 'abilities' => 'nullable|string', 'expires_at' => 'nullable|date|after:now']);
$abilities = array_values(array_filter(array_map('trim', explode(',', $d['abilities'] ?: '*'))));
[,$plain] = ApiToken::issue($r->user(), $d['name'], $abilities, isset($d['expires_at']) ? new \DateTimeImmutable($d['expires_at']) : null);
return back()->with('api_token', $plain)->with('status', 'API token created.');
}
public function destroyToken(ApiToken $token): RedirectResponse
{
$token->delete();
return back()->with('status', 'API token revoked.');
}
public function storeWebhook(Request $r): RedirectResponse
{
$d = $r->validate(['name' => 'required|string|max:255', 'url' => 'required|url|max:2048', 'events' => 'required|string', 'site_id' => 'nullable|integer|exists:sites,id']);
Webhook::create(['name' => $d['name'], 'url' => $d['url'], 'events' => array_values(array_filter(array_map('trim', explode(',', $d['events'])))), 'site_id' => $d['site_id'] ?? null, 'secret' => Str::random(48), 'is_active' => true]);
return back()->with('status', 'Webhook created.');
}
public function destroyWebhook(Webhook $webhook): RedirectResponse
{
$webhook->delete();
return back()->with('status','Webhook deleted.');
}
}
@@ -0,0 +1,54 @@
<?php
namespace Modules\Api\Http\Controllers;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Modules\Api\Http\Resources\ContentResource;
use Modules\Blog\Models\Blog;
use Modules\News\Models\News;
use Modules\Pages\Models\Page;
final class ContentController
{
public function pages(): AnonymousResourceCollection
{
return $this->many(Page::query()->where('status', 'published'));
}
public function blog(): AnonymousResourceCollection
{
return $this->many(Blog::query()->published());
}
public function news(): AnonymousResourceCollection
{
return $this->many(News::query()->published());
}
public function page(Page $page): ContentResource
{
abort_unless($page->status === 'published', 404);
return new ContentResource($page);
}
public function blogPost(Blog $blog): ContentResource
{
abort_unless(Blog::query()->published()->whereKey($blog)->exists(), 404);
return new ContentResource($blog);
}
public function newsArticle(News $news): ContentResource
{
abort_unless(News::query()->published()->whereKey($news)->exists(), 404);
return new ContentResource($news);
}
private function many(Builder $q): AnonymousResourceCollection
{
return ContentResource::collection($q->latest('updated_at')->paginate((int) config('api.pagination', 20)));
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Api\Http\Controllers;
use Illuminate\Http\JsonResponse;
final class OpenApiController
{
public function __invoke(): JsonResponse
{
return response()->json(['openapi' => '3.1.0', 'info' => config('api.openapi'), 'servers' => [['url' => url('/'.config('api.prefix', 'api/v1'))]], 'components' => ['securitySchemes' => ['bearerAuth' => ['type' => 'http', 'scheme' => 'bearer']]], 'paths' => ['/pages' => ['get' => ['summary' => 'List published pages', 'responses' => ['200' => ['description' => 'OK']]]], '/blog' => ['get' => ['summary' => 'List published blog posts', 'responses' => ['200' => ['description' => 'OK']]]], '/news' => ['get' => ['summary' => 'List published news', 'responses' => ['200' => ['description' => 'OK']]]], '/user' => ['get' => ['summary' => 'Authenticated user', 'security' => [['bearerAuth' => []]], 'responses' => ['200' => ['description' => 'OK'], '401' => ['description' => 'Unauthenticated']]]]]]);
}
}
@@ -0,0 +1,28 @@
<?php
namespace Modules\Api\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Modules\Api\Models\ApiToken;
use Symfony\Component\HttpFoundation\Response;
final class AuthenticateApiToken
{
public function handle(Request $r, Closure $next, ?string $ability = null): Response
{
$plain = $r->bearerToken();
if (! $plain) {
return response()->json(['message' => 'Unauthenticated.'], 401);
}$token = ApiToken::with('user')->where('token', hash('sha256', $plain))->first();
if (! $token || ($token->expires_at && $token->expires_at->isPast()) || ! $token->user) {
return response()->json(['message' => 'Invalid or expired API token.'], 401);
}if ($ability && ! $token->allows($ability)) {
return response()->json(['message' => 'Insufficient token ability.'], 403);
}$token->update(['last_used_at' => now()]);
auth()->setUser($token->user);
$r->attributes->set('apiToken', $token);
return $next($r);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Modules\Api\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class ContentResource extends JsonResource
{
public function toArray(Request $r): array
{
return ['id' => $this->id, 'type' => $this->resource->getTable(), 'title' => $this->title, 'slug' => $this->slug, 'excerpt' => $this->excerpt ?? null, 'content' => $this->content ?? null, 'status' => $this->status ?? null, 'featured_image' => $this->featured_image ?? null, 'published_at' => $this->published_at ?? null, 'updated_at' => $this->updated_at];
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace Modules\Api\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
final class ApiToken extends Model
{
protected $fillable = ['user_id', 'name', 'token', 'abilities', 'last_used_at', 'expires_at'];
protected $hidden = ['token'];
protected function casts(): array
{
return ['abilities' => 'array', 'last_used_at' => 'datetime', 'expires_at' => 'datetime'];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public static function issue(User $u, string $name, array $abilities = ['*'], ?\DateTimeInterface $expires = null): array
{
$plain = config('api.token_prefix', 'cms_').Str::random(64);
$record = self::query()->create(['user_id' => $u->id, 'name' => $name, 'token' => hash('sha256', $plain), 'abilities' => $abilities, 'expires_at' => $expires]);
return [$record, $plain];
}
public function allows(string $ability): bool
{
$a = $this->abilities ?: [];
return in_array('*', $a, true) || in_array($ability, $a, true);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\Api\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Webhook extends Model
{
protected $fillable = ['site_id', 'name', 'url', 'secret', 'events', 'is_active', 'failure_count', 'last_triggered_at'];
protected $hidden = ['secret'];
protected function casts(): array
{
return ['events' => 'array', 'is_active' => 'boolean', 'last_triggered_at' => 'datetime'];
}
public function deliveries(): HasMany
{
return $this->hasMany(WebhookDelivery::class);
}
public function subscribesTo(string $event): bool
{
return in_array('*', $this->events ?: [], true) || in_array($event, $this->events ?: [], true);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Modules\Api\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class WebhookDelivery extends Model
{
protected $fillable = ['webhook_id', 'uuid', 'event', 'payload', 'response_status', 'response_body', 'attempts', 'delivered_at', 'failed_at'];
protected function casts(): array
{
return ['payload' => 'array', 'delivered_at' => 'datetime', 'failed_at' => 'datetime'];
}
public function webhook(): BelongsTo
{
return $this->belongsTo(Webhook::class);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Api\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Api\Services\WebhookDispatcher;
final class ApiServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Api', 'Config/config.php'), 'api');
$this->app->singleton(WebhookDispatcher::class);
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Api', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Api', 'Resources/views'), 'api');
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Modules\Api\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
Route::middleware('api')->prefix(config('api.prefix', 'api/v1'))->name('api.v1.')->group(module_path('Api', 'Routes/api.php'));
Route::middleware('web')->group(module_path('Api', 'Routes/web.php'));
}
}
@@ -0,0 +1 @@
<x-layouts.app :title="__('API & Webhooks')"><div class="space-y-8"><div><h1 class="text-2xl font-semibold">{{ __('API & Webhooks') }}</h1><p class="text-sm text-zinc-500">{{ __('Manage headless access, bearer tokens and webhook subscriptions.') }}</p></div>@if(session('status'))<flux:callout variant="success">{{ session('status') }}</flux:callout>@endif @if(session('api_token'))<flux:callout variant="warning"><strong>{{ __('Copy this token now') }}</strong><div class="mt-2 break-all font-mono">{{ session('api_token') }}</div></flux:callout>@endif<div class="grid gap-6 xl:grid-cols-2"><flux:card><h2 class="text-lg font-semibold">{{ __('Create token') }}</h2><form method="POST" action="{{ route('administration.api.tokens.store') }}" class="mt-4 space-y-4">@csrf<flux:input name="name" :label="__('Name')" required/><flux:input name="abilities" :label="__('Abilities')" value="content.read"/><flux:input name="expires_at" type="datetime-local" :label="__('Expires at')"/><flux:button type="submit" variant="primary">{{ __('Create') }}</flux:button></form></flux:card><flux:card><h2 class="text-lg font-semibold">{{ __('Create webhook') }}</h2><form method="POST" action="{{ route('administration.api.webhooks.store') }}" class="mt-4 space-y-4">@csrf<flux:input name="name" :label="__('Name')" required/><flux:input name="url" type="url" :label="__('URL')" required/><flux:input name="events" :label="__('Events')" value="content.published"/><flux:input name="site_id" type="number" :label="__('Site ID')"/><flux:button type="submit" variant="primary">{{ __('Create') }}</flux:button></form></flux:card></div><flux:card><div class="flex justify-between"><h2 class="text-lg font-semibold">{{ __('Tokens') }}</h2><a class="underline" href="{{ route('api.v1.openapi') }}">OpenAPI JSON</a></div>@forelse($tokens as $token)<div class="flex justify-between border-b py-3"><div>{{ $token->name }} <span class="text-sm text-zinc-500">{{ implode(', ', $token->abilities ?? []) }}</span></div><form method="POST" action="{{ route('administration.api.tokens.destroy',$token) }}">@csrf @method('DELETE')<flux:button size="sm" variant="danger">{{ __('Revoke') }}</flux:button></form></div>@empty<p class="py-4 text-zinc-500">{{ __('No tokens.') }}</p>@endforelse{{ $tokens->links() }}</flux:card><flux:card><h2 class="text-lg font-semibold">{{ __('Webhooks') }}</h2>@forelse($webhooks as $webhook)<div class="flex justify-between border-b py-3"><div>{{ $webhook->name }} <span class="text-sm text-zinc-500">{{ $webhook->url }}</span></div><form method="POST" action="{{ route('administration.api.webhooks.destroy',$webhook) }}">@csrf @method('DELETE')<flux:button size="sm" variant="danger">{{ __('Delete') }}</flux:button></form></div>@empty<p class="py-4 text-zinc-500">{{ __('No webhooks.') }}</p>@endforelse{{ $webhooks->links() }}</flux:card></div></x-layouts.app>
+18
View File
@@ -0,0 +1,18 @@
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Modules\Api\Http\Controllers\ContentController;
use Modules\Api\Http\Controllers\OpenApiController;
use Modules\Api\Http\Middleware\AuthenticateApiToken;
Route::get('/openapi.json', OpenApiController::class)->name('openapi');
Route::controller(ContentController::class)->group(function (): void {
Route::get('/pages', 'pages');
Route::get('/pages/{page}', 'page');
Route::get('/blog', 'blog');
Route::get('/blog/{blog}', 'blogPost');
Route::get('/news', 'news');
Route::get('/news/{news}', 'newsArticle');
});
Route::middleware(AuthenticateApiToken::class)->get('/user', fn (Request $r) => $r->user());
+12
View File
@@ -0,0 +1,12 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Api\Http\Controllers\ApiController;
Route::middleware(['web', 'auth', 'permission:api.manage'])->prefix('administration/api')->name('administration.api.')->controller(ApiController::class)->group(function (): void {
Route::get('/', 'index')->name('index');
Route::post('/tokens', 'storeToken')->name('tokens.store');
Route::delete('/tokens/{token}', 'destroyToken')->name('tokens.destroy');
Route::post('/webhooks', 'storeWebhook')->name('webhooks.store');
Route::delete('/webhooks/{webhook}', 'destroyWebhook')->name('webhooks.destroy');
});
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\Api\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Modules\Api\Models\Webhook;
use Modules\Api\Models\WebhookDelivery;
final class WebhookDispatcher
{
public function dispatch(string $event, array $payload, ?int $siteId = null): void
{
Webhook::where('is_active', true)->get()->filter(fn (Webhook $h) => ($h->site_id === null || $h->site_id === $siteId) && $h->subscribesTo($event))->each(function (Webhook $h) use ($event, $payload): void {
$uuid = (string) Str::uuid();
$json = json_encode($payload, JSON_UNESCAPED_SLASHES) ?: '{}';
$delivery = WebhookDelivery::create(['webhook_id' => $h->id, 'uuid' => $uuid, 'event' => $event, 'payload' => $payload, 'attempts' => 1]);
try {
$res = Http::timeout((int) config('api.webhook_timeout', 10))->withHeaders(['X-CMS-Event' => $event, 'X-CMS-Delivery' => $uuid, 'X-CMS-Signature' => 'sha256='.hash_hmac('sha256', $json, $h->getRawOriginal('secret'))])->withBody($json, 'application/json')->post($h->url);
$delivery->update(['response_status' => $res->status(), 'response_body' => Str::limit($res->body(), 10000), 'delivered_at' => $res->successful() ? now() : null, 'failed_at' => $res->successful() ? null : now()]);
$h->update(['last_triggered_at' => now(), 'failure_count' => $res->successful() ? 0 : $h->failure_count + 1]);
} catch (\Throwable $e) {
$delivery->update(['response_body' => $e->getMessage(), 'failed_at' => now()]);
$h->increment('failure_count');
}
});
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
use App\Models\User;
use Modules\Api\Models\ApiToken;
use Modules\Api\Providers\ApiServiceProvider;
it('boots the api module', fn () => expect(app()->providerIsLoaded(ApiServiceProvider::class))->toBeTrue());
it('hashes issued api tokens', function (): void {
$u = User::factory()->create();
[$t,$plain] = ApiToken::issue($u, 'Test', ['content.read']);
expect($t->getRawOriginal('token'))->toBe(hash('sha256', $plain))->not->toBe($plain);
});
it('publishes openapi documentation', fn () => $this->getJson('/api/v1/openapi.json')->assertOk()->assertJsonPath('openapi', '3.1.0'));
+1
View File
@@ -0,0 +1 @@
{"name":"Api","alias":"api","version":"1.0.0","description":"REST APIs, bearer tokens, webhooks and OpenAPI.","keywords":["api","headless","webhooks"],"priority":90,"providers":["Modules\\Api\\Providers\\ApiServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Sites"]}
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'name' => 'Audit',
'enabled' => true,
];
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table): void {
$table->id();
$table->nullableMorphs('actor');
$table->string('event')->index();
$table->nullableMorphs('auditable');
$table->json('old_values')->nullable();
$table->json('new_values')->nullable();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->timestamp('created_at')->useCurrent();
});
}
public function down(): void
{
Schema::dropIfExists('audit_logs');
}
};
@@ -0,0 +1,14 @@
<?php
namespace Modules\Audit\Http\Controllers;
use App\Http\Controllers\Controller;
use Modules\Audit\Models\AuditLog;
final class AuditController extends Controller
{
public function index()
{
return view('audit::index', ['logs' => AuditLog::query()->latest('created_at')->paginate(50)]);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace Modules\Audit\Models;
use Illuminate\Database\Eloquent\Model;
final class AuditLog extends Model
{
public $timestamps = false;
protected $guarded = [];
protected function casts(): array
{
return ['old_values' => 'array', 'new_values' => 'array', 'created_at' => 'datetime'];
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Modules\Audit\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Audit\Support\ModuleInfo;
final class AuditServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower());
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
$this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower());
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace Modules\Audit\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
parent::boot();
$this->routes(function (): void {
Route::middleware('web')->group(module_path('Audit', 'Routes/web.php'));
});
}
}
+1
View File
@@ -0,0 +1 @@
<x-layouts.app :title="__('Audit log')"><div class="space-y-6"><div><h1 class="text-2xl font-semibold">Audit log</h1><p class="mt-1 text-sm text-zinc-500">Recorded platform activity.</p></div><div class="overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"><table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700"><thead><tr><th class="px-4 py-3 text-left">Event</th><th class="px-4 py-3 text-left">Actor</th><th class="px-4 py-3 text-left">IP</th><th class="px-4 py-3 text-left">Date</th></tr></thead><tbody class="divide-y divide-zinc-200 dark:divide-zinc-800">@forelse($logs as $log)<tr><td class="px-4 py-3 font-medium">{{ $log->event }}</td><td class="px-4 py-3">{{ $log->actor_type ? class_basename($log->actor_type).' #'.$log->actor_id : 'System' }}</td><td class="px-4 py-3">{{ $log->ip_address ?: '—' }}</td><td class="px-4 py-3">{{ optional($log->created_at)->format('d M Y H:i') }}</td></tr>@empty<tr><td colspan="4" class="px-4 py-10 text-center text-zinc-500">No audit events recorded.</td></tr>@endforelse</tbody></table></div>{{ $logs->links() }}</div></x-layouts.app>
+6
View File
@@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Audit\Http\Controllers\AuditController;
Route::middleware(['auth', 'verified'])->get('administration/audit', [AuditController::class, 'index'])->name('administration.audit.index');
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Modules\Audit\Support;
final class ModuleInfo
{
public static function name(): string
{
return 'Audit';
}
public static function nameLower(): string
{
return 'audit';
}
}
+5
View File
@@ -0,0 +1,5 @@
<?php
it('boots the Audit module', function (): void {
expect(config('audit.name'))->toBe('Audit');
});
+10
View File
@@ -0,0 +1,10 @@
{
"name": "absdev/audit-module",
"description": "Audit foundation module for the ABSDEV Laravel application platform.",
"type": "laravel-module",
"autoload": {
"psr-4": {
"Modules\\Audit\\": ""
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "Audit",
"alias": "audit",
"version": "1.0.0",
"description": "Audit foundation module for the ABSDEV Laravel application platform.",
"keywords": [
"laravel",
"modules",
"audit"
],
"priority": 10,
"providers": [
"Modules\\Audit\\Providers\\AuditServiceProvider"
],
"aliases": {},
"files": [],
"requires": [
"Core"
]
}
+3
View File
@@ -0,0 +1,3 @@
<?php
return ['name' => 'Authors'];
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('authors', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('name');
$table->string('slug')->unique();
$table->string('job_title')->nullable();
$table->text('biography')->nullable();
$table->string('avatar_path')->nullable();
$table->json('social_links')->nullable();
$table->string('meta_title')->nullable();
$table->text('meta_description')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('authors');
}
};
@@ -0,0 +1,57 @@
<?php
namespace Modules\Authors\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Modules\Authors\Models\Author;
final class AuthorController extends Controller
{
public function index(): View
{
return view('authors::admin.index', ['records' => Author::with('user')->latest()->paginate(25)]);
}
public function create(): View
{
return view('authors::admin.form', ['users' => User::orderBy('name')->get()]);
}
public function store(Request $r): RedirectResponse
{
Author::create($this->data($r));
return redirect()->route('administration.authors.index')->with('status', 'Author created.');
}
public function edit(Author $author): View
{
return view('authors::admin.form', ['record' => $author, 'users' => User::orderBy('name')->get()]);
}
public function update(Request $r, Author $author): RedirectResponse
{
$author->update($this->data($r));
return redirect()->route('administration.authors.index')->with('status', 'Author updated.');
}
public function destroy(Author $author): RedirectResponse
{
$author->delete();
return back()->with('status', 'Author deleted.');
}
private function data(Request $r): array
{
$d = $r->validate(['user_id' => ['nullable', 'exists:users,id'], 'name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255'], 'job_title' => ['nullable', 'string', 'max:255'], 'biography' => ['nullable', 'string'], 'avatar_path' => ['nullable', 'string', 'max:255'], 'meta_title' => ['nullable', 'string', 'max:255'], 'meta_description' => ['nullable', 'string'], 'is_active' => ['sometimes', 'boolean']]);
$d['is_active'] = $r->boolean('is_active');
return $d;
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Authors\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class Author extends Model
{
protected $fillable = ['user_id', 'name', 'slug', 'job_title', 'biography', 'avatar_path', 'social_links', 'meta_title', 'meta_description', 'is_active'];
protected function casts(): array
{
return ['social_links' => 'array', 'is_active' => 'boolean'];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Authors\Providers;
use Illuminate\Support\ServiceProvider;
final class AuthorsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Authors', 'Config/config.php'), 'authors');
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Authors', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Authors', 'Resources/views'), 'authors');
}
}
@@ -0,0 +1,15 @@
<?php
namespace Modules\Authors\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
parent::boot();
Route::middleware('web')->group(module_path('Authors', 'Routes/web.php'));
}
}
@@ -0,0 +1 @@
<x-layouts.app :title="isset($record) ? 'Edit author' : 'Create author'"><form method="POST" action="{{ isset($record) ? route('administration.authors.update',$record) : route('administration.authors.store') }}" class="max-w-3xl space-y-5">@csrf @isset($record) @method('PUT') @endisset <flux:select name="user_id" label="Linked user"><option value="">None</option>@foreach($users as $user)<option value="{{ $user->id }}" @selected(old('user_id',$record->user_id ?? null)==$user->id)>{{ $user->name }}</option>@endforeach</flux:select><flux:input name="name" label="Display name" value="{{ old('name',$record->name ?? '') }}" required/><flux:input name="slug" label="Slug" value="{{ old('slug',$record->slug ?? '') }}" required/><flux:input name="job_title" label="Job title" value="{{ old('job_title',$record->job_title ?? '') }}"/><flux:textarea name="biography" label="Biography">{{ old('biography',$record->biography ?? '') }}</flux:textarea><flux:input name="avatar_path" label="Avatar path" value="{{ old('avatar_path',$record->avatar_path ?? '') }}"/><flux:checkbox name="is_active" value="1" label="Active" :checked="old('is_active',$record->is_active ?? true)"/><flux:button type="submit" variant="primary">Save</flux:button></form></x-layouts.app>
@@ -0,0 +1 @@
<x-layouts.app :title="__('Authors')"><div class="flex items-center justify-between mb-6"><div><h1 class="text-2xl font-semibold">Authors</h1><p class="text-sm text-zinc-500">Manage publishing authors.</p></div><flux:button variant="primary" :href="route('administration.authors.create')" wire:navigate>Create</flux:button></div>@if(session('status'))<div class="mb-4 rounded border border-green-300 bg-green-50 p-3 text-green-800">{{ session('status') }}</div>@endif<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white"><table class="w-full text-sm"><thead class="bg-zinc-50"><tr><th class="p-3 text-left">Name / title</th><th class="p-3 text-left">Status</th><th class="p-3 text-right">Actions</th></tr></thead><tbody>@forelse($records as $record)<tr class="border-t"><td class="p-3 font-medium">{{ $record->title ?? $record->name }}</td><td class="p-3">{{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }}</td><td class="p-3 text-right"><flux:button size="sm" :href="route('administration.authors.edit', $record)" wire:navigate>Edit</flux:button><form class="inline" method="POST" action="{{ route('administration.authors.destroy', $record) }}">@csrf @method('DELETE')<flux:button size="sm" variant="danger" type="submit">Delete</flux:button></form></td></tr>@empty<tr><td colspan="3" class="p-6 text-center text-zinc-500">No records yet.</td></tr>@endforelse</tbody></table></div><div class="mt-4">{{ $records->links() }}</div></x-layouts.app>
+13
View File
@@ -0,0 +1,13 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Authors\Http\Controllers\AuthorController;
Route::middleware(['auth', 'verified'])->prefix('administration/authors')->name('administration.authors.')->group(function (): void {
Route::get('/', [AuthorController::class, 'index'])->name('index');
Route::get('/create', [AuthorController::class, 'create'])->name('create');
Route::post('/', [AuthorController::class, 'store'])->name('store');
Route::get('/{author}/edit', [AuthorController::class, 'edit'])->name('edit');
Route::match(['put', 'patch'], '/{author}', [AuthorController::class, 'update'])->name('update');
Route::delete('/{author}', [AuthorController::class, 'destroy'])->name('destroy');
});
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\Authors\Tests\Feature;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_module_configuration_is_available(): void
{
$this->assertSame('Authors', config('authors.name'));
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "Authors",
"alias": "authors",
"version": "1.0.0",
"description": "Authors publishing module.",
"keywords": ["publishing", "authors"],
"priority": 30,
"providers": ["Modules\\Authors\\Providers\\AuthorsServiceProvider"],
"aliases": {},
"files": [],
"requires": ["Core", "Administration"]
}
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'queue' => env('AUTOMATION_QUEUE', 'default'),
'max_steps' => (int) env('AUTOMATION_MAX_STEPS', 50),
'webhook_timeout' => (int) env('AUTOMATION_WEBHOOK_TIMEOUT', 10),
'retain_runs_days' => (int) env('AUTOMATION_RETAIN_RUNS_DAYS', 90),
];
@@ -0,0 +1,30 @@
<?php
namespace Modules\Automation\Console;
use Illuminate\Console\Command;
use Modules\Automation\Models\Automation;
use Modules\Automation\Services\AutomationRunner;
final class DispatchScheduledAutomations extends Command
{
protected $signature = 'automation:dispatch-scheduled';
protected $description = 'Dispatch active automations whose next run time is due.';
public function handle(AutomationRunner $runner): int
{
$count = 0;
Automation::query()->where('status', 'active')->where('trigger_type', 'schedule')->whereNotNull('next_run_at')->where('next_run_at', '<=', now())->chunkById(100, function ($items) use ($runner, &$count) {
foreach ($items as $automation) {
$runner->dispatch($automation, ['scheduled_at' => now()->toIso8601String()], 'schedule');
$interval = max(1, (int) ($automation->trigger_config['interval_minutes'] ?? 60));
$automation->update(['next_run_at' => now()->addMinutes($interval)]);
$count++;
}
});
$this->info("Dispatched {$count} scheduled automation(s).");
return self::SUCCESS;
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Modules\Automation\Contracts;
use Modules\Automation\Models\AutomationStep;
interface ActionRegistry
{
public function all(): array;
public function execute(AutomationStep $step, array $payload): array;
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Modules\Automation\Contracts;
interface TriggerRegistry
{
public function all(): array;
public function has(string $type): bool;
}

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