Analytics administration dashboard
* Page-view tracking * Unique visitor and session metrics * Content-view events * Search analytics * Conversion tracking * Form-submission events * Commerce events * Site-aware analytics * Privacy-conscious visitor hashing * Optional IP anonymisation * Do Not Track support * Configurable excluded paths * Daily metric aggregation * Top-page reporting * Recent-event activity * Report definitions * Scheduled report foundation * CSV and JSON report formats * Report execution history schema * Analytics retention and pruning * Administration navigation * Analytics permissions * Module registration * Feature test
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
<?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'),
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
<?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; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?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; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?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,42 @@
|
||||
<?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,30 @@
|
||||
<?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,21 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?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'];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?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'];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?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'];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?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,28 @@
|
||||
<?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,12 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?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');
|
||||
@@ -0,0 +1,8 @@
|
||||
<?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,27 @@
|
||||
<?php
|
||||
namespace Modules\Analytics\Services;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Analytics\Models\AnalyticsEvent;
|
||||
use Modules\Analytics\Models\DailyMetric;
|
||||
final class AnalyticsAggregator
|
||||
{
|
||||
public function aggregate(?string $date=null): int
|
||||
{
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?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();
|
||||
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', (string)($payload['visitor_id'] ?? $request->cookie('analytics_visitor','guest'))),
|
||||
'session_hash'=>hash('sha256', (string)($payload['session_id'] ?? $request->session()->getId())),
|
||||
'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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?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']);
|
||||
}
|
||||
}
|
||||
@@ -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"]}
|
||||
@@ -76,6 +76,9 @@ final class RolePermissionSeeder extends Seeder
|
||||
'ai.view',
|
||||
'ai.manage',
|
||||
'ai.execute',
|
||||
'analytics.view',
|
||||
'analytics.manage',
|
||||
'analytics.export',
|
||||
];
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
|
||||
@@ -40,5 +40,6 @@
|
||||
"Commerce": true,
|
||||
"Security": true,
|
||||
"Automation": true,
|
||||
"AI": true
|
||||
"AI": true,
|
||||
"Analytics": true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user