feat(analytics): add site analytics with grouped navigation sections
- Add Analytics container: models, tasks, jobs, controllers, routes, services - Add analytics config with section groups (structure, institute, content, services) - Add database tables: analytics_sessions, analytics_hits, analytics_daily_stats - Add frontend: Analytics page, section detail, chart, overview, navigation components - Add consent banner and JS tracking service (sendBeacon + Inertia listener) - Add permission sync for view_analytics, view_any_analytic - Fix trailing slash URL matching in GetNavigationSectionsTask - Fix event propagation on expand arrows in navigation sections - Fix RecordVisitJob to not depend on dropped analytics_geo_cache table - Replace plain text views/visitors with badge-style icons - Group navigation sections by category with separate blocks - Add config-driven non-CMS sections (faculties, divisions, DPO, journals) - Remove Events, TV, Sveden from analytics sections
This commit is contained in:
@@ -12,7 +12,7 @@ class EducationalGroupFactory extends Factory
|
||||
{
|
||||
return [
|
||||
'title' => $this->faker->words(3, true),
|
||||
'faculty_id' => \App\Containers\InstituteStructure\Models\Faculty::factory(),
|
||||
'faculty_id' => \App\Containers\InstituteStructure\Models\Faculty::inRandomOrder()->first(),
|
||||
'education_form_id' => $this->faker->numberBetween(1, 3),
|
||||
'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
|
||||
'updated_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
|
||||
|
||||
@@ -6,23 +6,13 @@ use App\Containers\Event\Models\EventCategory;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Event>
|
||||
*/
|
||||
class EventFactory extends Factory
|
||||
{
|
||||
protected $model = \App\Containers\Event\Models\Event::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$id = $this->faker->numberBetween(1, 1000000);
|
||||
$title = $this->faker->sentence;
|
||||
$slug = Str::slug($title);
|
||||
$content = array_map(function () {
|
||||
return [
|
||||
'type' => $this->faker->randomElement(['heading', 'paragraph']),
|
||||
@@ -32,29 +22,26 @@ class EventFactory extends Factory
|
||||
],
|
||||
];
|
||||
}, range(1, $this->faker->numberBetween(1, 5)));
|
||||
$today = now();
|
||||
$random_days = rand(1, 25); // Генерируем случайное число дней от 1 до 365
|
||||
$event_date_start = date('Y-m-d', strtotime($today->format('Y-m-d') . ' +' . $random_days . ' days'));
|
||||
$event_time_start = $this->faker->time('H:i');
|
||||
$address = $this->faker->address;
|
||||
$is_online = $this->faker->randomElement([true, false]);
|
||||
$category_id = EventCategory::inRandomOrder()->first();
|
||||
$created_at = now();
|
||||
$updated_at = now();
|
||||
|
||||
$eventDateStart = $this->faker->dateTimeBetween('-6 months', 'now');
|
||||
$endDate = date('Y-m-d', strtotime($eventDateStart->format('Y-m-d') . ' +' . rand(1, 30) . ' days'));
|
||||
$eventDateEnd = $this->faker->optional(0.5)->dateTimeBetween(
|
||||
$eventDateStart->format('Y-m-d'),
|
||||
$endDate
|
||||
);
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'title' => $title,
|
||||
'slug' => $slug,
|
||||
'slug' => Str::slug($title),
|
||||
'content' => $content,
|
||||
'event_date_start' => $event_date_start,
|
||||
'event_time_start' => $event_time_start,
|
||||
'address' => $address,
|
||||
'is_online' => $is_online,
|
||||
'category_id' => $category_id,
|
||||
'created_at' => $created_at,
|
||||
'updated_at' => $updated_at,
|
||||
'event_date_start' => $eventDateStart->format('Y-m-d'),
|
||||
'event_date_end' => $eventDateEnd?->format('Y-m-d'),
|
||||
'event_time_start' => $this->faker->time('H:i'),
|
||||
'address' => $this->faker->address,
|
||||
'is_online' => $this->faker->boolean,
|
||||
'category_id' => EventCategory::factory(),
|
||||
'created_at' => $this->faker->dateTimeBetween('-6 months', 'now'),
|
||||
'updated_at' => $this->faker->dateTimeBetween('-6 months', 'now'),
|
||||
];
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ class PageFactory extends Factory
|
||||
return [
|
||||
'title' => $title,
|
||||
'slug' => $slug,
|
||||
'path' => $slug,
|
||||
'content' => [['type' => 'paragraph', 'data' => ['content' => $this->faker->paragraph]]],
|
||||
'is_registered' => false,
|
||||
'is_visible' => true,
|
||||
|
||||
@@ -43,12 +43,12 @@ class UserDetailFactory extends Factory
|
||||
'photo' => "images/01J7TKMCR55KY7ARS2DA6VVAHE.jpg",
|
||||
'academicTitle' => $this->faker->word,
|
||||
'AcademicDegree' => $this->faker->word,
|
||||
'education' => $this->faker->sentence,
|
||||
'education' => [['item' => $this->faker->sentence(6)]],
|
||||
'awards' => $awards,
|
||||
'professDisciplines' => $generateItems(),
|
||||
'professionalRetraining' => $generateItems(),
|
||||
'professionalDevelopment' => $generateItems(),
|
||||
'workExperience' => $this->faker->randomDigit(),
|
||||
'workExperience' => $generateItems(),
|
||||
'attendedConferences' => $generateItems(),
|
||||
'publications' => $generateItems(),
|
||||
'contactEmail' => $this->faker->unique()->safeEmail,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?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_sessions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('visitor_id', 36)->index();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->string('entry_page');
|
||||
$table->string('ip', 45);
|
||||
$table->string('country', 2)->nullable();
|
||||
$table->string('city')->nullable();
|
||||
$table->string('utm_source')->nullable();
|
||||
$table->string('utm_medium')->nullable();
|
||||
$table->string('utm_campaign')->nullable();
|
||||
$table->string('browser')->nullable();
|
||||
$table->string('os')->nullable();
|
||||
$table->enum('device_type', ['desktop', 'tablet', 'mobile'])->nullable();
|
||||
$table->timestamp('started_at');
|
||||
$table->timestamp('last_activity_at');
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('user_id')->references('id')->on('users')->nullOnDelete();
|
||||
$table->index(['visitor_id', 'last_activity_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('analytics_sessions');
|
||||
}
|
||||
};
|
||||
@@ -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('analytics_hits', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('session_id');
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->string('url');
|
||||
$table->string('referrer')->nullable();
|
||||
$table->string('title')->nullable();
|
||||
$table->timestamp('created_at');
|
||||
|
||||
$table->foreign('session_id')->references('id')->on('analytics_sessions')->cascadeOnDelete();
|
||||
$table->foreign('user_id')->references('id')->on('users')->nullOnDelete();
|
||||
$table->index('url');
|
||||
$table->index('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('analytics_hits');
|
||||
}
|
||||
};
|
||||
@@ -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('analytics_daily_stats', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->date('date');
|
||||
$table->string('url');
|
||||
$table->unsignedInteger('views_count')->default(0);
|
||||
$table->unsignedInteger('unique_visitors')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['date', 'url']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('analytics_daily_stats');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?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::table('analytics_sessions', function (Blueprint $table) {
|
||||
$table->dropColumn(['country', 'city']);
|
||||
});
|
||||
|
||||
Schema::dropIfExists('analytics_geo_cache');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('analytics_sessions', function (Blueprint $table) {
|
||||
$table->string('country', 2)->nullable()->after('ip');
|
||||
$table->string('city')->nullable()->after('country');
|
||||
});
|
||||
|
||||
Schema::create('analytics_geo_cache', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('ip_hash', 64)->unique();
|
||||
$table->string('country')->nullable();
|
||||
$table->string('city')->nullable();
|
||||
$table->timestamp('cached_at');
|
||||
$table->index('cached_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Containers\Analytics\Models\AnalyticsHit;
|
||||
use App\Containers\Analytics\Models\AnalyticsSession;
|
||||
use App\Containers\User\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AnalyticsMockSeeder extends Seeder
|
||||
{
|
||||
private const URLS = [
|
||||
'/', '/news', '/faculties', '/faculties/1', '/faculties/2', '/faculties/3',
|
||||
'/departments', '/departments/1', '/departments/2', '/departments/3',
|
||||
'/schedules', '/schedules/1', '/schedules/2', '/events', '/events/1',
|
||||
'/additional-educations', '/additional-educations/1',
|
||||
'/page/abiturientam', '/page/studentam', '/page/nauchnaya-rabota',
|
||||
'/page/ob-institute', '/page/struktura', '/page/obrazovanie',
|
||||
'/news/1', '/news/2', '/news/3', '/news/4', '/news/5',
|
||||
'/news/6', '/news/7', '/news/8', '/news/9', '/news/10',
|
||||
'/departments/1/workers', '/departments/2/workers', '/departments/3/workers',
|
||||
'/faculties/1/workers', '/faculties/2/workers',
|
||||
];
|
||||
|
||||
private const BROWSERS = ['Chrome', 'Chrome', 'Chrome', 'YandexBrowser', 'Firefox', 'Safari', 'Edge'];
|
||||
private const OSSES = ['Windows', 'Windows', 'Windows', 'macOS', 'Linux', 'Android', 'iOS'];
|
||||
private const DEVICES = ['desktop', 'desktop', 'desktop', 'desktop', 'mobile', 'mobile', 'tablet'];
|
||||
private const COUNTRIES = ['RU', 'RU', 'RU', 'RU', 'BY', 'KZ', 'UZ'];
|
||||
private const CITIES = [
|
||||
'Moscow', 'Saint Petersburg', 'Novosibirsk', 'Yekaterinburg', 'Kazan',
|
||||
'Nizhny Tagil', 'Chelyabinsk', 'Minsk', 'Almaty', 'Tashkent',
|
||||
];
|
||||
private const SOURCES = [
|
||||
null, null, null, null, null,
|
||||
'https://www.google.com/search?q=ntspi',
|
||||
'https://yandex.ru/search/?text=ntspi',
|
||||
'https://vk.com/ntspi_official',
|
||||
'https://t.me/ntspi_news',
|
||||
'https://dzen.ru/a/ntspi',
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->command?->info('Creating analytics mock data (30 days)...');
|
||||
|
||||
for ($day = 0; $day < 30; $day++) {
|
||||
$date = now()->subDays($day);
|
||||
$sessionsCount = rand(8, 40);
|
||||
|
||||
for ($s = 0; $s < $sessionsCount; $s++) {
|
||||
$visitorId = Str::uuid();
|
||||
$hitsCount = rand(1, 12);
|
||||
$browser = self::BROWSERS[array_rand(self::BROWSERS)];
|
||||
$os = self::OSSES[array_rand(self::OSSES)];
|
||||
$device = self::DEVICES[array_rand(self::DEVICES)];
|
||||
|
||||
$startedAt = $date->copy()->addHours(rand(0, 23))->addMinutes(rand(0, 59));
|
||||
$lastActivity = $startedAt->copy()->addSeconds(rand(10, 1800));
|
||||
|
||||
$session = AnalyticsSession::create([
|
||||
'visitor_id' => $visitorId,
|
||||
'user_id' => rand(1, 10) > 8 ? User::inRandomOrder()->value('id') : null,
|
||||
'entry_page' => self::URLS[array_rand(self::URLS)],
|
||||
'ip' => long2ip(rand(0, 0xFFFFFF00)),
|
||||
'country' => self::COUNTRIES[array_rand(self::COUNTRIES)],
|
||||
'city' => self::CITIES[array_rand(self::CITIES)],
|
||||
'utm_source' => rand(1, 10) > 7 ? ['google', 'vk', 'telegram', 'dzen'][array_rand(['google', 'vk', 'telegram', 'dzen'])] : null,
|
||||
'utm_medium' => null,
|
||||
'utm_campaign' => null,
|
||||
'browser' => $browser,
|
||||
'os' => $os,
|
||||
'device_type' => $device,
|
||||
'started_at' => $startedAt,
|
||||
'last_activity_at' => $lastActivity,
|
||||
]);
|
||||
|
||||
for ($h = 0; $h < $hitsCount; $h++) {
|
||||
$hitTime = $startedAt->copy()->addSeconds(rand(0, max(1, $lastActivity->timestamp - $startedAt->timestamp)));
|
||||
|
||||
AnalyticsHit::create([
|
||||
'session_id' => $session->id,
|
||||
'user_id' => $session->user_id,
|
||||
'url' => self::URLS[array_rand(self::URLS)],
|
||||
'referrer' => self::SOURCES[array_rand(self::SOURCES)],
|
||||
'title' => null,
|
||||
'created_at' => $hitTime,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->command?->info('Analytics mock: ' . AnalyticsSession::count() . ' sessions, ' . AnalyticsHit::count() . ' hits');
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ class DatabaseSeeder extends Seeder
|
||||
public function run(): void
|
||||
{
|
||||
$this->call([
|
||||
RolesSeeder::class,
|
||||
MockDataSeeder::class,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ use App\Containers\Education\Models\AdmissionCampaign;
|
||||
use App\Containers\Education\Models\AdmissionPlan;
|
||||
use App\Containers\Education\Models\DirectionStudy;
|
||||
use App\Containers\Education\Models\EducationalProgram;
|
||||
use App\Containers\Event\Models\Event;
|
||||
use App\Containers\Event\Models\EventCategory;
|
||||
use App\Containers\InstituteStructure\Models\Department;
|
||||
use App\Containers\InstituteStructure\Models\Division;
|
||||
use App\Containers\InstituteStructure\Models\Faculty;
|
||||
@@ -49,52 +47,61 @@ class MockDataSeeder extends Seeder
|
||||
// 1. Users & Roles
|
||||
$this->createUsers();
|
||||
|
||||
// 2. Institute Structure
|
||||
$faculties = Faculty::factory()->count(8)->create();
|
||||
$departments = Department::factory()->count(15)->create();
|
||||
$divisions = Division::factory()->count(5)->create();
|
||||
// 2. Institute Structure: 6 факультетов, по 2 кафедры на каждый
|
||||
$faculties = Faculty::factory()->count(6)->create();
|
||||
|
||||
$departments = collect();
|
||||
foreach ($faculties as $faculty) {
|
||||
$departments = $departments->merge(
|
||||
Department::factory()->count(2)->create(['faculty_id' => $faculty->id])
|
||||
);
|
||||
}
|
||||
|
||||
$divisions = Division::factory()->count(20)->create();
|
||||
|
||||
// 3. Pivot: workers_faculties, workers_departments, teachers_departments
|
||||
$this->attachWorkers($faculties, $departments);
|
||||
|
||||
// 4. Article
|
||||
$categories = Category::factory()->count(20)->create();
|
||||
Post::factory()->count(100)->create();
|
||||
Post::factory()->count(1000)->create();
|
||||
Tag::factory()->count(30)->create();
|
||||
|
||||
// 5. Events
|
||||
$eventCategories = EventCategory::factory()->count(8)->create();
|
||||
Event::factory()->count(50)->create();
|
||||
|
||||
// 6. Education
|
||||
// 5. Education
|
||||
$directionStudies = DirectionStudy::factory()->count(10)->create();
|
||||
$programs = EducationalProgram::factory()->count(25)->create();
|
||||
$campaigns = AdmissionCampaign::factory()->count(5)->create();
|
||||
AdmissionPlan::factory()->count(30)->create();
|
||||
$programs = EducationalProgram::factory()->count(25)
|
||||
->sequence(fn () => ['direction_study_id' => $directionStudies->random()->id])
|
||||
->create();
|
||||
$campaigns = AdmissionCampaign::factory()->count(6)->create();
|
||||
AdmissionPlan::factory()->count(30)
|
||||
->sequence(fn () => [
|
||||
'admission_campaigns_id' => $campaigns->random()->id,
|
||||
'educational_programs_id' => $programs->random()->id,
|
||||
])
|
||||
->create();
|
||||
|
||||
// 7. Pivot: program_department
|
||||
// 6. Pivot: program_department
|
||||
$this->attachProgramsToDepartments($programs, $departments);
|
||||
|
||||
// 8. Additional Education
|
||||
$directions = DirectionAdditionalEducation::factory()->count(5)->create();
|
||||
$addCategories = AdditionalEducationCategory::factory()->count(12)->create();
|
||||
// 7. Additional Education
|
||||
DirectionAdditionalEducation::factory()->count(5)->create();
|
||||
AdditionalEducationCategory::factory()->count(12)->create();
|
||||
AdditionalEducation::factory()->count(20)->create();
|
||||
|
||||
// 9. Science
|
||||
$journals = AcademicJournal::factory()->count(4)->create();
|
||||
// 8. Science
|
||||
AcademicJournal::factory()->count(4)->create();
|
||||
JournalIssue::factory()->count(15)->create();
|
||||
|
||||
// 10. Schedule
|
||||
// 9. Schedule
|
||||
$groups = EducationalGroup::factory()->count(40)->create();
|
||||
Schedule::factory()->count(90)->create();
|
||||
|
||||
// 11. Widgets
|
||||
// 10. Widgets: 1 главный слайдер + 5 слайдов
|
||||
Slider::create([
|
||||
'title' => 'Главный слайдер',
|
||||
'slug' => 'quos-velit-quisquam',
|
||||
'is_active' => true,
|
||||
]);
|
||||
Slider::factory()->count(4)->create();
|
||||
|
||||
$mainSlider = Slider::where('slug', 'quos-velit-quisquam')->first();
|
||||
Slide::factory()->count(5)->create([
|
||||
@@ -103,8 +110,8 @@ class MockDataSeeder extends Seeder
|
||||
'start_time' => now()->subWeek(),
|
||||
'end_time' => now()->addMonth(),
|
||||
]);
|
||||
Slide::factory()->count(15)->create();
|
||||
|
||||
// 11. Contact & Reference widgets
|
||||
ContactWidget::create([
|
||||
'title' => 'Главная страница контакты',
|
||||
'slug' => 'glavnaia-stranica-kontakty',
|
||||
@@ -130,7 +137,6 @@ class MockDataSeeder extends Seeder
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
ContactWidget::factory()->count(2)->create();
|
||||
|
||||
PageReferenceList::create([
|
||||
'title' => 'Главная страница ресурсы',
|
||||
@@ -142,8 +148,8 @@ class MockDataSeeder extends Seeder
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
PageReferenceList::factory()->count(4)->create();
|
||||
$forms = CustomForm::factory()->count(8)->create();
|
||||
|
||||
CustomForm::factory()->count(8)->create();
|
||||
CustomFormResponse::factory()->count(40)->create();
|
||||
|
||||
// 12. App Structure
|
||||
@@ -152,10 +158,16 @@ class MockDataSeeder extends Seeder
|
||||
->count(15)
|
||||
->sequence(fn () => ['main_section_id' => $mainSections->random()->id])
|
||||
->create();
|
||||
Page::factory()
|
||||
->count(40)
|
||||
->sequence(fn () => ['sub_section_id' => $subSections->random()->id])
|
||||
->create();
|
||||
|
||||
$pages = collect();
|
||||
for ($i = 0; $i < 40; $i++) {
|
||||
$ss = $subSections->random();
|
||||
$ms = $ss->mainSection;
|
||||
$page = Page::factory()->make(['sub_section_id' => $ss->id]);
|
||||
$page->path = $ms->slug . '/' . $ss->slug . '/' . $page->slug;
|
||||
$page->save();
|
||||
$pages->push($page);
|
||||
}
|
||||
|
||||
// 13. Integration Credentials
|
||||
IntegrationCredential::factory()->count(3)->create();
|
||||
@@ -188,7 +200,6 @@ class MockDataSeeder extends Seeder
|
||||
);
|
||||
|
||||
User::factory()->count(50)->create();
|
||||
|
||||
UserDetail::factory()->count(30)->create();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user