2478 changed files with 8311 additions and 70100 deletions
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
-4
View File
@@ -1,4 +0,0 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by fontconfig.
# For information about cache directory tags, see:
# http://www.brynosaurus.com/cachedir/
@@ -1,54 +0,0 @@
Graphics Backend used: svp
Passed Tests: 66
Quirky Tests: 36
Failed Tests: 1
Skipped Tests: 6
---Name of the tests that failed---
testDrawInvertN50WithRectangle
---Name of the tests that were Quirky---
testDrawRectWithLine
testDrawRectWithPolygon
testDrawRectWithPolyLine
testDrawRectWithPolyPolygon
testDrawRectWithPolyPolygonB2D
testDrawDiamondWithLine
testComplexDrawTransformedBitmap24bpp
testDashedLine
testLinearGradientBorder
testLinearGradientSteps
testRadialGradient
testRadialGradientOfs
testHalfEllipseWithPolyLine
testHalfEllipseAAWithPolyLine
testHalfEllipseAAWithPolyLineB2D
testHalfEllipseWithPolygon
testHalfEllipseAAWithPolygon
testTextDrawing
testDrawRectangleOnSize1028WithPixel
testDrawRectangleOnSize4096WithPixel
testDrawRectangleOnSize1028WithLine
testDrawRectangleOnSize4096WithLine
testDrawRectangleOnSize1028WithPolyLine
testDrawRectangleOnSize4096WithPolyLine
testDrawRectangleOnSize1028WithPolygon
testDrawRectangleOnSize4096WithPolygon
testDrawRectangleOnSize1028WithPolyLineB2D
testDrawRectangleOnSize4096WithPolyLineB2D
testDrawRectangleOnSize1028WithPolyPolygon
testDrawRectangleOnSize4096WithPolyPolygon
testDrawRectangleOnSize1028WithPolyPolygonB2D
testDrawRectangleOnSize4096WithPolygonPolygonB2D
testDrawOpenPolygonWithPolyLine
testDrawOpenPolygonWithPolygon
testDrawOpenPolygonWithPolyPolygon
testDrawOpenPolygonWithPolyPolygonB2D
---Name of the tests that were Skipped---
testDrawInvertTrackFrameWithRectangle
testDrawBitmap32bpp
testDrawTransformedBitmap32bpp
testDrawBitmapExWithAlpha32bpp
testDrawMask32bpp
testDrawBlend32bpp
Binary file not shown.
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE script:module PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "module.dtd">
<!--
* This file is part of the LibreOffice project.
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* This file incorporates work covered by the following license notice:
*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed
* with this work for additional information regarding copyright
* ownership. The ASF licenses this file to you under the Apache
* License, Version 2.0 (the "License"); you may not use this file
* except in compliance with the License. You may obtain a copy of
* the License at http://www.apache.org/licenses/LICENSE-2.0 .
-->
<script:module xmlns:script="http://openoffice.org/2000/script" script:name="Module1" script:language="StarBasic">REM ***** BASIC *****
Sub Main
End Sub</script:module>
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE library:library PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "library.dtd">
<library:library xmlns:library="http://openoffice.org/2000/library" library:name="Standard" library:readonly="false" library:passwordprotected="false"/>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE library:library PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "library.dtd">
<library:library xmlns:library="http://openoffice.org/2000/library" library:name="Standard" library:readonly="false" library:passwordprotected="false">
<library:element library:name="Module1"/>
</library:library>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE library:libraries PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "libraries.dtd">
<library:libraries xmlns:library="http://openoffice.org/2000/library" xmlns:xlink="http://www.w3.org/1999/xlink">
<library:library library:name="Standard" library:link="false"/>
</library:libraries>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE library:libraries PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "libraries.dtd">
<library:libraries xmlns:library="http://openoffice.org/2000/library" xmlns:xlink="http://www.w3.org/1999/xlink">
<library:library library:name="Standard" library:link="false"/>
</library:libraries>
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
520(Build:2)
@@ -1 +0,0 @@
1
@@ -1,2 +0,0 @@
<?xml version="1.0"?>
<conf:configuration-backend-db xmlns:conf="http://openoffice.org/extensionmanager/configuration-registry/2010"/>
@@ -1,2 +0,0 @@
<?xml version="1.0"?>
<help:help-backend-db xmlns:help="http://openoffice.org/extensionmanager/help-registry/2010"/>
@@ -1 +0,0 @@
1
@@ -1,2 +0,0 @@
<?xml version="1.0"?>
<conf:configuration-backend-db xmlns:conf="http://openoffice.org/extensionmanager/configuration-registry/2010"/>
@@ -1,2 +0,0 @@
<?xml version="1.0"?>
<help:help-backend-db xmlns:help="http://openoffice.org/extensionmanager/help-registry/2010"/>
@@ -1,2 +0,0 @@
<?xml version="1.0"?>
<conf:configuration-backend-db xmlns:conf="http://openoffice.org/extensionmanager/configuration-registry/2010"/>
@@ -1,2 +0,0 @@
<?xml version="1.0"?>
<help:help-backend-db xmlns:help="http://openoffice.org/extensionmanager/help-registry/2010"/>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,52 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<item oor:path="/org.openoffice.Office.Common/Misc"><prop oor:name="FirstRun" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Linguistic/ServiceManager/LastFoundSpellCheckers"><prop oor:name="en-US" oor:op="fuse" oor:type="oor:string-list"><value><it>org.openoffice.lingu.MySpellSpellChecker</it></value></prop></item>
<item oor:path="/org.openoffice.Office.Linguistic/ServiceManager/SpellCheckerList"><prop oor:name="en-US" oor:op="fuse" oor:type="oor:string-list"><value><it>org.openoffice.lingu.MySpellSpellChecker</it></value></prop></item>
<item oor:path="/org.openoffice.Office.Logging/Settings"><node oor:name="unopkg" oor:op="replace"><prop oor:name="LogLevel" oor:op="fuse"><value>2147483647</value></prop><prop oor:name="DefaultHandler" oor:op="fuse"><value>com.sun.star.logging.FileHandler</value></prop><node oor:name="HandlerSettings"><prop oor:name="FileURL" oor:op="fuse"><value>$(userurl)/$(loggername).log</value></prop></node><prop oor:name="DefaultFormatter" oor:op="fuse"><value>com.sun.star.logging.PlainTextFormatter</value></prop><node oor:name="FormatterSettings"></node></node></item>
<item oor:path="/org.openoffice.Office.Views/Windows"><node oor:name="SplitWindow0" oor:op="replace"><prop oor:name="Visible" oor:op="fuse"><value xsi:nil="true"/></prop><node oor:name="UserData"><prop oor:name="UserItem" oor:op="fuse" oor:type="xs:string"><value>V1,2,0</value></prop></node><prop oor:name="WindowState" oor:op="fuse"><value xsi:nil="true"/></prop></node></item>
<item oor:path="/org.openoffice.Office.Views/Windows"><node oor:name="SplitWindow1" oor:op="replace"><prop oor:name="Visible" oor:op="fuse"><value xsi:nil="true"/></prop><node oor:name="UserData"><prop oor:name="UserItem" oor:op="fuse" oor:type="xs:string"><value>V1,2,0</value></prop></node><prop oor:name="WindowState" oor:op="fuse"><value xsi:nil="true"/></prop></node></item>
<item oor:path="/org.openoffice.Office.Views/Windows"><node oor:name="SplitWindow2" oor:op="replace"><prop oor:name="Visible" oor:op="fuse"><value xsi:nil="true"/></prop><node oor:name="UserData"><prop oor:name="UserItem" oor:op="fuse" oor:type="xs:string"><value>V1,2,0</value></prop></node><prop oor:name="WindowState" oor:op="fuse"><value xsi:nil="true"/></prop></node></item>
<item oor:path="/org.openoffice.Office.Views/Windows"><node oor:name="SplitWindow3" oor:op="replace"><prop oor:name="Visible" oor:op="fuse"><value xsi:nil="true"/></prop><node oor:name="UserData"><prop oor:name="UserItem" oor:op="fuse" oor:type="xs:string"><value>V1,2,0</value></prop></node><prop oor:name="WindowState" oor:op="fuse"><value xsi:nil="true"/></prop></node></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="DefaultAnchor" oor:op="fuse"><value>1</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="DrawingControl" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="FieldCode" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="GraphicObject" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="Note" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="SectionBoundaries" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="ShowBoundaries" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="ShowChangesInMargin" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="ShowContentTips" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="ShowInlineTooltips" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="ShowOutlineContentVisibilityButton" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="Table" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="TableBoundaries" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="TextBoundaries" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="TextBoundariesFull" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="TreatSubOutlineLevelsAsContent" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="UseHeaderFooterMenu" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="Bookmarks" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="Break" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="HiddenCharacter" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="HiddenParagraph" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="HiddenText" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="MetaCharacters" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="OptionalHyphen" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="ParagraphEnd" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="ProtectedSpace" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="Space" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="Tab" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Update"><prop oor:name="Chart" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Update"><prop oor:name="Field" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Update"><prop oor:name="Link" oor:op="fuse"><value>1</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Zoom"><prop oor:name="DefaultZoom" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Zoom"><prop oor:name="ZoomType" oor:op="fuse"><value>0</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Zoom"><prop oor:name="ZoomValue" oor:op="fuse"><value>100</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/FmtAidsAutocomplete"><prop oor:name="EncloseWithCharacters" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Setup/L10N"><prop oor:name="ooLocale" oor:op="fuse"><value>en-US</value></prop></item>
<item oor:path="/org.openoffice.Setup/Office/Factories/org.openoffice.Setup:Factory['com.sun.star.text.TextDocument']"><prop oor:name="ooSetupFactoryWindowAttributes" oor:op="fuse"><value>0,0,800,600;1;,,,;</value></prop></item>
<item oor:path="/org.openoffice.Setup/Office"><prop oor:name="LastCompatibilityCheckID" oor:op="fuse"><value>520(Build:2)</value></prop></item>
<item oor:path="/org.openoffice.Setup/Office"><prop oor:name="OfficeRestartInProgress" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Setup/Office"><prop oor:name="ooSetupInstCompleted" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Setup/Product"><prop oor:name="ooSetupLastVersion" oor:op="fuse"><value>25.2</value></prop></item>
</oor:items>
@@ -1,2 +0,0 @@
<?xml version="1.0"?>
<conf:configuration-backend-db xmlns:conf="http://openoffice.org/extensionmanager/configuration-registry/2010"/>
@@ -1,2 +0,0 @@
<?xml version="1.0"?>
<help:help-backend-db xmlns:help="http://openoffice.org/extensionmanager/help-registry/2010"/>
Executable → Regular
+1 -16
View File
@@ -1,6 +1,5 @@
.env
deploy.sh
.deploy_prev_sha
/node_modules
/vendor
/sourcefiles
@@ -10,18 +9,4 @@ _deploy
/public/abitur
/public/sveden
dump.sql
.idea
.DS_Store
**/.
.cursor
.cache
hot
/storage/logs
resources/js/ziggy.js
public/vikon_core
.mimocode
.qwen
QWEN.md
AGENTS.md
docs
.phpunit.result.cache
.idea
File diff suppressed because one or more lines are too long
Vendored
-179
View File
@@ -1,179 +0,0 @@
pipeline {
agent any
environment {
APP_DIR = '/var/www'
CONTAINER = 'ntspi-php'
}
stages {
stage('Pre-deploy') {
parallel {
stage('Backup Database') {
steps {
dir("${APP_DIR}") {
sh '''
mkdir -p backups
docker exec ntspi-db mysqldump -u admin -psecret ntspi_db | gzip > backups/ntspi_db_$(date +%F_%H%M).sql.gz
find backups -name "*.sql.gz" -mtime +3 -delete
'''
}
}
}
stage('Cleanup Old Files') {
steps {
dir("${APP_DIR}") {
sh 'docker exec ntspi-php find /var/www/storage/app/email_attachments -type f -mtime +3 -delete'
}
}
}
}
}
stage('Maintenance Mode') {
steps {
dir("${APP_DIR}") {
sh 'docker exec ntspi-php php artisan down || true'
}
}
}
stage('Pull from Git') {
steps {
dir("${APP_DIR}") {
sh 'git config --global --add safe.directory ${APP_DIR}'
sh 'DEPLOY_PREV_SHA=$(git rev-parse HEAD) && echo $DEPLOY_PREV_SHA > .deploy_prev_sha'
sh 'git reset --hard'
sh 'git remote set-url origin https://git.occasus.ru/failj/ntspi-app.git'
sh 'git pull origin master'
}
}
}
stage('Install Dependencies') {
steps {
dir("${APP_DIR}") {
sh 'docker exec ntspi-php php -d open_basedir=none /usr/local/bin/composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader'
}
}
}
stage('Database Migrations') {
steps {
dir("${APP_DIR}") {
sh 'docker exec ntspi-php php artisan migrate --force'
sh 'docker exec ntspi-php php artisan dashboard:sync-permissions'
}
}
post {
failure {
dir("${APP_DIR}") {
sh '''
PREV_SHA=$(cat .deploy_prev_sha 2>/dev/null || echo "")
if [ -n "$PREV_SHA" ]; then
echo "Rolling back to $PREV_SHA"
git reset --hard $PREV_SHA
docker exec ntspi-php php artisan migrate:rollback --force || true
docker exec ntspi-php sh -c "php -d open_basedir=none /usr/local/bin/composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader"
docker exec ntspi-php php artisan cache:clear
docker restart ntspi-nginx ntspi-php ntspi-php-queue
fi
'''
}
}
}
}
stage('Build Frontend') {
steps {
dir("${APP_DIR}") {
sh 'docker exec ntspi-php sh -c "npm ci --legacy-peer-deps && npm run build"'
}
}
}
stage('Clear & Warm Caches') {
steps {
dir("${APP_DIR}") {
sh '''
docker exec ntspi-php php artisan cache:clear
docker exec ntspi-php php artisan config:clear
docker exec ntspi-php php artisan route:clear
docker exec ntspi-php php artisan view:clear
docker exec ntspi-php php artisan filament:clear-cached-components
docker exec ntspi-php php artisan filament:optimize-clear
docker exec ntspi-php php artisan optimize
docker exec ntspi-php php artisan icons:cache
docker exec ntspi-php php artisan filament:cache-components
docker exec ntspi-php php artisan filament:optimize
'''
}
}
}
stage('Fix Permissions') {
steps {
dir("${APP_DIR}") {
sh 'docker exec -u root ntspi-php chown -R www-data:www-data storage bootstrap/cache'
sh 'docker exec -u root ntspi-php chmod -R 775 storage bootstrap/cache'
}
}
}
stage('Restart Services') {
steps {
dir("${APP_DIR}") {
sh '''
docker restart ntspi-nginx ntspi-php ntspi-php-queue
sleep 5
docker exec ntspi-php php artisan inertia:stop-ssr || true
sleep 2
docker exec ntspi-php php artisan inertia:start-ssr || true
'''
}
}
}
stage('Health Check') {
steps {
dir("${APP_DIR}") {
sh '''
for i in $(seq 1 30); do
if docker exec ntspi-php php artisan about > /dev/null 2>&1; then
echo "App is ready"
break
fi
echo "Waiting for app... ($i/30)"
sleep 1
done
'''
}
}
}
stage('Enable App') {
steps {
dir("${APP_DIR}") {
sh 'docker exec ntspi-php php artisan up'
}
}
}
}
post {
success {
echo "Deploy completed successfully"
dir("${APP_DIR}") {
sh 'rm -f .deploy_prev_sha'
}
}
failure {
echo "Deploy failed. Check build logs in Jenkins."
dir("${APP_DIR}") {
sh 'docker exec ntspi-php php artisan up || true'
sh 'rm -f .deploy_prev_sha'
}
}
}
}
-118
View File
@@ -1,118 +0,0 @@
***
# Corporate Digital University System (NTSPI)
A comprehensive web platform designed for the **Nizhny Tagil State Social Pedagogical Institute (NTSPI)**. This system serves as a full-featured replacement for the legacy website, providing tools for content management, educational program administration, admission campaigns, and institutional structure management.
## 🚀 Tech Stack
### Frontend
* **Framework:** Vue.js 3 (Composition API)
* **Adapter:** Inertia.js (Monolith structure with SPA feel)
* **Styling:** Tailwind CSS
* **Build Tool:** Vite
### Backend
* **Core:** PHP 8.2 / Laravel
* **Admin Panel:** Filament PHP (CMS & Resource Management)
* **Database:** MySQL 8.0
* **Caching & Queues:** Redis
* **Server:** Nginx + PHP-FPM
### Infrastructure
* **Containerization:** Docker & Docker Compose
* **SSL:** Certbot (Let's Encrypt)
---
## 🏗 Architecture
The backend follows the **Porto Architecture Pattern**, organizing code into logical modular units to ensure scalability and maintainability.
* **Ship Layer:** Low-level code, parent classes, and configuration.
* **Containers Layer:** Encapsulates business logic into modular containers (e.g., `AdditionalEducation`, `Article`, `Event`, `User`, `Schedule`, etc.).
* **Request Lifecycle:** Route -> Controller -> Action -> Task -> UI.
---
## ✨ Key Features
* **Content Management (CMS):** Advanced management of News, Articles, and Events with media galleries and status workflows (Draft, Published, Archived).
* **Institute Structure:** Hierarchical management of Faculties, Departments (Kafedras), Divisions, and Staff profiles.
* **Educational Programs:** Catalog of study programs, admission plans, and entrance exam requirements.
* **Admissions Management:** Tools to manage "Admission Campaigns" (Priemnaya Kampaniya), enabling dynamic updates to student intake numbers and rules.
* **Role-Based Access Control (RBAC):** Powered by **Filament Shield**. Granular permissions allowing different departments (Deans, Press Service, HR) to manage their specific sections independently.
* **Dynamic Routing:** Custom pages and navigation structures managed directly via the Database.
* **Cross-Model Search:** Unified global search across News, Pages, Events, and Programs using standard Eloquent models.
---
## 🛠 Installation & Local Development
The project includes a Docker environment for easy deployment.
### 1. Clone the Repository
```bash
git clone https://github.com/F4ilji/ntspi-app.git
cd ntspi-app
```
### 2. Configure Environment
Create the environment file from the example.
```bash
cp example.env .env
```
### 3. Create Directories & Set Permissions
Ensure Laravel storage directories exist and have write permissions.
```bash
mkdir -p storage/{app,logs,framework/{cache,sessions,views}}
sudo chown -R www-data:www-data storage
sudo chmod -R 775 storage
```
### 4. Start Docker Containers
Launch Nginx, MySQL, Redis, and the App container.
```bash
docker compose up --build -d
```
### 5. Install Dependencies
Enter the PHP container to run setup commands:
```bash
docker exec -it ntspi-php bash
```
Inside the container, run:
```bash
# Backend dependencies
composer install
# Database migrations
php artisan migrate
# Frontend dependencies
npm i
# Build assets
npm run build
```
### 6. Initialize System Data
Set up user roles and seed the database with initial data.
```bash
# Initialize permissions/roles (Critical for Filament Shield)
php artisan roles:init-roles
# Seed dummy data (Optional)
php artisan db:seed
```
---
## 📂 Project Structure Overview
* `_docker/`: Docker configuration files (Nginx, PHP, MySQL).
* `app/Containers/`: Business logic modules (Porto pattern).
* `resources/js/`: Vue.js components, Pages, and shared UI assets.
* `routes/`: Web and API routes (including dynamic DB-based routing logic).
BIN
View File
Binary file not shown.
Executable → Regular
+15 -38
View File
@@ -1,9 +1,6 @@
FROM php:8.2-fpm
# Устанавливаем рабочую директорию
WORKDIR /var/www
# Установка системных зависимостей и инструментов в одном слое
# Установка системных зависимостей
RUN apt-get update && apt-get install -y \
apt-utils \
libpq-dev \
@@ -24,26 +21,15 @@ RUN apt-get update && apt-get install -y \
libicu-dev \
g++ \
default-mysql-client \
# LibreOffice для конвертации DOC в DOCX
libreoffice-common \
libreoffice-writer \
# Kerberos для IMAP
libkrb5-dev \
# Установка Node.js (актуальная LTS-версия 18)
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& docker-php-ext-configure gd --with-webp --with-jpeg \
&& docker-php-ext-install gd pdo_mysql bcmath zip intl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Установка Node.js (актуальная LTS-версия)
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g npm \
# Очистка кэша для уменьшения размера образа
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Установка install-php-extensions
ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN chmod +x /usr/local/bin/install-php-extensions
# Установка расширений PHP
RUN docker-php-ext-configure gd --with-webp --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd pdo_mysql bcmath zip intl \
&& install-php-extensions imap
&& npm install -g npm
# Установка Composer
ENV COMPOSER_ALLOW_SUPERUSER=1
@@ -51,19 +37,7 @@ RUN curl -sS https://getcomposer.org/installer | php -- \
--filename=composer \
--install-dir=/usr/local/bin
# Создание необходимых директорий для Laravel
RUN mkdir -p storage/framework/views storage/framework/sessions storage/framework/cache storage/logs bootstrap/cache
# Добавляем www-data в группу с GID=1000 (совпадает с GID хост-пользователя)
RUN groupadd -g 1000 hostgroup 2>/dev/null || true \
&& usermod -aG hostgroup www-data
# Установка правильных прав доступа
RUN chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R 775 storage bootstrap/cache
# Копирование конфигураций PHP и Supervisor
# Копирование конфигураций
COPY ./_docker/app/php.ini /usr/local/etc/php/conf.d/php.ini
COPY ./_docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
@@ -73,9 +47,12 @@ RUN chmod 0644 /etc/cron.d/laravel-cron \
&& crontab /etc/cron.d/laravel-cron \
&& touch /var/log/cron.log
# Запуск Supervisor (управляет PHP-FPM, Inertia SSR, и worker-ами)
# Рабочая директория
WORKDIR /var/www
# Запуск Supervisor (управляет PHP-FPM и Inertia SSR)
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/supervisord.conf"]
# Открываем порты
EXPOSE 9000
EXPOSE 13714
EXPOSE 13714
Executable → Regular
View File
Executable → Regular
+1 -53
View File
@@ -1,56 +1,4 @@
post_max_size = 128M
upload_max_filesize = 128M
max_file_uploads = 50
; OPTIMIZATION: Увеличен лимит памяти для обработки больших IMAP-сообщений
; Предыдущая ошибка: 256MB было недостаточно для загрузки всех писем
; Теперь: 1GB позволяет обрабатывать письма batch-ами по 10 штук
memory_limit = 1G
memory_limit = 256M
request_terminate_timeout = 300
max_execution_time = 120
max_input_time = 120
; OPTIMIZATION: Настройки OPcache для производительности
opcache.enable = 1
opcache.memory_consumption = 128
opcache.max_accelerated_files = 10000
opcache.revalidate_freq = 2
; OPTIMIZATION: Настройки сессий
session.gc_maxlifetime = 1440
session.cookie_lifetime = 0
; ============================================
; SECURITY: open_basedir restriction
; ОТКЛЮЧЕНО — блокирует работу Composer, Artisan, LibreOffice
; ============================================
; open_basedir = /var/www:/tmp:/root/.config:/usr/local/bin:/usr/local/lib/php
; ============================================
; SECURITY: Запрет опасных функций
; curl_exec и shell_exec оставлены — используются приложением (VK API, LibreOffice конвертация)
; proc_open и popen оставлены — требуются для Inertia SSR (Symfony Process)
; ============================================
disable_functions = passthru,system,parse_ini_file,show_source,highlight_file,pcntl_exec,leak,apache_child_terminate,posix_kill,posix_mkfifo,posix_setpgid,posix_setsid,posix_setuid
; ============================================
; SECURITY: Запрет удалённого включения файлов
; Предотвращает RFI (Remote File Inclusion) атаки
; ============================================
allow_url_include = Off
allow_url_fopen = On
; ============================================
; SECURITY: Защищённые сессии
; ============================================
session.cookie_httponly = 1
session.cookie_secure = 1
session.use_strict_mode = 1
session.use_only_cookies = 1
session.name = __Secure-NTSPISESSID
; ============================================
; SECURITY: Скрыть версию PHP из заголовков
; ============================================
expose_php = Off
-24
View File
@@ -1,24 +0,0 @@
# _docker/jenkins/Dockerfile
FROM jenkins/jenkins:lts-jdk17
# Переключаемся на пользователя root для установки пакетов
USER root
# Устанавливаем Docker CLI и другие утилиты
RUN apt-get update && apt-get install -y --no-install-recommends lsb-release curl && \
curl -fsSLo /usr/share/keyrings/docker-archive-keyring.asc https://download.docker.com/linux/debian/gpg && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.asc] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && \
apt-get update && \
apt-get install -y --no-install-recommends docker-ce-cli && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Устанавливаем Docker Compose v2 (как плагин)
RUN mkdir -p /usr/local/lib/docker/cli-plugins && \
curl -SL "https://github.com/docker/compose/releases/download/v2.2.3/docker-compose-linux-$(uname -m)" -o /usr/local/lib/docker/cli-plugins/docker-compose && \
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
# Возвращаемся к пользователю jenkins
USER jenkins
Executable → Regular
View File
-10
View File
@@ -25,17 +25,7 @@ server {
try_files $uri /index.php?$args; # Обработка запросов
}
# ========================================================================
# VIKON MODULE SECURITY — Block executable files in module directories
# MUST be placed BEFORE location ~ \.php$ to take effect
# ========================================================================
# Block PHP and other server-side scripts in sveden/abitur
location ~ ^/(sveden|abitur)/.*\.(php|php3|php4|php5|php7|php8|phps|phtml|pl|py|pyc|cgi|sh|bash|bat|cmd|exe|com|ps1|psm1|rb|asp|aspx|jsp|cfm)$ {
deny all;
return 403;
access_log /var/log/nginx/blocked_module_scripts.log;
}
location /sveden/ {
alias /var/www/public/sveden/;
+6 -121
View File
@@ -50,40 +50,17 @@ server {
root /var/www/public;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self' https:; script-src 'self' 'unsafe-inline' 'unsafe-eval' https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; font-src 'self' data: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-ancestors 'self';" always;
location ^~ /jenkins/ {
proxy_pass http://jenkins:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 90;
proxy_max_temp_file_size 0;
proxy_redirect http:// https://;
}
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy "strict-origin-when-cross-origin";
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
location / {
add_header Access-Control-Allow-Origin "https://www.ntspi.ru" always;
add_header Access-Control-Allow-Origin *;
try_files $uri /index.php?$args;
}
# ========================================================================
# VIKON MODULE SECURITY — Block executable files in module directories
# MUST be placed BEFORE location ~ \.php$ to take effect
# ========================================================================
location ~ ^/(sveden|abitur)/.*\.(php|php3|php4|php5|php7|php8|phps|phtml|pl|py|pyc|cgi|sh|bash|bat|cmd|exe|com|ps1|psm1|rb|asp|aspx|jsp|cfm)$ {
deny all;
return 403;
access_log /var/log/nginx/blocked_module_scripts.log;
}
location /sveden/ {
alias /var/www/public/sveden/;
index index.html;
@@ -108,97 +85,7 @@ server {
return 301 /abitur/;
}
location /vsoko/ {
alias /var/www/public/vsoko/;
index index.html;
try_files $uri $uri/ /vsoko/index.html;
expires epoch;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}
location = /vsoko {
return 301 /vsoko/;
}
# ============================================
# SECURITY: Запрет доступа к чувствительным файлам
# ============================================
# Запрет доступа к .env, .git и всем скрытым файлам
location ~ /\. {
deny all;
return 404;
}
# Запрет доступа к composer, package, lock, readme файлам
location ~* (composer\.(json|lock)|package\.(json|lock)|\.md|\.txt|\.log)$ {
deny all;
return 404;
}
# Разрешаем доступ к публичным vendor ассетам (log-viewer, и т.д.)
# Это статические файлы в public/vendor/
location /vendor/ {
# Запрещаем выполнение PHP файлов
location ~ \.php$ {
deny all;
return 404;
}
# Отдаём статические файлы
try_files $uri =404;
autoindex off;
}
# Разрешаем доступ к публичным файлам storage (через symlink public/storage)
# Это должны быть ТОЛЬКО изображения, документы и т.д. — НЕ PHP файлы
location /storage/ {
# Запрещаем выполнение PHP файлов в storage
location ~ \.php$ {
deny all;
return 404;
}
# Разрешаем доступ к статическим файлам
try_files $uri =404;
# Запрещаем листинг директорий
autoindex off;
}
# Запрет прямого доступа к internal storage paths (в обход symlink)
location ~ ^/storage/app/(?!public) {
deny all;
return 404;
}
# ============================================
# VIKON: OAuth callback redirect (index.php)
# ============================================
location = /vikon_core/update/index.php {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass app:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
fastcgi_param REQUEST_SCHEME https;
fastcgi_param HTTPS On;
}
# ============================================
# PHP-FPM: ТОЛЬКО /index.php как entry point
# ============================================
location ~ \.php$ {
# КРИТИЧНО: Разрешаем ТОЛЬКО /index.php
# Любой другой .php файл (например, shell.php в uploads) — 403
if ($uri !~ ^/index\.php$) {
return 403;
}
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass app:9000;
@@ -208,8 +95,6 @@ server {
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
fastcgi_read_timeout 600;
fastcgi_send_timeout 600;
fastcgi_param REQUEST_SCHEME https;
fastcgi_param HTTPS On;
}
+1 -22
View File
@@ -20,32 +20,11 @@ server {
root /var/www/public;
location ^~ /jenkins/ {
proxy_pass http://jenkins:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 90;
proxy_max_temp_file_size 0;
proxy_redirect http:// https://;
}
location / {
add_header Access-Control-Allow-Origin *; # Заголовок для CORS
try_files $uri /index.php?$args; # Обработка запросов
}
# ========================================================================
# VIKON MODULE SECURITY — Block executable files in module directories
# MUST be placed BEFORE location ~ \.php$ to take effect
# ========================================================================
location ~ ^/(sveden|abitur)/.*\.(php|php3|php4|php5|php7|php8|phps|phtml|pl|py|pyc|cgi|sh|bash|bat|cmd|exe|com|ps1|psm1|rb|asp|aspx|jsp|cfm)$ {
deny all;
return 403;
access_log /var/log/nginx/blocked_module_scripts.log;
}
location /sveden/ {
alias /var/www/public/sveden/;
index index.html;
View File
BIN
View File
Binary file not shown.
+223
View File
@@ -0,0 +1,223 @@
<?php
namespace App\Console\Commands;
use App\Containers\AdditionalEducation\Models\AdditionalEducation;
use App\Containers\AppStructure\Models\Page;
use App\Containers\Article\Enums\PostStatus;
use App\Containers\Article\Models\Post;
use App\Containers\Education\Models\EducationalProgram;
use App\Containers\Event\Models\Event;
use App\Containers\InstituteStructure\Models\Department;
use App\Containers\InstituteStructure\Models\Division;
use App\Containers\InstituteStructure\Models\Faculty;
use App\Containers\User\Models\User;
use App\Ship\Enums\Education\EducationalProgramStatus;
use Spatie\Sitemap\Sitemap;
use Spatie\Sitemap\Tags\Url;
use Illuminate\Console\Command;
class GenerateSitemap extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'sitemap:generate';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Генерирует карту сайта';
/**
* Execute the console command.
*/
public function handle()
{
$sitemap = Sitemap::create();
// Генерация карты сайта для всех моделей
$this->generatePages($sitemap);
$this->generatePosts($sitemap);
$this->generateDivisions($sitemap);
$this->generateEducationPrograms($sitemap); // Добавляем генерацию для EducationProgram
$this->generateEvents($sitemap); // Добавляем генерацию для Event
$this->generateAdditionalEducations($sitemap); // Добавляем генерацию для AdditionalEducation
$this->generateFaculties($sitemap); // Добавляем генерацию для Faculty
$this->generateDepartments($sitemap); // Добавляем генерацию для Department
$this->generateUsers($sitemap); // Добавляем генерацию для User
// Сохраняем карту сайта в файл
$sitemap->writeToFile(public_path('sitemap.xml'));
}
protected function generatePages(Sitemap $sitemap)
{
$pages = Page::query()
->where('is_visible', true)
->where('code', 200)
->where('path', '!=', null)
->where('is_url', false)
->where('title', '!=', null)
->where('searchable', true)
->get();
$this->addUrlsToSitemap($sitemap, $pages, function($page) {
return [
'route' => 'page.view',
'params' => ['path' => $page->path],
'lastModificationDate' => $page->updated_at,
'priority' => 0.5,
];
});
}
protected function generatePosts(Sitemap $sitemap)
{
$posts = Post::query()
->where('status', PostStatus::PUBLISHED)
->get();
$this->addUrlsToSitemap($sitemap, $posts, function($post) {
return [
'route' => 'client.post.show',
'params' => ['slug' => $post->slug],
'lastModificationDate' => $post->updated_at,
'priority' => 0.5,
];
});
}
protected function generateDivisions(Sitemap $sitemap)
{
$divisions = Division::query()
->where('is_active', true)
->get();
$this->addUrlsToSitemap($sitemap, $divisions, function($division) {
return [
'route' => 'client.division.show',
'params' => ['slug' => $division->slug],
'lastModificationDate' => $division->updated_at,
'priority' => 0.5,
];
});
}
protected function generateEducationPrograms(Sitemap $sitemap)
{
$programs = EducationalProgram::query()
->where('status', EducationalProgramStatus::PUBLISHED) // Пример условия для активных программ
->get();
$this->addUrlsToSitemap($sitemap, $programs, function($program) {
return [
'route' => 'client.program.show',
'params' => ['slug' => $program->slug],
'lastModificationDate' => $program->updated_at,
'priority' => 0.5,
];
});
}
protected function generateEvents(Sitemap $sitemap)
{
$now = now()->toDateString(); // Текущая дата
$events = Event::query()
->where('event_date_end', '>=', $now) // Только актуальные события
->get();
$this->addUrlsToSitemap($sitemap, $events, function($event) {
return [
'route' => 'client.event.show',
'params' => ['slug' => $event->slug],
'lastModificationDate' => $event->updated_at,
'priority' => 0.5,
];
});
}
protected function generateAdditionalEducations(Sitemap $sitemap)
{
$educations = AdditionalEducation::query()
->where('is_active', true) // Пример условия для активных программ
->get();
$this->addUrlsToSitemap($sitemap, $educations, function($education) {
return [
'route' => 'client.additionalEducation.show',
'params' => ['slug' => $education->slug],
'lastModificationDate' => $education->updated_at,
'priority' => 0.5,
];
});
}
protected function generateFaculties(Sitemap $sitemap)
{
$faculties = Faculty::query()
->where('is_active', true) // Пример условия для активных факультетов
->get();
$this->addUrlsToSitemap($sitemap, $faculties, function($faculty) {
return [
'route' => 'client.faculty.show',
'params' => ['slug' => $faculty->slug],
'lastModificationDate' => $faculty->updated_at,
'priority' => 0.5,
];
});
}
protected function generateDepartments(Sitemap $sitemap)
{
$departments = Department::query()
->where('is_active', true) // Пример условия для активных кафедр
->get();
$this->addUrlsToSitemap($sitemap, $departments, function($department) {
return [
'route' => 'client.department.show',
'params' => ['facultySlug' => $department->faculty->slug, 'departmentSlug' => $department->slug],
'lastModificationDate' => $department->updated_at,
'priority' => 0.5,
];
});
}
protected function generateUsers(Sitemap $sitemap)
{
$users = User::query()
->whereHas('userDetail', function ($q) {
$q->where('is_only_worker', false);
})
->get();
$this->addUrlsToSitemap($sitemap, $users, function($user) {
return [
'route' => 'client.person.show',
'params' => ['slug' => $user->slug],
'lastModificationDate' => $user->updated_at,
'priority' => 0.5,
];
});
}
protected function addUrlsToSitemap(Sitemap $sitemap, $items, callable $callback)
{
foreach ($items as $item) {
$urlData = $callback($item);
$url = route($urlData['route'], $urlData['params']);
$sitemap->add(Url::create($url)
->setLastModificationDate($urlData['lastModificationDate'])
->setPriority($urlData['priority']));
}
}
}
View File
View File
View File
Executable → Regular
View File
@@ -1,38 +0,0 @@
<?php
namespace App\Containers\AdditionalEducation\Actions;
use App\Containers\AdditionalEducation\Data\Resources\AdditionalEducationResource;
use App\Containers\AdditionalEducation\Tasks\FindAdditionalEducationBySlugTask;
use App\Ship\Contracts\SeoServiceInterface;
use Illuminate\Http\Request;
class GetAdditionalEducationBySlugAction
{
public function __construct(
readonly SeoServiceInterface $seoPageProvider,
readonly FindAdditionalEducationBySlugTask $findAdditionalEducationBySlugTask,
) {}
public function run(string $slug, Request $request): array
{
$additionalEducationModel = $this->findAdditionalEducationBySlugTask->run($slug);
$seo = $this->seoPageProvider->getSeoForModel($additionalEducationModel);
$settingsPage = $request->attributes->get('settings_page') ?? [];
if (array_key_exists('form', $settingsPage)) {
$form = $settingsPage['form'];
} else {
$form = null;
}
$additionalEducation = new AdditionalEducationResource($additionalEducationModel);
return compact(
'additionalEducation',
'seo',
'form',
);
}
}
@@ -1,43 +0,0 @@
<?php
namespace App\Containers\AdditionalEducation\Actions;
use App\Containers\AdditionalEducation\Tasks\GetAllAdditionalEducationCategoriesPreviewTask;
use App\Containers\AdditionalEducation\Tasks\GetDirectionAdditionalEducationsTask;
use App\Containers\AdditionalEducation\Tasks\GetFilteredAdditionalEducationCategoriesTask;
use App\Containers\AdditionalEducation\Tasks\BuildFormsEducationArrayTask;
use App\Containers\AdditionalEducation\Tasks\BuildAdditionalEducationFiltersTask;
use App\Ship\Actions\Action;
use App\Ship\Contracts\SeoServiceInterface;
use Illuminate\Http\Request;
class GetAllAdditionalEducationsAction
{
public function __construct(
readonly SeoServiceInterface $seoPageProvider,
readonly GetDirectionAdditionalEducationsTask $getDirectionAdditionalEducationsTask,
readonly GetFilteredAdditionalEducationCategoriesTask $getFilteredAdditionalEducationCategoriesTask,
readonly GetAllAdditionalEducationCategoriesPreviewTask $getAllAdditionalEducationCategoriesPreviewTask,
readonly BuildFormsEducationArrayTask $buildFormsEducationArrayTask,
readonly BuildAdditionalEducationFiltersTask $buildAdditionalEducationFiltersTask,
) {}
public function run(Request $request): array
{
$directionAdditionalEducations = $this->getDirectionAdditionalEducationsTask->run($request);
$additionalEducations = $this->getFilteredAdditionalEducationCategoriesTask->run($request);
$categories = $this->getAllAdditionalEducationCategoriesPreviewTask->run();
$forms_education = $this->buildFormsEducationArrayTask->run();
$seo = $this->seoPageProvider->getSeoForCurrentPage();
$filters = $this->buildAdditionalEducationFiltersTask->run($request);
return compact(
'directionAdditionalEducations',
'additionalEducations',
'filters',
'forms_education',
'categories',
'seo'
);
}
}
View File
View File
View File
+2 -4
View File
@@ -5,15 +5,13 @@ namespace App\Containers\AdditionalEducation\Models;
use App\Ship\Enums\Education\FormEducation;
use App\Ship\Models\Model;
use App\Ship\Models\Seo;
use App\Ship\Traits\HasContainerFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphOne;
class AdditionalEducation extends Model
{
use HasContainerFactory;
protected static string $factory = \Database\Factories\AdditionalEducationFactory::class;
use HasFactory;
protected $guarded = false;
+2 -4
View File
@@ -3,16 +3,14 @@
namespace App\Containers\AdditionalEducation\Models;
use App\Ship\Models\Model;
use App\Ship\Traits\HasContainerFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AdditionalEducationCategory extends Model
{
use HasContainerFactory;
protected static string $factory = \Database\Factories\AdditionalEducationCategoryFactory::class;
use HasFactory;
protected $guarded = false;
+2 -4
View File
@@ -3,14 +3,12 @@
namespace App\Containers\AdditionalEducation\Models;
use App\Ship\Models\Model;
use App\Ship\Traits\HasContainerFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
class DirectionAdditionalEducation extends Model
{
use HasContainerFactory;
protected static string $factory = \Database\Factories\DirectionAdditionalEducationFactory::class;
use HasFactory;
protected $guarded = false;
@@ -1,46 +0,0 @@
<?php
namespace App\Containers\AdditionalEducation\Tasks;
use App\Containers\AdditionalEducation\Data\Resources\AdditionalEducationCategoryResource;
use App\Containers\AdditionalEducation\Models\AdditionalEducationCategory;
use App\Ship\Enums\Education\FormEducation;
use Illuminate\Http\Request;
class BuildAdditionalEducationFiltersTask
{
public function run(Request $request): array
{
$categoriesContent = [];
if ($request->category) {
foreach ((array)$request->category as $item) {
$categoriesContent[$item] = (new GetFilteredAdditionalEducationCategoriesTask())->run(new Request(['category' => $item]))->first();
}
}
$forms_education = array_reduce(
FormEducation::cases(),
fn ($acc, $case) => $acc + [$case->name => $case->getLabel()],
[]
);
return [
'direction_filter' => [
'type' => 'direction',
'value' => $request->input('direction'),
'param' => 'direction'
],
'form_education_filter' => [
'type' => 'form',
'value' => $request->input('form'),
'param' => 'form'
],
'category_filter' => [
'type' => 'category',
'value' => $request->input('category'),
'param' => 'category',
'content' => $categoriesContent,
],
];
}
}
@@ -1,17 +0,0 @@
<?php
namespace App\Containers\AdditionalEducation\Tasks;
use App\Ship\Enums\Education\FormEducation;
class BuildFormsEducationArrayTask
{
public function run(): array
{
return array_reduce(
FormEducation::cases(),
fn ($acc, $case) => $acc + [$case->name => $case->getLabel()],
[]
);
}
}
@@ -1,20 +0,0 @@
<?php
namespace App\Containers\AdditionalEducation\Tasks;
use App\Containers\AdditionalEducation\Models\AdditionalEducation;
use App\Ship\Enums\CacheKeys;
use App\Ship\Tasks\Task;
use Illuminate\Support\Facades\Cache;
class FindAdditionalEducationBySlugTask
{
public function run(string $slug)
{
return Cache::remember(
CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAM_PREFIX->value . $slug,
now()->addDay(),
fn() => AdditionalEducation::with('category.direction')->where('slug', $slug)->firstOrFail()
);
}
}
@@ -1,28 +0,0 @@
<?php
namespace App\Containers\AdditionalEducation\Tasks;
use App\Containers\AdditionalEducation\Data\Resources\AdditionalEducationCategoryPreviewResource;
use App\Containers\AdditionalEducation\Models\AdditionalEducationCategory;
use App\Ship\Enums\CacheKeys;
use App\Ship\Tasks\Task;
use Illuminate\Support\Facades\Cache;
class GetAllAdditionalEducationCategoriesPreviewTask
{
public function run()
{
return Cache::remember(
CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAMS_PREFIX->value . 'categories',
now()->addWeek(),
function () {
return AdditionalEducationCategoryPreviewResource::collection(
AdditionalEducationCategory::query()
->where('is_active', true)
->has('additionalEducations')
->get()
);
}
);
}
}
@@ -1,31 +0,0 @@
<?php
namespace App\Containers\AdditionalEducation\Tasks;
use App\Containers\AdditionalEducation\Data\Resources\DirectionAdditionalEducationResource;
use App\Containers\AdditionalEducation\Models\DirectionAdditionalEducation;
use App\Ship\Enums\CacheKeys;
use App\Ship\Tasks\Task;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class GetDirectionAdditionalEducationsTask
{
public function run(Request $request)
{
$cacheKey = md5(serialize($request->all()));
return Cache::remember(
CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAMS_PREFIX->value . 'directions_' . $cacheKey,
now()->addDay(),
function () {
return DirectionAdditionalEducationResource::collection(
DirectionAdditionalEducation::query()
->where('is_active', true)
->whereHas('additionalEducationCategories', fn ($q) => $q->whereHas('additionalEducations'))
->get()
);
}
);
}
}
@@ -1,47 +0,0 @@
<?php
namespace App\Containers\AdditionalEducation\Tasks;
use App\Containers\AdditionalEducation\Data\Resources\AdditionalEducationCategoryResource;
use App\Containers\AdditionalEducation\Models\AdditionalEducationCategory;
use App\Ship\Enums\CacheKeys;
use App\Ship\Enums\Education\FormEducation;
use App\Ship\Tasks\Task;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class GetFilteredAdditionalEducationCategoriesTask
{
public function run(Request $request)
{
$cacheKey = md5(serialize($request->all()));
return Cache::remember(
CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAMS_PREFIX->value . $cacheKey,
now()->addDay(),
function () use ($request) {
$query = AdditionalEducationCategory::query()
->has('additionalEducations')
->withActivePrograms()
->where('is_active', true);
if ($request->has('form')) {
$formValue = FormEducation::fromName($request->form)->value;
$query->whereHas('additionalEducations', fn ($q) => $q->where('form_education', $formValue))
->with(['additionalEducations' => fn ($q) => $q->where('form_education', $formValue)]);
}
if ($request->has('category')) {
$slugs = is_array($request->category) ? $request->category : [$request->category];
$query->whereIn('slug', $slugs);
}
if ($request->has('direction')) {
$query->whereHas('direction', fn($q) => $q->where('slug', $request->direction));
}
return AdditionalEducationCategoryResource::collection($query->get());
}
);
}
}
@@ -2,31 +2,160 @@
namespace App\Containers\AdditionalEducation\UI\WEB\Controllers;
use App\Containers\AdditionalEducation\Actions\GetAllAdditionalEducationsAction;
use App\Containers\AdditionalEducation\Actions\GetAdditionalEducationBySlugAction;
use App\Containers\AdditionalEducation\Data\Resources\AdditionalEducationCategoryPreviewResource;
use App\Containers\AdditionalEducation\Data\Resources\AdditionalEducationCategoryResource;
use App\Containers\AdditionalEducation\Data\Resources\AdditionalEducationResource;
use App\Containers\AdditionalEducation\Data\Resources\DirectionAdditionalEducationResource;
use App\Containers\AdditionalEducation\Models\AdditionalEducation;
use App\Containers\AdditionalEducation\Models\AdditionalEducationCategory;
use App\Containers\AdditionalEducation\Models\DirectionAdditionalEducation;
use App\Ship\Contracts\SeoServiceInterface;
use App\Ship\Controllers\Controller;
use App\Ship\Enums\CacheKeys;
use App\Ship\Enums\Education\FormEducation;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
class ClientAdditionalEducationController extends Controller
{
public function __construct(
readonly GetAllAdditionalEducationsAction $getAllAdditionalEducationsAction,
readonly GetAdditionalEducationBySlugAction $getAdditionalEducationBySlugAction
){}
public function __construct(readonly SeoServiceInterface $seoPageProvider){}
public function index(Request $request): \Inertia\Response
{
$data = $this->getAllAdditionalEducationsAction->run($request);
$cacheKey = md5(serialize($request->all()));
return Inertia::render('Client/Additional-educations/Index', $data);
// Основные данные (кешируются)
$directionAdditionalEducations = Cache::remember(
CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAMS_PREFIX->value . 'directions_' . $cacheKey,
now()->addDay(),
function () {
return DirectionAdditionalEducationResource::collection(
DirectionAdditionalEducation::query()
->where('is_active', true)
->whereHas('additionalEducationCategories', fn ($q) => $q->whereHas('additionalEducations'))
->get()
);
}
);
$additionalEducations = Cache::remember(
CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAMS_PREFIX->value . $cacheKey,
now()->addDay(),
function () use ($request) {
$query = AdditionalEducationCategory::query()
->has('additionalEducations')
->withActivePrograms()
->where('is_active', true);
if ($request->has('form')) {
$formValue = FormEducation::fromName($request->form)->value;
$query->whereHas('additionalEducations', fn ($q) => $q->where('form_education', $formValue))
->with(['additionalEducations' => fn ($q) => $q->where('form_education', $formValue)]);
}
if ($request->has('category')) {
$slugs = is_array($request->category) ? $request->category : [$request->category];
$query->whereIn('slug', $slugs);
}
if ($request->has('direction')) {
$query->whereHas('direction', fn($q) => $q->where('slug', $request->direction));
}
return AdditionalEducationCategoryResource::collection($query->get());
}
);
$categories = Cache::remember(
CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAMS_PREFIX->value . 'categories',
now()->addWeek(),
function () {
return AdditionalEducationCategoryPreviewResource::collection(
AdditionalEducationCategory::query()
->where('is_active', true)
->has('additionalEducations')
->get()
);
}
);
// Динамические данные (не кешируются)
$categoriesContent = [];
if ($request->category) {
foreach ((array)$request->category as $item) {
$categoriesContent[$item] = new AdditionalEducationCategoryResource(
AdditionalEducationCategory::where('slug', $item)->first()
);
}
}
$forms_education = array_reduce(
FormEducation::cases(),
fn ($acc, $case) => $acc + [$case->name => $case->getLabel()],
[]
);
$filters = [
'direction_filter' => [
'type' => 'direction',
'value' => $request->input('direction'),
'param' => 'direction'
],
'form_education_filter' => [
'type' => 'form',
'value' => $request->input('form'),
'param' => 'form'
],
'category_filter' => [
'type' => 'category',
'value' => $request->input('category'),
'param' => 'category',
'content' => $categoriesContent,
],
];
$seo = $this->seoPageProvider->getSeoForCurrentPage();
return Inertia::render('Client/Additional-educations/Index', compact(
'directionAdditionalEducations',
'additionalEducations',
'filters',
'forms_education',
'categories',
'seo'
));
}
public function show(string $slug, Request $request): \Inertia\Response
public function show(string $slug): \Inertia\Response
{
$data = $this->getAdditionalEducationBySlugAction->run($slug, $request);
$additionalEducationModel = Cache::remember(
CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAM_PREFIX->value . $slug,
now()->addDay(),
fn() => AdditionalEducation::with('category.direction')->where('slug', $slug)->firstOrFail()
);
return Inertia::render('Client/Additional-educations/Show', $data);
}
}
$seo = Cache::remember(
CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAM_PREFIX->value . 'seo_' . $slug,
now()->addDay(),
fn() => $this->seoPageProvider->getSeoForModel($additionalEducationModel)
);
$settingsPage = request()->attributes->get('settings_page') ?? [];
if (array_key_exists('custom_form', $settingsPage)) {
$form = $settingsPage['custom_form'];
} else {
$form = null;
}
$additionalEducation = new AdditionalEducationResource($additionalEducationModel);
return Inertia::render('Client/Additional-educations/Show', compact(
'additionalEducation',
'seo',
'form',
));
}}
View File
@@ -1,48 +0,0 @@
<?php
namespace App\Containers\Analytics\Commands;
use App\Containers\Analytics\Models\AnalyticsDailyStat;
use App\Containers\Analytics\Models\AnalyticsHit;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Facades\DB;
class AggregateAnalyticsCommand extends \Illuminate\Console\Command
{
protected $signature = 'analytics:aggregate {--days=1 : Days back to aggregate}';
protected $description = 'Aggregate analytics hits into daily stats';
public function handle(): int
{
$days = (int) $this->option('days');
$date = now()->subDays($days)->toDateString();
$stats = AnalyticsHit::selectRaw('
DATE(created_at) as hit_date,
url,
COUNT(*) as views_count,
COUNT(DISTINCT session_id) as unique_visitors
')
->whereDate('created_at', $date)
->groupBy(DB::raw('DATE(created_at)'), 'url')
->get();
$inserted = 0;
foreach ($stats as $row) {
AnalyticsDailyStat::updateOrCreate(
['date' => $row->hit_date, 'url' => $row->url],
[
'views_count' => $row->views_count,
'unique_visitors' => $row->unique_visitors,
]
);
$inserted++;
}
$this->info("Aggregated {$inserted} daily stat rows for {$date}");
return static::SUCCESS;
}
}
@@ -1,68 +0,0 @@
<?php
namespace App\Containers\Analytics\Jobs;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\Analytics\Models\AnalyticsSession;
use App\Containers\Analytics\Services\DeviceDetectorService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RecordVisitJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 15;
public function __construct(
public array $data,
) {}
public function handle(
DeviceDetectorService $detector,
): void {
$userAgent = $this->data['user_agent'] ?? '';
if ($detector->isBot($userAgent)) {
return;
}
$device = $detector->parse($userAgent);
$utmParams = array_filter([
'utm_source' => $this->data['utm_source'] ?? null,
'utm_medium' => $this->data['utm_medium'] ?? null,
'utm_campaign' => $this->data['utm_campaign'] ?? null,
], fn($v) => $v !== null);
$session = (new AnalyticsSession())->firstOrCreateSession(
visitorId: $this->data['visitor_id'],
ip: $this->data['ip'] ?? '0.0.0.0',
userId: $this->data['user_id'] ?? null,
entryPage: $this->data['url'],
utmParams: $utmParams,
);
if (empty($session->browser)) {
$session->update([
'browser' => $device['browser'],
'os' => $device['os'],
'device_type' => $device['type'],
]);
}
AnalyticsHit::create([
'session_id' => $session->id,
'user_id' => $this->data['user_id'] ?? null,
'url' => $this->data['url'],
'referrer' => $this->data['referrer'] ?? null,
'title' => $this->data['title'] ?? null,
'created_at' => now(),
]);
}
}
@@ -1,23 +0,0 @@
<?php
namespace App\Containers\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
class AnalyticsDailyStat extends Model
{
protected $table = 'analytics_daily_stats';
protected $fillable = [
'date',
'url',
'views_count',
'unique_visitors',
];
protected $casts = [
'date' => 'date',
'views_count' => 'integer',
'unique_visitors' => 'integer',
];
}
@@ -1,36 +0,0 @@
<?php
namespace App\Containers\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AnalyticsHit extends Model
{
protected $table = 'analytics_hits';
public $timestamps = false;
protected $fillable = [
'session_id',
'user_id',
'url',
'referrer',
'title',
'created_at',
];
protected $casts = [
'created_at' => 'datetime',
];
public function session(): BelongsTo
{
return $this->belongsTo(AnalyticsSession::class, 'session_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(\App\Containers\User\Models\User::class, 'user_id');
}
}
@@ -1,72 +0,0 @@
<?php
namespace App\Containers\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AnalyticsSession extends Model
{
protected $table = 'analytics_sessions';
protected $fillable = [
'visitor_id',
'user_id',
'entry_page',
'ip',
'utm_source',
'utm_medium',
'utm_campaign',
'browser',
'os',
'device_type',
'started_at',
'last_activity_at',
];
protected $casts = [
'started_at' => 'datetime',
'last_activity_at' => 'datetime',
];
public function hits(): HasMany
{
return $this->hasMany(AnalyticsHit::class, 'session_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(\App\Containers\User\Models\User::class, 'user_id');
}
public function firstOrCreateSession(
string $visitorId,
string $ip,
int $userId = null,
string $entryPage = null,
array $utmParams = [],
): self {
$lastSession = static::where('visitor_id', $visitorId)
->where('last_activity_at', '>', now()->subMinutes(30))
->latest('last_activity_at')
->first();
if ($lastSession) {
$lastSession->update(['last_activity_at' => now()]);
return $lastSession;
}
return static::create([
'visitor_id' => $visitorId,
'user_id' => $userId,
'entry_page' => $entryPage,
'ip' => $ip,
'utm_source' => $utmParams['utm_source'] ?? null,
'utm_medium' => $utmParams['utm_medium'] ?? null,
'utm_campaign' => $utmParams['utm_campaign'] ?? null,
'started_at' => now(),
'last_activity_at' => now(),
]);
}
}
@@ -1,22 +0,0 @@
<?php
namespace App\Containers\Analytics\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Console\Scheduling\Schedule;
class AnalyticsServiceProvider extends ServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
\App\Containers\Analytics\Commands\AggregateAnalyticsCommand::class,
]);
$this->app->afterResolving(Schedule::class, function (Schedule $schedule) {
$schedule->command('analytics:aggregate', ['--days' => 1])->hourly();
});
}
}
}
@@ -1,35 +0,0 @@
<?php
namespace App\Containers\Analytics\Services;
use WhichBrowser\Parser;
class DeviceDetectorService
{
public function parse(string $userAgent): array
{
$parser = new Parser($userAgent);
$browser = $parser->browser->name ?? null;
$os = $parser->os->name ?? null;
$deviceType = 'desktop';
if ($parser->isType('tablet')) {
$deviceType = 'tablet';
} elseif ($parser->isType('mobile')) {
$deviceType = 'mobile';
}
return [
'browser' => $browser,
'os' => $os,
'type' => $deviceType,
];
}
public function isBot(string $userAgent): bool
{
$parser = new Parser($userAgent);
return $parser->isType('bot');
}
}
@@ -1,56 +0,0 @@
<?php
namespace App\Containers\Analytics\Services;
use App\Containers\Analytics\Models\AnalyticsGeoCache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeoIpService
{
private const CACHE_TTL_DAYS = 7;
public function locate(string $ip): ?array
{
$ipHash = sha1($ip);
$cached = AnalyticsGeoCache::where('ip_hash', $ipHash)
->where('cached_at', '>', now()->subDays(self::CACHE_TTL_DAYS))
->first();
if ($cached) {
return [
'country' => $cached->country,
'city' => $cached->city,
];
}
try {
$response = Http::timeout(3)->get("http://ip-api.com/json/{$ip}", [
'fields' => 'country,city',
]);
if ($response->successful()) {
$data = $response->json();
AnalyticsGeoCache::updateOrCreate(
['ip_hash' => $ipHash],
[
'country' => $data['country'] ?? null,
'city' => $data['city'] ?? null,
'cached_at' => now(),
]
);
return [
'country' => $data['country'] ?? null,
'city' => $data['city'] ?? null,
];
}
} catch (\Throwable $e) {
Log::warning('GeoIP lookup failed', ['ip' => $ip, 'error' => $e->getMessage()]);
}
return null;
}
}
@@ -1,97 +0,0 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\Analytics\Models\AnalyticsSession;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class GetAnalyticsOverviewTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$to = now()->endOfDay();
if ($days === 0) {
$prevFrom = Carbon::yesterday()->startOfDay();
$prevTo = Carbon::yesterday()->endOfDay();
} else {
$prevFrom = Carbon::now()->subDays($days * 2)->startOfDay();
$prevTo = Carbon::now()->subDays($days)->startOfDay();
}
$current = $this->getPeriodStats($from, $to);
$previous = $this->getPeriodStats($prevFrom, $prevTo);
return [
'unique_visitors' => $this->compare($current['visitors'], $previous['visitors']),
'page_views' => $this->compare($current['views'], $previous['views']),
'avg_time' => $this->compare($current['avg_time'], $previous['avg_time'], true),
'bounce_rate' => $this->compare($current['bounce_rate'], $previous['bounce_rate'], true, true),
];
}
private function getPeriodStats(Carbon $from, Carbon $to): array
{
$hits = AnalyticsHit::whereBetween('created_at', [$from, $to])->count();
$sessions = AnalyticsSession::whereBetween('started_at', [$from, $to])
->get(['id', 'started_at', 'last_activity_at']);
$uniqueVisitors = AnalyticsSession::whereBetween('started_at', [$from, $to])
->distinct('visitor_id')
->count('visitor_id');
$totalSessions = $sessions->count();
$avgTime = 0;
if ($totalSessions > 0) {
$totalSeconds = $sessions->sum(function ($s) {
return $s->started_at->diffInSeconds($s->last_activity_at);
});
$avgTime = (int) round($totalSeconds / $totalSessions);
}
$bounceRate = 0;
if ($totalSessions > 0) {
$sessionIds = $sessions->pluck('id');
$singleHitSessions = AnalyticsHit::whereIn('session_id', $sessionIds)
->selectRaw('session_id, COUNT(*) as hits')
->groupBy('session_id')
->havingRaw('COUNT(*) = 1')
->count();
$bounceRate = (int) round(($singleHitSessions / $totalSessions) * 100);
}
return [
'views' => $hits,
'visitors' => $uniqueVisitors,
'avg_time' => $avgTime,
'bounce_rate' => $bounceRate,
];
}
private function compare(int $current, int $previous, bool $isRatio = false, bool $invertDirection = false): array
{
$change = 0;
if ($previous > 0) {
$change = $isRatio
? $current - $previous
: (int) round((($current - $previous) / $previous) * 100);
} elseif ($current > 0) {
$change = $isRatio ? 0 : 100;
}
$direction = $invertDirection
? ($change < 0 ? 'up' : ($change > 0 ? 'down' : 'neutral'))
: ($change > 0 ? 'up' : ($change < 0 ? 'down' : 'neutral'));
return [
'value' => $current,
'change' => $change,
'direction' => $direction,
];
}
}
@@ -1,50 +0,0 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsDailyStat;
use App\Containers\Analytics\Models\AnalyticsHit;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class GetDailyStatsTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$stats = AnalyticsDailyStat::where('date', '>=', $from)
->selectRaw('
date,
SUM(views_count) as total_views,
SUM(unique_visitors) as total_unique
')
->groupBy('date')
->orderBy('date')
->get();
if ($stats->isEmpty()) {
return AnalyticsHit::where('created_at', '>=', $from)
->selectRaw('
DATE(created_at) as hit_date,
COUNT(*) as total_views,
COUNT(DISTINCT session_id) as total_unique
')
->groupBy(DB::raw('DATE(created_at)'))
->orderBy('hit_date')
->get()
->map(fn($row) => [
'date' => (string) $row->hit_date,
'views' => (int) $row->total_views,
'visitors' => (int) $row->total_unique,
])
->toArray();
}
return $stats->map(fn($row) => [
'date' => $row->date instanceof Carbon ? $row->date->format('Y-m-d') : (string) $row->date,
'views' => (int) $row->total_views,
'visitors' => (int) $row->total_unique,
])->toArray();
}
}
@@ -1,56 +0,0 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsSession;
use Illuminate\Support\Carbon;
class GetDevicesTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$devices = AnalyticsSession::where('started_at', '>=', $from)
->selectRaw('
device_type,
COUNT(*) as count
')
->groupBy('device_type')
->get()
->pluck('count', 'device_type')
->toArray();
$browsers = AnalyticsSession::where('started_at', '>=', $from)
->whereNotNull('browser')
->selectRaw('
browser,
COUNT(*) as count
')
->groupBy('browser')
->orderByDesc('count')
->limit(10)
->get()
->pluck('count', 'browser')
->toArray();
$oses = AnalyticsSession::where('started_at', '>=', $from)
->whereNotNull('os')
->selectRaw('
os,
COUNT(*) as count
')
->groupBy('os')
->orderByDesc('count')
->limit(10)
->get()
->pluck('count', 'os')
->toArray();
return [
'devices' => $devices,
'browsers' => $browsers,
'oses' => $oses,
];
}
}
@@ -1,134 +0,0 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\AppStructure\Models\MainSection;
use Illuminate\Support\Carbon;
class GetNavigationSectionsTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$hits = AnalyticsHit::where('created_at', '>=', $from)
->selectRaw('url, session_id')
->get();
$mainSections = MainSection::with('subSections.pages')
->orderBy('sort')
->get();
$sections = [];
$cmsSlugs = [];
// 1. CMS sections (MainSection → SubSection → Page)
foreach ($mainSections as $ms) {
$msPrefix = '/' . $ms->slug;
$cmsSlugs[] = $msPrefix;
$msHits = $hits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $msPrefix));
$subSections = [];
foreach ($ms->subSections as $ss) {
$ssPrefix = $msPrefix . '/' . $ss->slug;
$ssHits = $msHits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $ssPrefix));
$pages = [];
foreach ($ss->pages as $page) {
$pagePath = '/' . $page->path;
$pHits = $ssHits->filter(fn($h) => rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/') === $pagePath);
$pages[] = [
'id' => $page->id,
'title' => $page->title ?: $page->slug,
'path' => $page->path,
'url' => $pagePath,
'views' => $pHits->count(),
'visitors' => $pHits->pluck('session_id')->unique()->count(),
];
}
$subSections[] = [
'id' => $ss->id,
'title' => $ss->title,
'slug' => $ss->slug,
'views' => $ssHits->count(),
'visitors' => $ssHits->pluck('session_id')->unique()->count(),
'pages' => $pages,
];
}
$sections[] = [
'id' => $ms->id,
'title' => $ms->title,
'slug' => $ms->slug,
'group' => 'structure',
'views' => $msHits->count(),
'visitors' => $msHits->pluck('session_id')->unique()->count(),
'sub_sections' => $subSections,
];
}
// 2. Config-defined sections (non-CMS)
$configSections = config('analytics.sections', []);
foreach ($configSections as $prefix => $config) {
if ($prefix === '/') continue;
if (in_array($prefix, $cmsSlugs)) continue;
$sectionHits = $hits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $prefix));
$subSections = [];
if (isset($config['children'])) {
$model = $config['children']['model'];
$nameKey = $config['children']['name_key'];
$slugKey = $config['children']['slug_key'];
$children = $model::all();
foreach ($children as $child) {
$childSlug = $child->{$slugKey};
$childPrefix = $prefix . '/' . $childSlug;
$childHits = $sectionHits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $childPrefix));
$subSections[] = [
'id' => $child->id,
'title' => $child->{$nameKey},
'slug' => $childSlug,
'views' => $childHits->count(),
'visitors' => $childHits->pluck('session_id')->unique()->count(),
'pages' => [],
];
}
}
$sections[] = [
'id' => 'config:' . $prefix,
'title' => $config['label'],
'slug' => ltrim($prefix, '/'),
'group' => $config['group'] ?? 'other',
'views' => $sectionHits->count(),
'visitors' => $sectionHits->pluck('session_id')->unique()->count(),
'sub_sections' => $subSections,
];
}
// Homepage — as first item in structure group
$homeHits = $hits->filter(fn($h) => (rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/') === '/'));
array_unshift($sections, [
'id' => 'home',
'title' => 'Главная страница',
'slug' => '',
'group' => 'structure',
'type' => 'home',
'views' => $homeHits->count(),
'visitors' => $homeHits->pluck('session_id')->unique()->count(),
'sub_sections' => [],
]);
return [
'sections' => $sections,
'groupLabels' => config('analytics.group_labels', []),
];
}
}
@@ -1,62 +0,0 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class GetReferrersTask
{
public function run(int $days = 30, int $limit = 10): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$referrers = AnalyticsHit::where('created_at', '>=', $from)
->whereNotNull('referrer')
->where('referrer', '!=', '')
->selectRaw('
CASE
WHEN referrer LIKE "%google.%" THEN "Google"
WHEN referrer LIKE "%yandex.%" THEN "Yandex"
WHEN referrer LIKE "%vk.com%" THEN "VK"
WHEN referrer LIKE "%t.me%" THEN "Telegram"
WHEN referrer LIKE "%ok.ru%" THEN "Одноклассники"
WHEN referrer LIKE "%dzen.ru%" THEN "Дзен"
WHEN referrer LIKE "%facebook.%" THEN "Facebook"
WHEN referrer LIKE "%instagram.%" THEN "Instagram"
WHEN referrer LIKE "%twitter.%" THEN "Twitter"
ELSE SUBSTRING_INDEX(SUBSTRING_INDEX(referrer, "/", 3), "/", -1)
END as source,
COUNT(*) as hits,
COUNT(DISTINCT session_id) as visitors
')
->groupBy('source')
->orderByDesc('hits')
->limit($limit)
->get()
->toArray();
$directCount = AnalyticsHit::where('created_at', '>=', $from)
->where(function ($q) {
$q->whereNull('referrer')->orWhere('referrer', '');
})
->count();
$results = array_map(fn($r) => [
'source' => $r['source'],
'hits' => (int) $r['hits'],
'visitors' => (int) $r['visitors'],
], $referrers);
if ($directCount > 0) {
array_unshift($results, [
'source' => 'Прямой заход',
'hits' => $directCount,
'visitors' => $directCount,
]);
}
return $results;
}
}
@@ -1,216 +0,0 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\Analytics\Models\AnalyticsSession;
use App\Containers\AppStructure\Models\MainSection;
use App\Containers\AppStructure\Models\Page;
use App\Containers\AppStructure\Models\SubSection;
use Illuminate\Support\Carbon;
class GetSectionDetailTask
{
public function run(string $prefix, int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$hits = AnalyticsHit::where('created_at', '>=', $from)
->where('url', 'like', $prefix . '%')
->selectRaw('url, session_id, created_at, referrer')
->get();
$totalViews = $hits->count();
$uniqueVisitors = $hits->pluck('session_id')->unique()->count();
$mainSection = null;
$subSection = null;
$page = null;
$children = [];
$breadcrumbs = [];
$type = 'unknown';
$exitPages = [];
$cleanPrefix = ltrim($prefix, '/');
$parts = array_filter(explode('/', $cleanPrefix));
// 1. Check if MainSection
$mainSection = MainSection::where('slug', $cleanPrefix)->first();
if ($mainSection) {
$type = 'main_section';
$breadcrumbs = [['title' => 'Аналитика', 'url' => route('dashboard.analytics.index')]];
$subSections = SubSection::where('main_section_id', $mainSection->id)
->with('pages')
->orderBy('sort')
->get();
foreach ($subSections as $ss) {
$ssPrefix = '/' . $mainSection->slug . '/' . $ss->slug;
$ssHits = $hits->filter(fn($h) => str_starts_with(parse_url($h->url, PHP_URL_PATH) ?: '', $ssPrefix));
$children[] = [
'id' => $ss->id,
'title' => $ss->title,
'prefix' => $ssPrefix,
'views' => $ssHits->count(),
'visitors' => $ssHits->pluck('session_id')->unique()->count(),
'pages_count' => $ss->pages->count(),
];
}
}
// 2. Check if SubSection (2 parts)
elseif (count($parts) === 2) {
$mainSection = MainSection::where('slug', $parts[0])->first();
$subSection = SubSection::where('slug', $parts[1])
->where('main_section_id', $mainSection?->id)
->first();
if ($subSection && $mainSection) {
$type = 'sub_section';
$breadcrumbs = [
['title' => 'Аналитика', 'url' => route('dashboard.analytics.index')],
['title' => $mainSection->title, 'url' => route('dashboard.analytics.section', ['prefix' => '/' . $mainSection->slug])],
];
$pages = Page::where('sub_section_id', $subSection->id)
->orderBy('sort')
->get();
foreach ($pages as $p) {
$pUrl = '/' . $p->path;
$pHits = $hits->filter(fn($h) => rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/') === $pUrl);
$children[] = [
'id' => $p->id,
'title' => $p->title ?: $p->slug,
'url' => $pUrl,
'views' => $pHits->count(),
'visitors' => $pHits->pluck('session_id')->unique()->count(),
];
}
}
}
// 3. Check if Page (by path or 3+ parts)
if ($type === 'unknown') {
$page = Page::where('path', $cleanPrefix)->first();
if ($page && $page->section) {
$subSection = $page->section;
$mainSection = $subSection->mainSection;
}
if ($mainSection && $subSection) {
$type = 'page';
$breadcrumbs = [
['title' => 'Аналитика', 'url' => route('dashboard.analytics.index')],
['title' => $mainSection->title, 'url' => route('dashboard.analytics.section', ['prefix' => '/' . $mainSection->slug])],
['title' => $subSection->title, 'url' => route('dashboard.analytics.section', ['prefix' => '/' . $mainSection->slug . '/' . $subSection->slug])],
];
// Exit pages: URLs visited after this page in the same session
$exitPages = $this->getExitPages($hits, $prefix, $days);
}
}
$label = $cleanPrefix;
if ($mainSection) $label = $mainSection->title;
if ($subSection) $label = $subSection->title;
if ($page) $label = $page->title ?: $page->slug;
$topPages = $hits->groupBy('url')
->map(fn($group) => [
'url' => $group->first()->url,
'views' => $group->count(),
'unique_visitors' => $group->pluck('session_id')->unique()->count(),
])
->sortByDesc('views')
->values()
->take(15)
->toArray();
$dailyStats = $hits->groupBy(fn($h) => $h->created_at->format('Y-m-d'))
->map(fn($group, $date) => [
'date' => $date,
'views' => $group->count(),
'visitors' => $group->pluck('session_id')->unique()->count(),
])
->values()
->sortBy('date')
->toArray();
$referrers = $hits->filter(fn($h) => !empty($h->referrer))
->groupBy(function ($h) {
$url = $h->referrer;
if (str_contains($url, 'google.')) return 'Google';
if (str_contains($url, 'yandex.')) return 'Yandex';
if (str_contains($url, 'vk.com')) return 'VK';
if (str_contains($url, 't.me')) return 'Telegram';
if (str_contains($url, 'ok.ru')) return 'Одноклассники';
if (str_contains($url, 'dzen.ru')) return 'Дзен';
return parse_url($url, PHP_URL_HOST) ?? 'Другое';
})
->map(fn($group) => [
'source' => $group->first()->referrer,
'hits' => $group->count(),
'visitors' => $group->pluck('session_id')->unique()->count(),
])
->sortByDesc('hits')
->values()
->take(10)
->toArray();
$devices = AnalyticsSession::where('started_at', '>=', $from)
->whereIn('id', $hits->pluck('session_id')->unique())
->selectRaw('device_type, COUNT(*) as count')
->groupBy('device_type')
->pluck('count', 'device_type')
->toArray();
return [
'prefix' => $prefix,
'type' => $type,
'label' => $label,
'breadcrumbs' => $breadcrumbs,
'children' => $children,
'exit_pages' => $exitPages,
'total_views' => $totalViews,
'unique_visitors' => $uniqueVisitors,
'top_pages' => $topPages,
'daily_stats' => $dailyStats,
'referrers' => $referrers,
'devices' => $devices,
];
}
private function getExitPages($hits, string $prefix, int $days): array
{
$sessionIds = $hits->pluck('session_id')->unique();
$allHits = AnalyticsHit::where('created_at', '>=', now()->subDays($days)->startOfDay())
->whereIn('session_id', $sessionIds)
->orderBy('session_id')
->orderBy('created_at')
->selectRaw('session_id, url, created_at')
->get()
->groupBy('session_id');
$exitCounts = [];
foreach ($allHits as $sessionId => $sessionHits) {
$sorted = $sessionHits->values();
for ($i = 0; $i < $sorted->count() - 1; $i++) {
$currentUrl = parse_url($sorted[$i]->url, PHP_URL_PATH) ?: '/';
if (str_starts_with($currentUrl, $prefix)) {
$nextUrl = parse_url($sorted[$i + 1]->url, PHP_URL_PATH) ?: '/';
if (!isset($exitCounts[$nextUrl])) {
$exitCounts[$nextUrl] = 0;
}
$exitCounts[$nextUrl]++;
}
}
}
arsort($exitCounts);
return array_slice(array_map(fn($url, $count) => [
'url' => $url,
'count' => $count,
], array_keys($exitCounts), $exitCounts), 0, 10);
}
}
@@ -1,71 +0,0 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\AppStructure\Models\MainSection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class GetSectionsStatsTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$sections = config('analytics.sections', []);
$hits = AnalyticsHit::where('created_at', '>=', $from)
->selectRaw('url, session_id')
->get();
$grouped = [];
foreach ($hits as $hit) {
$prefix = $this->resolvePrefix($hit->url, $sections);
if (!isset($grouped[$prefix])) {
$grouped[$prefix] = ['views' => 0, 'visitors' => []];
}
$grouped[$prefix]['views']++;
$grouped[$prefix]['visitors'][$hit->session_id] = true;
}
$result = [];
foreach ($grouped as $prefix => $data) {
$config = $sections[$prefix] ?? null;
$result[] = [
'prefix' => $prefix,
'label' => $config['label'] ?? $prefix,
'icon' => $config['icon'] ?? 'folder',
'views' => $data['views'],
'visitors' => count($data['visitors']),
'order' => $config['order'] ?? 99,
];
}
usort($result, fn($a, $b) => $a['order'] <=> $b['order']);
return $result;
}
private function resolvePrefix(string $url, array $sections): string
{
$path = parse_url($url, PHP_URL_PATH) ?: '/';
$segments = explode('/', trim($path, '/'));
$firstSegment = '/' . ($segments[0] ?? '');
if (isset($sections[$firstSegment])) {
return $firstSegment;
}
if ($firstSegment === '/' && $path === '/') {
return '/';
}
// Check main sections for CMS pages
$mainSection = MainSection::where('slug', $segments[0] ?? '')->first();
if ($mainSection) {
return '/page';
}
return '/other';
}
}

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