Squashed commit message
Some checks failed
Telegram Mini App Shop Builder / Compute version metadata (push) Has been cancelled
Telegram Mini App Shop Builder / Run Frontend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run Backend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run PHP_CodeSniffer (push) Has been cancelled
Telegram Mini App Shop Builder / Build module. (push) Has been cancelled
Telegram Mini App Shop Builder / release (push) Has been cancelled

This commit is contained in:
2026-03-11 22:08:41 +03:00
commit 5439e8ef9a
590 changed files with 65793 additions and 0 deletions

64
.cursor/agents.md Normal file
View File

@@ -0,0 +1,64 @@
# Cursor AI Agents Configuration
## Роли и правила поведения ИИ
### Основная роль: Senior Full-Stack Developer
Вы - опытный full-stack разработчик, специализирующийся на:
- OpenCart модульной разработке
- Кастомных фреймворках (OpenCart Framework)
- PHP 7.4+ с современными практиками
- Vue.js 3 (Composition API)
- Telegram Mini App разработке
### Правила работы с кодом
1. **Всегда используй существующие паттерны проекта**
2. **Не создавай дубликаты - используй существующие утилиты**
3. **Следуй соглашениям именования проекта**
4. **Тестируй изменения перед коммитом**
5. **Документируй публичные API**
6. **Комментарии только на английском языке и только если они действительно оправданы**
### Правила коммитов
1. **Следование Conventional Commits**
- Используй префиксы: `feat:`, `fix:`, `chore:`, `refactor:`, `style:`, `test:`, `docs:`
- Формат: `<type>: <subject>` (первая строка до 72 символов)
- После пустой строки - подробное описание изменений
2. **Язык коммитов**
- Все коммиты на **английском языке**
- Подробное описание изменений в теле коммита
- Перечисляй все измененные файлы и ключевые изменения
3. **Примеры правильных коммитов**
```
feat: add setting to control category products button visibility
- Add show_category_products_button field to StoreDTO
- Update SettingsSerializerService to support new field
- Add setting in admin panel on 'Store' tab with toggle
- Pass setting to SPA through SettingsHandler
- Button displays only for categories with child categories
- Add default value true to configuration
```
### Запрещено
- Хардкод значений (используй конфиги/настройки)
- Игнорирование обработки ошибок
- Создание циклических зависимостей
Для разработки FrontEnd используй:
- Vue.js 3 (Composition API)
- Старайся избегать функций watch там, где это возможно и где можно сделать более красиво.
- Для frontend/admin используй Tailwind 4 с префиксом `tw:`.
- Для frontend/spa используй Tailwind 4 без префикса.
- Для frontend/admin используй иконки от FontAwesome 4, потому что это уже встроено в OpenCart 3.
- Для frontend/admin используй компоненты VuePrime 4.
- Для frontend/spa используй Daisy UI.
- Чтобы получить название стандартной таблицы OpenCart, используй хелпер `db_table`, либо добавляй константу DB_PREFIX перед названием таблицы. Так ты получишь название таблицы с префиксом.
- Все таблицы моего модуля MegaPay начинаются с префикса `megapay_`. Примеры миграций лежат в `module/oc_telegram_shop/upload/oc_telegram_shop/database/migrations`

44
.cursor/config.json Normal file
View File

@@ -0,0 +1,44 @@
{
"rules": {
"preferCompositionAPI": true,
"strictTypes": true,
"noHardcodedValues": true,
"useDependencyInjection": true
},
"paths": {
"megapay_module": "module/oc_telegram_shop/upload/oc_telegram_shop",
"frontendAdmin": "frontend/admin",
"telegramShopSpa": "frontend/spa",
"migrations": "module/oc_telegram_shop/upload/oc_telegram_shop/database/migrations",
"megapayHandlers": "module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers",
"adminHandlers": "module/oc_telegram_shop/upload/oc_telegram_shop/bastion/Handlers",
"models": "module/oc_telegram_shop/upload/oc_telegram_shop/src/Models",
"framework": "module/oc_telegram_shop/upload/oc_telegram_shop/framework"
},
"naming": {
"classes": "PascalCase",
"methods": "camelCase",
"variables": "camelCase",
"constants": "UPPER_SNAKE_CASE",
"files": "PascalCase for classes, kebab-case for others",
"tables": "snake_case with megapay_ prefix"
},
"php": {
"version": "7.4+",
"preferVersion": "7.4+",
"psr12": true
},
"javascript": {
"version": "ES2020+",
"framework": "Vue 3 Composition API",
"stateManagement": "Pinia",
"uiLibrary": "PrimeVue (admin), Tailwind (spa)"
},
"database": {
"queryBuilder": true,
"migrations": true,
"tablePrefix": "megapay_",
"noForeignKeys": true
}
}

View File

@@ -0,0 +1,38 @@
## MegaPay Pulse Heartbeat Telemetry
### Цель
Раз в час отправлять телеметрию (heartbeat) на MegaPay Pulse, чтобы фиксировать состояние магазина и версии окружения без участия пользователя.
### Backend (`module/oc_telegram_shop/upload/oc_telegram_shop`)
- `framework/MegaPayPulse/MegaPayPulseService.php`
- Новый метод `handleHeartbeat()` собирает данные: домен (через `Utils::getCurrentDomain()`), username бота (через `TelegramService::getMe()`), версии PHP, модуля (из `composer.json`), OpenCart (`VERSION` и `VERSION_CORE`), текущий UTC timestamp.
- Последний успешный пинг кешируется (ключ `megapay_pulse_heartbeat`, TTL 1 час) через существующий `CacheInterface`.
- Подпись heartbeat выполняется через отдельный `PayloadSigner`, который использует секрет `pulse.heartbeat_secret`/`PULSE_HEARTBEAT_SECRET`. Логируются предупреждения при ошибках кеша/бота/подписи.
- Отправка идет на эндпоинт `heartbeat` с таймаутом 2 секунды и заголовком `X-MEGAPAY-VERSION`, взятым из `composer.json`.
- `framework/MegaPayPulse/MegaPayPulseServiceProvider.php`
- Регистрирует основной `PayloadSigner` (по `pulse.api_key`) и отдельный heartbeat signer (по `pulse.heartbeat_secret` или `PULSE_HEARTBEAT_SECRET`), инжектит `LoggerInterface`.
- `src/Handlers/TelemetryHandler.php` + `src/routes.php`
- Добавлен маршрут `heartbeat`, который вызывает `handleHeartbeat()` и возвращает `{ status: "ok" }`. Логгер пишет warning при проблемах.
### Frontend (`frontend/spa`)
- `src/utils/ftch.js`: новая функция `heartbeat()` вызывает `api_action=heartbeat`.
- `src/stores/Pulse.js`: добавлен action `heartbeat`, использующий новую API-функцию и логирующий результат.
- `src/main.js`: после `pulse.ingest(...)` вызывается `pulse.heartbeat()` без блокировки цепочки.
### Конфигурация / ENV
- `PULSE_API_HOST` — базовый URL MegaPay Pulse (используется и для events, и для heartbeat).
- `PULSE_TIMEOUT` — общий таймаут HTTP (для heartbeat принудительно 2 секунды).
- `PULSE_HEARTBEAT_SECRET` (или `pulse.heartbeat_secret` в настройках) — общий секрет для подписания heartbeat. Обязателен, иначе heartbeat не будет отправляться.
- `pulse.api_key` — прежний API ключ, используется только для event-инджеста.
### Поведение
1. Frontend (SPA) вызывает `heartbeat` при инициализации приложения (fire-and-forget).
2. Backend проверяет кеш. Если часа еще не прошло, `handleHeartbeat()` возвращает без запросов.
3. При необходимости собираются данные, подписываются через heartbeat signer и отправляются POST-запросом на `/heartbeat`.
4. Любые сбои (bot info, подпись, HTTP) логируются как warning, чтобы не тревожить пользователей.
### TODO / Возможные улучшения
- При необходимости вынести heartbeat запуск в крон/CLI, чтобы не зависеть от фронтенда.
- Добавить метрики успешности heartbeat в админку.

View File

@@ -0,0 +1,128 @@
# Промпты для генерации API
## Создание нового API endpoint
```
Создай новый API endpoint [ENDPOINT_NAME] для [DESCRIPTION]:
1. Handler в [HANDLER_PATH]:
- Метод handle() принимает Request
- Валидация входных данных
- Использование Service для бизнес-логики
- Возврат JsonResponse с правильной структурой
- Обработка ошибок с логированием
2. Service в [SERVICE_PATH]:
- Бизнес-логика
- Работа с Model
- Валидация данных
- Обработка исключений
3. Model в [MODEL_PATH] (если нужен):
- Методы для работы с БД
- Использование Query Builder
- Типизация методов
4. Route в routes.php:
- Добавь маршрут с правильным именем
5. Миграция (если нужна новая таблица):
- Создай миграцию в database/migrations/
- Используй фиксированный префикс megapay_
- Добавь индексы где необходимо
Следуй архитектуре MVC-L проекта и используй существующие паттерны.
```
## Создание CRUD API
```
Создай полный CRUD API для сущности [ENTITY_NAME]:
1. Handler с методами:
- list() - список с пагинацией и фильтрацией
- get() - получение одной записи
- create() - создание
- update() - обновление
- delete() - удаление
2. Service с бизнес-логикой для всех операций
3. Model с методами:
- findAll() - список
- findById() - по ID
- create() - создание
- update() - обновление
- delete() - удаление
4. DTO для валидации данных
5. Миграция для таблицы [TABLE_NAME]
6. Routes для всех endpoints
Используй серверную пагинацию, фильтрацию и сортировку для list().
```
## Создание Admin API endpoint
```
Создай Admin API endpoint [ENDPOINT_NAME] в bastion/Handlers/:
1. Handler в bastion/Handlers/[HANDLER_NAME].php:
- Используй Request для получения параметров
- Валидация данных
- Работа через Service
- Возврат JsonResponse с структурой { data: { data: [...], totalRecords: ... } }
- Обработка ошибок
2. Service в bastion/Services/ (если нужен):
- Бизнес-логика для админки
- Работа с Models
3. Route в bastion/routes.php
4. Frontend компонент (если нужен UI):
- Vue компонент в frontend/admin/src/views/
- Используй PrimeVue компоненты
- Серверная пагинация/фильтрация
- Обработка ошибок с toast уведомлениями
Следуй существующим паттернам проекта.
```
## Создание Frontend API клиента
```
Создай функцию для работы с API endpoint [ENDPOINT_NAME]:
1. В frontend/[admin|spa]/src/utils/http.js:
- Функция api[Method] для вызова endpoint
- Правильная обработка ошибок
- Возврат структурированного ответа
2. Использование:
- В компонентах через import
- Обработка loading states
- Toast уведомления для ошибок
Следуй существующим паттернам в http.js.
```
## Создание миграции
```
Создай миграцию для таблицы [TABLE_NAME]:
1. Файл: database/migrations/[TIMESTAMP]_[DESCRIPTION].php
2. Используй фиксированный префикс megapay_ для таблицы
3. Добавь все необходимые поля с правильными типами
4. Добавь индексы для часто используемых полей
5. Используй utf8mb4_unicode_ci collation
6. Используй InnoDB engine
7. Добавь created_at и updated_at timestamps
8. Не создавай foreign keys (используй только индексы)
Следуй структуре существующих миграций.
```

View File

@@ -0,0 +1,106 @@
# Правила оформления Changelog для документации
## Общие требования
- **Формат**: Markdown
- **Структура**: Одна версия = одна страница
- **Стиль**: Профессиональный, лаконичный, без маркетинговых лозунгов
- **Язык**: Русский
- **Целевая аудитория**: Разработчики и владельцы магазинов
- **Содержимое**: Только ключевые изменения, без лишних технических деталей
## Структура страницы
### Вводный абзац
- Краткое описание релиза (1-2 предложения)
- Дополнить 1-2 предложениями о ключевых изменениях
- Упоминать основные фичи и улучшения
### Разделы (строго в этом порядке)
1. **🚀 Добавлено** - новые функции и возможности
2. **✨ Улучшено** - улучшения существующих функций
3. **🐞 Исправлено** - исправленные ошибки
4. **⚠️ Несовместимые изменения** - изменения без обратной совместимости
5. **🔧 Технические изменения** - технические изменения (отдельный раздел)
### Правила для разделов
- Раздел **НЕ** добавлять, если в нём нет пунктов
- Использовать маркдаун-списки, без нумерации
- Не добавлять ссылок, если они явно не указаны
- Не добавлять лишних пояснений, выводов и заключений
- Выводить только содержимое Markdown-файла, без комментариев
## Разделение изменений
### Бизнес-логика и процессы
Разделы "Добавлено", "Улучшено", "Исправлено", "Несовместимые изменения" содержат только изменения, связанные с:
- Бизнес-процессами магазина
- Пользовательским опытом
- Функциональностью для владельцев магазинов
- Продающими фичами
### Технические изменения
Все технические изменения выносятся в отдельный раздел **🔧 Технические изменения**:
- Без подразделов, всё в одном списке
- Только самые ключевые технические изменения
- Не расписывать детали, которые не интересны пользователю
## Стиль написания
### Терминология
- Английские термины писать по-английски (например: "navbar", а не "навбар")
- Избегать технических терминов в бизнес-разделах (например: "customer_id" → "автоматическое связывание покупателей")
### Описания
**Для ключевых продающих фич:**
- Давать подробное описание с деталями
- Указывать возможности и преимущества
- Подчеркивать ценность для пользователя
**Для технических изменений:**
- Кратко, только ключевое
- Без лишних деталей
**Для обычных функций:**
- Лаконично, но информативно
- Фокус на пользе для пользователя
## Порядок размещения
### В разделе "Добавлено"
Ключевые продающие фичи размещать **первыми**:
1. Система конфигурации главной страницы через блоки
2. Конструктор форм на базе FormKit
3. Интеграция с Яндекс.Метрикой
4. Политика конфиденциальности
5. Поддержка купонов и подарочных сертификатов
6. Остальные функции
## Примеры правильных формулировок
### ✅ Хорошо
- "Автоматическое связывание покупателей Telegram с покупателями OpenCart: система автоматически находит и связывает покупателей из Telegram-магазина с существующими покупателями в OpenCart по email или номеру телефона, создавая единую базу клиентов"
### ❌ Плохо
- "Сохранение customer_id вместе с заказом для связи с клиентами OpenCart: автоматическая связь заказов из Telegram с покупателями в OpenCart, единая база клиентов"
### ✅ Хорошо
- "Navbar с логотипом и названием приложения"
### ❌ Плохо
- "Навбар с логотипом и названием приложения"
## Что не включать
- Внутренние детали разработки (тесты, статический анализ, обфускация)
- Технические детали, не интересные пользователям
- Избыточные технические описания
- Маркетинговые лозунги и призывы

View File

@@ -0,0 +1,62 @@
# Промпты для документирования
## Документирование класса
```
Добавь PHPDoc документацию для класса [CLASS_NAME]:
1. Описание класса и его назначения
2. @package тег
3. @author тег
4. Документация для всех публичных методов
5. Документация для публичных свойств
6. Примеры использования где уместно
```
## Документирование метода
```
Добавь PHPDoc для метода [METHOD_NAME]:
1. Описание метода
2. @param для всех параметров с типами
3. @return с типом возвращаемого значения
4. @throws для всех исключений
5. Примеры использования если сложная логика
```
## Документирование API endpoint
```
Создай документацию для API endpoint [ENDPOINT_NAME]:
1. Описание назначения
2. HTTP метод и путь
3. Параметры запроса (query/body)
4. Формат ответа (JSON структура)
5. Коды ошибок
6. Примеры запросов/ответов
7. Требования к авторизации
```
## Документирование Vue компонента
```
Добавь документацию для Vue компонента [COMPONENT_NAME]:
1. Описание компонента
2. Props с типами и описаниями
3. Emits с описаниями
4. Slots если есть
5. Примеры использования
6. Зависимости от других компонентов
```
## Создание README
```
Создай README.md для [MODULE/COMPONENT]:
1. Описание назначения
2. Установка/настройка
3. Использование с примерами
4. API документация
5. Конфигурация
6. Troubleshooting
```

View File

@@ -0,0 +1,88 @@
# Промпты для рефакторинга
## Общий рефакторинг
```
Проанализируй код в файле [FILE_PATH] и выполни рефакторинг:
1. Убери дублирование кода
2. Улучши читаемость
3. Примени принципы SOLID
4. Добавь обработку ошибок где необходимо
5. Улучши типизацию
6. Добавь документацию для публичных методов
7. Убедись что код следует архитектуре MVC-L проекта
8. Используй существующие утилиты и сервисы проекта вместо создания новых
```
## Рефакторинг Handler
```
Рефакторинг Handler [HANDLER_NAME]:
1. Вынеси бизнес-логику в отдельный Service
2. Добавь валидацию входных данных
3. Улучши обработку ошибок с логированием
4. Используй DTO для передачи данных
5. Добавь PHPDoc комментарии
6. Убедись что используется Dependency Injection
7. Оптимизируй запросы к БД если необходимо
```
## Рефакторинг Model
```
Рефакторинг Model [MODEL_NAME]:
1. Убедись что все запросы используют Query Builder
2. Добавь методы для частых операций (findBy, findAll, create, update)
3. Добавь валидацию данных перед сохранением
4. Улучши типизацию методов
5. Добавь PHPDoc комментарии
6. Используй транзакции для сложных операций
```
## Рефакторинг Vue компонента
```
Рефакторинг Vue компонента [COMPONENT_NAME]:
1. Вынеси логику в composable функции
2. Улучши типизацию props и emits
3. Оптимизируй computed properties
4. Добавь обработку ошибок
5. Улучши структуру template
6. Добавь loading states
7. Используй существующие утилиты проекта
```
## Удаление дублирования
```
Найди и устрани дублирование кода в:
- [FILE_PATH_1]
- [FILE_PATH_2]
- [FILE_PATH_3]
Создай общие утилиты/сервисы где необходимо, следуя архитектуре проекта.
```
## Улучшение производительности
```
Проанализируй производительность кода в [FILE_PATH]:
1. Оптимизируй запросы к БД (используй индексы, избегай N+1)
2. Добавь кэширование где уместно
3. Оптимизируй алгоритмы
4. Уменьши количество запросов к API
5. Используй ленивую загрузку на фронтенде
```
## Улучшение безопасности
```
Улучши безопасность кода в [FILE_PATH]:
1. Добавь валидацию всех входных данных
2. Используй prepared statements (Query Builder)
3. Добавь CSRF защиту где необходимо
4. Валидируй права доступа
5. Санитизируй выходные данные
6. Добавь rate limiting где необходимо
```

View File

@@ -0,0 +1,53 @@
# Промпты для тестирования
## Создание unit теста
```
Создай unit тест для [CLASS_NAME] в tests/Unit/:
1. Используй PHPUnit
2. Покрой все публичные методы
3. Тестируй успешные сценарии
4. Тестируй обработку ошибок
5. Используй моки для зависимостей
6. Следуй структуре существующих тестов
7. Используй TestCase базовый класс проекта
```
## Создание integration теста
```
Создай integration тест для [FEATURE_NAME] в tests/Integration/:
1. Тестируй полный flow от запроса до ответа
2. Используй тестовую БД
3. Очищай данные после тестов
4. Тестируй реальные сценарии использования
5. Проверяй валидацию данных
6. Проверяй обработку ошибок
```
## Создание Vue компонент теста
```
Создай тест для Vue компонента [COMPONENT_NAME] в frontend/[admin|spa]/tests/:
1. Используй Vitest
2. Тестируй рендеринг компонента
3. Тестируй props
4. Тестируй события (emits)
5. Тестируй пользовательские взаимодействия
6. Используй моки для API вызовов
7. Следуй структуре существующих тестов
```
## Покрытие тестами
```
Проанализируй покрытие тестами для [FILE_PATH]:
1. Определи какие методы не покрыты тестами
2. Создай тесты для критичных методов
3. Убедись что тестируются граничные случаи
4. Добавь тесты для обработки ошибок
```

View File

@@ -0,0 +1,201 @@
# Архитектурные правила
## OpenCart Framework Architecture
### MVC-L Pattern
Проект использует модифицированный паттерн MVC-L (Model-View-Controller-Language):
- **Model**: Классы в `src/Models/` - работа с данными, доступ к БД
- **View**: Vue компоненты на фронтенде, JSON ответы на бэкенде
- **Controller**: Handlers в `src/Handlers/` и `bastion/Handlers/`
- **Language**: Переводчик в `framework/Translator/`
### Dependency Injection
Все зависимости внедряются через Container:
```php
// ✅ Правильно
public function __construct(
private Builder $builder,
private TelegramCustomer $telegramCustomerModel
) {}
// ❌ Неправильно
public function __construct() {
$this->builder = new Builder(...);
}
```
### Service Providers
Регистрация сервисов через Service Providers:
```php
class MyServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(MyService::class, function ($app) {
return new MyService($app->get(Dependency::class));
});
}
}
```
### Routes
Маршруты определяются в `routes.php`:
```php
return [
'actionName' => [HandlerClass::class, 'methodName'],
];
```
### Handlers (Controllers)
Handlers обрабатывают HTTP запросы:
```php
class MyHandler
{
public function handle(Request $request): JsonResponse
{
// Валидация
// Бизнес-логика через Services
// Возврат JsonResponse
}
}
```
### Models
Models работают с данными:
```php
class MyModel
{
public function __construct(
private ConnectionInterface $database,
private Builder $builder
) {}
public function findById(int $id): ?array
{
return $this->builder->newQuery()
->from($this->tableName)
->where('id', '=', $id)
->firstOrNull();
}
}
```
### Services
Services содержат бизнес-логику:
```php
class MyService
{
public function __construct(
private MyModel $model
) {}
public function doSomething(array $data): array
{
// Бизнес-логика
return $this->model->create($data);
}
}
```
### Migrations
Миграции в `database/migrations/`:
```php
return new class extends Migration {
public function up(): void
{
$this->database->statement('CREATE TABLE ...');
}
};
```
### Query Builder
Всегда используй Query Builder вместо прямых SQL:
```php
// ✅ Правильно
$query = $this->builder->newQuery()
->select(['id', 'name'])
->from('table_name')
->where('status', '=', 'active')
->get();
// ❌ Неправильно
$result = $this->database->query("SELECT * FROM table_name WHERE status = 'active'");
```
### Frontend Architecture
#### Admin Panel (Vue 3)
- Composition API
- Pinia для state management
- PrimeVue для UI компонентов
- Axios для HTTP запросов
- Vue Router для навигации
#### SPA (Telegram Mini App)
- Composition API
- Pinia stores
- Tailwind CSS для стилей
- Telegram WebApp API
- Vue Router
### Naming Conventions
- **Classes**: PascalCase (`TelegramCustomerService`)
- **Methods**: camelCase (`getCustomers`)
- **Variables**: camelCase (`$customerData`)
- **Constants**: UPPER_SNAKE_CASE (`MAX_RETRIES`)
- **Files**: PascalCase для классов, kebab-case для остального
- **Tables**: snake_case с префиксом `megapay_`
### Error Handling
Всегда обрабатывай ошибки:
```php
try {
$result = $this->service->doSomething();
} catch (SpecificException $e) {
$this->logger->error('Error message', ['exception' => $e]);
throw new UserFriendlyException('User message');
}
```
### Configuration
Используй конфигурационные файлы в `configs/`:
```php
$config = $this->app->getConfigValue('app.setting_name');
```
### Caching
Используй Cache Service для кэширования:
```php
$cache = $this->app->get(CacheInterface::class);
$value = $cache->get('key', function() {
return expensiveOperation();
});
```

View File

@@ -0,0 +1,70 @@
# FormBuilder System Context
## Architectural Overview
The FormBuilder ecosystem is a strictly typed Vue 3 application module designed to generate standard FormKit Schema JSON. It eschews internal DTOs in favor of direct schema manipulation.
### Core Components
1. **FormBuilderView (`views/FormBuilderView.vue`)**:
* **Role**: Smart container / Data fetcher.
* **Responsibility**: Fetches form data from API (`GET /api/admin/forms/{alias}`), handles loading states, and passes data to `FormBuilder`.
* **Contract**: Expects API response `{ data: { schema: Array, is_custom: Boolean, ... } }`.
2. **FormBuilder (`components/FormBuilder/FormBuilder.vue`)**:
* **Role**: Main Orchestrator / State Owner.
* **Responsibility**: Manages `v-model` (schema), mode switching (Visual/Code/Preview), and provides state to children.
* **State Management**: Uses `defineModel` for `formFields` (schema) and `isCustom` (mode flag). Uses `provide('formFields')` and `provide('selectedFieldId')` for deep dependency injection.
* **Modes**:
* **Visual**: Drag-and-drop interface using `vuedraggable`.
* **Code**: Direct JSON editing of the FormKit schema. Sets `isCustom = true`.
* **Preview**: Renders the current schema using `FormKit`.
3. **FormCanvas (`components/FormBuilder/FormCanvas.vue`)**:
* **Role**: Visual Editor Surface.
* **Responsibility**: Renders the draggable list of fields.
* **Implementation**: Uses `vuedraggable` bound to `formFields`.
* **UX**: Implements "Ghost" and "Drag" classes for visual feedback. Handles selection logic.
4. **FieldsPanel (`components/FormBuilder/FieldsPanel.vue`)**:
* **Role**: Component Palette.
* **Responsibility**: Source of truth for available field types.
* **Implementation**: Uses `vuedraggable` with `pull: 'clone', put: false` to spawn new fields.
5. **FieldSettings (`components/FormBuilder/FieldSettings.vue`)**:
* **Role**: Property Editor.
* **Responsibility**: Edits properties of the `selectedFieldId`.
* **Constraint**: Must use PrimeVue components for all inputs.
## Data Flow & Invariants
1. **Schema Authority**: The FormKit Schema JSON is the single source of truth. There is no "internal model" separate from the schema.
2. **Reactivity**:
* `formFields` is an Array of Objects.
* Mutations must preserve reactivity. When using `v-model` or `provide/inject`, ensure array methods (splice, push, filter) are used correctly or replace the entire array reference if needed to trigger watchers.
* **Immutability**: `useFormFields` composable uses immutable patterns (returning new array references) to ensure `defineModel` in parent detects changes.
3. **Mode Logic**:
* Switching to **Code** mode sets `isCustom = true`.
* Switching to **Visual** mode sets `isCustom = false`.
* **Safety**: Switching modes triggers JSON validation. Invalid JSON prevents mode switch.
4. **Drag and Drop**:
* Powered by `vuedraggable` (Sortable.js).
* **Clone Logic**: `FieldsPanel` clones from `availableFields`. `FormCanvas` receives the clone.
* **ID Generation**: Unique IDs are generated upon cloning/addition to ensure key stability.
## Naming & Conventions
* **Tailwind**: Use `tw:` prefix for all utility classes (e.g., `tw:flex`, `tw:p-4`).
* **Components**: PrimeVue components are the standard UI kit (Button, Panel, InputText, etc.).
* **Icons**: FontAwesome (`fa fa-*`).
* **Files**: PascalCase for components (`FormBuilder.vue`), camelCase for logic (`useFormFields.js`).
## Integration Rules
* **Backend**: The backend stores the JSON blob directly. `FormBuilder` does not transform data before save; it emits the raw schema.
* **API**: `useFormsStore` handles API communication.
## Pitfalls & Warnings
* **vuedraggable vs @formkit/drag-and-drop**: We strictly use `vuedraggable`. Do not re-introduce `@formkit/drag-and-drop`.
* **Watchers**: Avoid `watch` where `computed` or event handlers suffice, to prevent infinite loops in bidirectional data flow.
* **Tailwind Config**: Do not use `@apply` with `tw:` prefixed classes in `<style>` blocks; standard CSS properties should be used if custom classes are needed.
## Future Modifications
* **Adding Fields**: Update `constants/availableFields.js` and ensure `utils/fieldHelpers.js` supports the new type.
* **Validation**: FormKit validation rules string (e.g., "required|email") is edited as a raw string in `FieldSettings`. Complex validation builders would require a new UI component.

332
.cursor/rules/javascript.md Normal file
View File

@@ -0,0 +1,332 @@
# JavaScript/TypeScript Code Style Rules
## JavaScript Version
- ES2020+ features
- Modern async/await
- Optional chaining (`?.`)
- Nullish coalescing (`??`)
- Template literals
## Code Style
### Variable Declarations
```javascript
// ✅ Используй const по умолчанию
const customers = [];
const totalRecords = 0;
// ✅ let только когда нужно переназначение
let currentPage = 1;
currentPage = 2;
// ❌ Не используй var
var oldVariable = 'bad';
```
### Arrow Functions
```javascript
// ✅ Предпочтительно для коротких функций
const filtered = items.filter(item => item.isActive);
// ✅ Для методов объектов
const api = {
get: async (url) => {
return await fetch(url);
}
};
// ✅ Для сложной логики - обычные функции
function complexCalculation(data) {
// много строк кода
return result;
}
```
### Template Literals
```javascript
// ✅ Предпочтительно
const message = `User ${userId} not found`;
const url = `${baseUrl}/api/${endpoint}`;
// ❌ Не используй конкатенацию
const message = 'User ' + userId + ' not found';
```
### Optional Chaining
```javascript
// ✅ Используй optional chaining
const name = user?.profile?.name;
const count = data?.items?.length ?? 0;
// ❌ Избегай длинных проверок
const name = user && user.profile && user.profile.name;
```
### Nullish Coalescing
```javascript
// ✅ Используй ?? для значений по умолчанию
const page = params.page ?? 1;
const name = user.name ?? 'Unknown';
// ❌ Не используй || для чисел/булевых
const page = params.page || 1; // 0 будет заменено на 1
```
### Destructuring
```javascript
// ✅ Используй деструктуризацию
const { data, totalRecords } = response.data;
const [first, second] = items;
// ✅ В параметрах функций
function processUser({ id, name, email }) {
// ...
}
// ✅ С значениями по умолчанию
const { page = 1, limit = 20 } = params;
```
### Async/Await
```javascript
// ✅ Предпочтительно
async function loadCustomers() {
try {
const response = await apiGet('getCustomers', params);
return response.data;
} catch (error) {
console.error('Error:', error);
throw error;
}
}
// ❌ Избегай .then() цепочек
function loadCustomers() {
return apiGet('getCustomers', params)
.then(response => response.data)
.catch(error => console.error(error));
}
```
## Vue.js 3 Composition API
### Script Setup
```vue
<script setup>
// ✅ Используй <script setup>
import { ref, computed, onMounted } from 'vue';
import { apiGet } from '@/utils/http.js';
const customers = ref([]);
const loading = ref(false);
const totalRecords = computed(() => customers.value.length);
async function loadCustomers() {
loading.value = true;
try {
const result = await apiGet('getCustomers');
customers.value = result.data || [];
} finally {
loading.value = false;
}
}
onMounted(() => {
loadCustomers();
});
</script>
```
### Reactive State
```javascript
// ✅ Используй ref для примитивов
const count = ref(0);
const name = ref('');
// ✅ Используй reactive для объектов
import { reactive } from 'vue';
const state = reactive({
customers: [],
loading: false
});
// ✅ Или ref для объектов (предпочтительно)
const state = ref({
customers: [],
loading: false
});
```
### Computed Properties
```javascript
// ✅ Используй computed для производных значений
const filteredCustomers = computed(() => {
return customers.value.filter(c => c.isActive);
});
// ❌ Не используй методы для вычислений
function filteredCustomers() {
return customers.value.filter(c => c.isActive);
}
```
### Props
```vue
<script setup>
// ✅ Определяй props с типами
const props = defineProps({
customerId: {
type: Number,
required: true
},
showDetails: {
type: Boolean,
default: false
}
});
</script>
```
### Emits
```vue
<script setup>
// ✅ Определяй emits
const emit = defineEmits(['update', 'delete']);
function handleUpdate() {
emit('update', data);
}
</script>
```
## Pinia Stores
```javascript
// ✅ Используй setup stores
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useCustomersStore = defineStore('customers', () => {
const customers = ref([]);
const loading = ref(false);
const totalRecords = computed(() => customers.value.length);
async function loadCustomers() {
loading.value = true;
try {
const result = await apiGet('getCustomers');
customers.value = result.data || [];
} finally {
loading.value = false;
}
}
return {
customers,
loading,
totalRecords,
loadCustomers
};
});
```
## Error Handling
```javascript
// ✅ Всегда обрабатывай ошибки
async function loadData() {
try {
const result = await apiGet('endpoint');
if (result.success) {
return result.data;
} else {
throw new Error(result.error);
}
} catch (error) {
console.error('Failed to load data:', error);
toast.error('Не удалось загрузить данные');
throw error;
}
}
```
## Naming Conventions
### Variables and Functions
```javascript
// ✅ camelCase
const customerData = {};
const totalRecords = 0;
function loadCustomers() {}
// ✅ Константы UPPER_SNAKE_CASE
const MAX_RETRIES = 3;
const API_BASE_URL = '/api';
```
### Components
```vue
<!-- PascalCase для компонентов -->
<CustomerCard />
<ProductsList />
```
### Files
```javascript
// ✅ kebab-case для файлов
// customers-view.vue
// http-utils.js
// customer-service.js
```
## Imports
```javascript
// ✅ Группируй импорты
// 1. Vue core
import { ref, computed, onMounted } from 'vue';
// 2. Third-party
import { apiGet } from '@/utils/http.js';
import { useToast } from 'primevue';
// 3. Local components
import CustomerCard from '@/components/CustomerCard.vue';
// 4. Types (если TypeScript)
import type { Customer } from '@/types';
```
## TypeScript (где используется)
```typescript
// ✅ Используй типы
interface Customer {
id: number;
name: string;
email?: string;
}
function getCustomer(id: number): Promise<Customer> {
return apiGet(`customers/${id}`);
}
```

243
.cursor/rules/php.md Normal file
View File

@@ -0,0 +1,243 @@
# PHP Code Style Rules
## PHP Version
Проект поддерживает PHP 7.4+
## PSR Standards
- **PSR-1**: Basic Coding Standard
- **PSR-4**: Autoloading Standard
- **PSR-12**: Extended Coding Style
## Code Style
### Type Declarations
```php
// ✅ Правильно - строгая типизация
public function getCustomers(Request $request): JsonResponse
{
$id = (int) $request->get('id');
return new JsonResponse(['data' => $customers]);
}
// ❌ Неправильно - без типов
public function getCustomers($request)
{
return ['data' => $customers];
}
```
### Nullable Types
```php
// ✅ Правильно
public function findById(?int $id): ?array
{
if ($id === null) {
return null;
}
return $this->query->where('id', '=', $id)->firstOrNull();
}
```
### Strict Types
Всегда используй `declare(strict_types=1);`:
```php
<?php
declare(strict_types=1);
namespace App\Services;
```
### Array Syntax
```php
// ✅ Предпочтительно - короткий синтаксис
$array = ['key' => 'value'];
// ❌ Не использовать
$array = array('key' => 'value');
```
### String Interpolation
```php
// ✅ Предпочтительно
$message = "User {$userId} not found";
// ✅ Альтернатива
$message = sprintf('User %d not found', $userId);
```
### Arrow Functions (PHP 7.4+)
```php
// ✅ Для простых операций
$filtered = array_filter($items, fn($item) => $item->isActive());
// ❌ Для сложной логики - используй обычные функции
```
### Nullsafe Operator (PHP 8.0+)
```php
// ✅ Для PHP 7.4
$name = $user && $user->profile ? $user->profile->name : null;
```
## Naming Conventions
### Classes
```php
// ✅ PascalCase
class TelegramCustomerService {}
class UserRepository {}
```
### Methods
```php
// ✅ camelCase
public function getCustomers(): array {}
public function saveOrUpdate(array $data): array {}
```
### Variables
```php
// ✅ camelCase
$customerData = [];
$totalRecords = 0;
```
### Constants
```php
// ✅ UPPER_SNAKE_CASE
private const MAX_RETRIES = 3;
public const DEFAULT_PAGE_SIZE = 20;
```
### Private Properties
```php
// ✅ camelCase с модификатором доступа
private string $tableName;
private Builder $builder;
```
## Documentation
### PHPDoc
```php
/**
* @throws ValidationException Если параметры невалидны
*/
public function getCustomers(Request $request): JsonResponse
{
// ...
}
```
### Inline Comments
```php
// ✅ Полезные комментарии
// Применяем фильтры для подсчета общего количества записей
$countQuery = $this->buildCountQuery($filters);
// ❌ Очевидные комментарии
// Получаем данные
$data = $this->getData();
```
## Error Handling
### Exceptions
```php
// ✅ Специфичные исключения
if (!$userId) {
throw new InvalidArgumentException('User ID is required');
}
// ✅ Логирование
try {
$result = $this->service->process();
} catch (Exception $e) {
$this->logger->error('Processing failed', [
'exception' => $e,
'context' => $context,
]);
throw new ProcessingException('Failed to process', 0, $e);
}
```
## Query Builder Usage
### Always Use Query Builder
```php
// ✅ Правильно
$customers = $this->builder->newQuery()
->select(['id', 'name', 'email'])
->from('megapay_customers')
->where('status', '=', 'active')
->orderBy('created_at', 'DESC')
->get();
// В крайних случаях можно использовать прямые SQL
$result = $this->database->query("SELECT * FROM megapay_customers");
```
### Parameter Binding
```php
// ✅ Query Builder автоматически биндит параметры
$query->where('name', 'LIKE', "%{$search}%");
// ❌ Никогда не конкатенируй значения в SQL, избегай SQL Injection.
```
## Array Access
### Safe Array Access
```php
// ✅ Используй Arr::get()
use Openguru\OpenCartFramework\Support\Arr;
$value = Arr::get($data, 'key', 'default');
// ❌ Небезопасно
$value = $data['key']; // может вызвать ошибку
```
## Return Types
```php
// ✅ Всегда указывай return type
public function getData(): array {}
public function findById(int $id): ?array {}
public function process(): void {}
// ❌ Без типа
public function getData() {}
```
## Visibility Modifiers
```php
// ✅ Всегда указывай модификатор доступа
private string $tableName;
protected Builder $builder;
public function getData(): array {}
```

370
.cursor/rules/vue.md Normal file
View File

@@ -0,0 +1,370 @@
# Vue.js 3 Rules
## Component Structure
### Template
```vue
<template>
<!-- Логическая структура -->
<div class="container">
<header>
<h2>{{ title }}</h2>
</header>
<main>
<DataTable :value="items" />
</main>
<footer>
<Button @click="handleSave">Save</Button>
</footer>
</div>
</template>
```
### Script Setup
```vue
<script setup>
// ✅ Всегда используй <script setup>
import { ref, computed, onMounted } from 'vue';
import DataTable from 'primevue/datatable';
import { apiGet } from '@/utils/http.js';
// Props
const props = defineProps({
title: {
type: String,
required: true
}
});
// Emits
const emit = defineEmits(['update', 'delete']);
// State
const items = ref([]);
const loading = ref(false);
// Computed
const totalItems = computed(() => items.value.length);
// Methods
async function loadItems() {
loading.value = true;
try {
const result = await apiGet('getItems');
items.value = result.data || [];
} finally {
loading.value = false;
}
}
// Lifecycle
onMounted(() => {
loadItems();
});
</script>
```
### Styles
```vue
<style scoped>
/* ✅ Используй scoped стили */
.container {
padding: 1rem;
}
/* ✅ Используй :deep() для доступа к дочерним компонентам */
:deep(.p-datatable) {
border: 1px solid #ccc;
}
</style>
```
## Component Naming
```vue
<!-- PascalCase для компонентов -->
<CustomerCard />
<ProductsList />
<OrderDetails />
<!-- kebab-case в шаблоне тоже работает -->
<customer-card />
```
## Props
```vue
<script setup>
// ✅ Всегда определяй типы и валидацию
const props = defineProps({
customerId: {
type: Number,
required: true,
validator: (value) => value > 0
},
showDetails: {
type: Boolean,
default: false
},
items: {
type: Array,
default: () => []
}
});
</script>
```
## Emits
```vue
<script setup>
// ✅ Определяй emits с типами
const emit = defineEmits<{
update: [id: number, data: object];
delete: [id: number];
cancel: [];
}>();
// ✅ Или с валидацией
const emit = defineEmits({
update: (id: number, data: object) => {
if (id > 0 && typeof data === 'object') {
return true;
}
console.warn('Invalid emit arguments');
return false;
}
});
</script>
```
## Reactive State
```vue
<script setup>
// ✅ ref для примитивов
const count = ref(0);
const name = ref('');
// ✅ ref для объектов (предпочтительно)
const customer = ref({
id: null,
name: '',
email: ''
});
// ✅ reactive только если нужно
import { reactive } from 'vue';
const state = reactive({
items: [],
loading: false
});
</script>
```
## Computed Properties
```vue
<script setup>
// ✅ Используй computed для производных значений
const filteredItems = computed(() => {
return items.value.filter(item => item.isActive);
});
// ✅ Computed с getter/setter
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (value) => {
const parts = value.split(' ');
firstName.value = parts[0];
lastName.value = parts[1];
}
});
</script>
```
## Event Handlers
```vue
<template>
<!-- Используй kebab-case для событий -->
<Button @click="handleClick" />
<Input @input="handleInput" />
<Form @submit.prevent="handleSubmit" />
</template>
<script setup>
// ✅ Именуй обработчики с префиксом handle
function handleClick() {
// ...
}
function handleInput(event) {
// ...
}
function handleSubmit() {
// ...
}
</script>
```
## Conditional Rendering
```vue
<template>
<!-- Используй v-if для условного рендеринга -->
<div v-if="loading">
<LoadingSpinner />
</div>
<!-- v-show для частых переключений -->
<div v-show="hasItems">
<ItemsList :items="items" />
</div>
<!-- v-else для альтернатив -->
<div v-else>
<EmptyState />
</div>
</template>
```
## Lists
```vue
<template>
<!-- Всегда используй :key -->
<div v-for="item in items" :key="item.id">
{{ item.name }}
</div>
<!-- Для индексов -->
<div v-for="(item, index) in items" :key="`item-${index}`">
{{ item.name }}
</div>
</template>
```
## Form Handling
```vue
<template>
<form @submit.prevent="handleSubmit">
<!-- Используй v-model -->
<InputText v-model="form.name" />
<Textarea v-model="form.description" />
<!-- Для кастомных компонентов -->
<CustomInput v-model="form.email" />
</form>
</template>
<script setup>
const form = ref({
name: '',
description: '',
email: ''
});
function handleSubmit() {
// Валидация и отправка
}
</script>
```
## PrimeVue Components
```vue
<template>
<!-- Используй PrimeVue компоненты в админке -->
<DataTable
:value="customers"
:loading="loading"
paginator
:rows="20"
@page="onPage"
>
<Column field="name" header="Name" sortable />
</DataTable>
</template>
```
## Styling
```vue
<style scoped>
/* ✅ Используй scoped -->
.container {
padding: 1rem;
}
/* ✅ :deep() для дочерних компонентов */
:deep(.p-datatable) {
border: 1px solid #ccc;
}
/* ✅ :slotted() для слотов */
:slotted(.header) {
font-weight: bold;
}
</style>
```
## Composition Functions
```vue
<script setup>
// ✅ Выноси сложную логику в composables
import { useCustomers } from '@/composables/useCustomers.js';
const {
customers,
loading,
loadCustomers,
totalRecords
} = useCustomers();
onMounted(() => {
loadCustomers();
});
</script>
```
## Error Handling
```vue
<script setup>
import { useToast } from 'primevue';
const toast = useToast();
async function loadData() {
try {
const result = await apiGet('endpoint');
if (result.success) {
data.value = result.data;
} else {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: result.error
});
}
} catch (error) {
console.error('Error:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось загрузить данные'
});
}
}
</script>
```

98
.cursorignore Normal file
View File

@@ -0,0 +1,98 @@
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Cursor ignore patterns
frontend/spa/node_modules
frontend/admin/node_modules
module/oc_telegram_shop/upload/oc_telegram_shop/vendor
module/oc_telegram_shop/upload/image
module/oc_telegram_shop/upload/oc_telegram_shop/.phpunit.cache
# Dependencies
node_modules/
vendor/
composer.lock
package-lock.json
yarn.lock
# Build outputs
dist/
build/
*.phar
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Logs
*.log
logs/
storage/logs/
# Cache
storage/cache/
.cache/
# Environment
.env
.env.local
.env.*.local
# Database
*.sql
*.sqlite
*.db
# Temporary files
tmp/
temp/
*.tmp
# OS
.DS_Store
Thumbs.db
# OpenCart specific
src/upload/system/
src/upload/image/cache/
src/storage/
# Test fixtures (large files)
tests/fixtures/*.sql
tests/fixtures/*.json
# Documentation builds
docs/_build/
# Coverage reports
coverage/
.nyc_output/
# Docker
docker-compose.override.yml

221
.github/workflows/main.yaml vendored Normal file
View File

@@ -0,0 +1,221 @@
name: Telegram Mini App Shop Builder
on:
push:
branches:
- master
- 'issue/**'
- develop
pull_request:
types:
- opened
- synchronize
- reopened
permissions:
contents: write
jobs:
version_meta:
name: Compute version metadata
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.meta.outputs.tag }}
filename: ${{ steps.meta.outputs.filename }}
is_release: ${{ steps.meta.outputs.is_release }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract tag and set filename
id: meta
run: |
set -euo pipefail
RELEASE_TAG=$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true)
if [ -n "$RELEASE_TAG" ]; then
echo "Это полноценный релиз"
TAG="$RELEASE_TAG"
FILENAME="oc_telegram_shop_${TAG}.ocmod.zip"
IS_RELEASE=true
else
echo "Это dev-сборка"
LAST_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true)
[ -z "$LAST_TAG" ] && LAST_TAG="v0.0.0"
SHORT_SHA=$(git rev-parse --short=7 HEAD)
DATE=$(date +%Y%m%d%H%M)
TAG="${LAST_TAG}-dev.${DATE}+${SHORT_SHA}"
FILENAME="oc_telegram_shop_${TAG}.ocmod.zip"
IS_RELEASE=false
fi
echo "is_release=$IS_RELEASE" >> $GITHUB_OUTPUT
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "filename=$FILENAME" >> $GITHUB_OUTPUT
test_frontend:
name: Run Frontend tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
- name: Install dependencies
working-directory: frontend/spa
run: npm install
- name: Run tests
working-directory: frontend/spa
env:
APP_ENV: testing
run: npm run test
test_backend:
name: Run Backend tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP 7.4
uses: shivammathur/setup-php@v2
with:
php-version: '7.4'
tools: composer
extensions: mbstring
- name: Install Composer dependencies
working-directory: module/oc_telegram_shop/upload/oc_telegram_shop
run: composer install --no-progress --no-interaction
- name: Run tests
working-directory: module/oc_telegram_shop/upload/oc_telegram_shop
env:
APP_ENV: testing
run: ./vendor/bin/phpunit --testdox tests/Unit tests/Telegram
- name: Static Analyzer
working-directory: module/oc_telegram_shop/upload/oc_telegram_shop
run: ./vendor/bin/phpstan analyse
phpcs:
name: Run PHP_CodeSniffer
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP 7.4
uses: shivammathur/setup-php@v2
with:
php-version: '7.4'
tools: phpcs
- name: Run PHP_CodeSniffer
working-directory: module/oc_telegram_shop/upload/oc_telegram_shop
run: phpcs --standard=PSR12 bastion framework src
module-build:
name: Build module.
runs-on: ubuntu-latest
needs: version_meta
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
- name: Setup PHP 7.4
uses: shivammathur/setup-php@v2
with:
php-version: '7.4'
tools: composer
- name: Write version.txt
run: |
set -euo pipefail
MODULE_ROOT="module/oc_telegram_shop/upload/oc_telegram_shop"
echo "${{ needs.version_meta.outputs.tag }}" > "${MODULE_ROOT}/version.txt"
- name: Build module
run: |
bash scripts/ci/build.sh "${GITHUB_WORKSPACE}"
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: oc_telegram_shop.ocmod.zip
path: ./build/oc_telegram_shop.ocmod.zip
retention-days: 1
release:
runs-on: ubuntu-latest
needs: [ version_meta, test_frontend, test_backend, module-build ]
if: github.ref == 'refs/heads/master' || github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: oc_telegram_shop.ocmod.zip
path: ./build
- name: Rename artifact file
run: mv ./build/oc_telegram_shop.ocmod.zip ./build/${{ needs.version_meta.outputs.filename }}
- name: Delete existing GitHub release and tag
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG=${{ needs.version_meta.outputs.tag }}
echo "⛔ Deleting existing release and tag (if any): $TAG"
gh release delete "$TAG" --cleanup-tag --yes || true
git push origin ":refs/tags/$TAG" || true
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
draft: ${{ needs.version_meta.outputs.is_release == 'false' }}
tag_name: ${{ needs.version_meta.outputs.tag }}
files: ./build/${{ needs.version_meta.outputs.filename }}
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Delete draft releases older than 7 days
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const daysToKeep = 7;
const cutoffDate = new Date(Date.now() - daysToKeep * 24 * 60 * 60 * 1000);
const releases = await github.rest.repos.listReleases({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100
});
for (const release of releases.data) {
if (release.draft) {
const created = new Date(release.created_at);
if (created < cutoffDate) {
console.log(`Deleting draft release: ${release.name || release.tag_name} (${release.id})`);
await github.rest.repos.deleteRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: release.id
});
try {
await github.rest.git.deleteRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${release.tag_name}`
});
console.log(`Deleted tag: ${release.tag_name}`);
} catch (err) {
console.log(`Tag ${release.tag_name} not found or already deleted.`);
}
}
}
}

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
src/*
frontend/spa/node_modules
module/oc_telegram_shop/upload/oc_telegram_shop/vendor
module/oc_telegram_shop/upload/image
module/oc_telegram_shop/upload/oc_telegram_shop/.phpunit.cache
module/oc_telegram_shop/upload/oc_telegram_shop/.env

543
CHANGELOG.md Normal file
View File

@@ -0,0 +1,543 @@
<!--- BEGIN HEADER -->
# Changelog
All notable changes to this project will be documented in this file.
<!--- END HEADER -->
## [2.2.1](https://github.com/megapay-labs/megapay/compare/v2.2.0...v2.2.1) (2026-02-22)
---
## [2.2.0](https://github.com/megapay-labs/megapay/compare/v2.1.0...v2.2.0) (2026-01-09)
### Features
* Add BETA label and UI improvements for MegaPay Pulse tab ([551c4a](https://github.com/megapay-labs/megapay/commit/551c4a3506ddedb1b11851e3d3cbcb4f3ed34e03))
* Add cache:clear CLI command for module cache clearing (#46) ([3d0a75](https://github.com/megapay-labs/megapay/commit/3d0a7536a64bc88dbb349a9640260757b46009c4))
* Add changelog ([bf99bf](https://github.com/megapay-labs/megapay/commit/bf99bfe8a442c8eaad64f348792b7ddcbfb4486c))
* Add config redis cache, categories cache (#44) ([0798f5](https://github.com/megapay-labs/megapay/commit/0798f5c3e98721efbb45e2988350364b606622cd))
* Add customer account page with profile information and actions ([ad94af](https://github.com/megapay-labs/megapay/commit/ad94afda6826dd1d120599353121bad000b675a7))
* Add customizable text for manager contact button ([0a7877](https://github.com/megapay-labs/megapay/commit/0a7877ddbe7908d6a17089e1005308a945d3d21f))
* Add haptic feedback toggle setting ([afade8](https://github.com/megapay-labs/megapay/commit/afade85d004872d10929119db3ac95ee3acd0251))
* Add product interaction mode selector with three scenarios ([ecf4df](https://github.com/megapay-labs/megapay/commit/ecf4df363d49bf0d8bdc1b9ca3a241f99f26cfb8))
* Add store_id conditions (#43) ([846418](https://github.com/megapay-labs/megapay/commit/84641868e98786264f517865d48619ac4bc1ef7a))
* Add system information drawer (#44) ([9da605](https://github.com/megapay-labs/megapay/commit/9da605b9eac82045b46c3edbb998d28a63c22188))
* Increase dock icons size and add click animation ([ce2ea9](https://github.com/megapay-labs/megapay/commit/ce2ea9dea1fcd24d70ea66345d761a739d25f9d1))
##### Admin
* Improve navigation UI and move logs to drawer ([6a635e](https://github.com/megapay-labs/megapay/commit/6a635e189614c50c8d4bdbeb2486eb5b32ba7da0))
##### Search
* Improvement search cache (#44) ([8a9bac](https://github.com/megapay-labs/megapay/commit/8a9bac8221146b5db4960cc10de3e29dcd75c9bf))
##### Spa
* Add UTM markers for product view on OpenCart (#47) ([647e20](https://github.com/megapay-labs/megapay/commit/647e20c6b093f3de5e6aafcad474cf1a99189d2e))
### Bug Fixes
* Correct external .env loading ([089b68](https://github.com/megapay-labs/megapay/commit/089b68672262286f4568a6a40627a0c2e0c51b14))
* Correctly work with megapay customers without usernames ([0312b8](https://github.com/megapay-labs/megapay/commit/0312b882e1ad5596e823943924d1b284d5592b14))
* Missing store_id for carousel products ([3a1f8d](https://github.com/megapay-labs/megapay/commit/3a1f8dbf948c65c6f2d94392c277317f1ce5da75))
##### Admin
* Correct logs sorting by datetime with milliseconds ([115c13](https://github.com/megapay-labs/megapay/commit/115c13393f045a8f2eb7992be11ce20e94b23b96))
##### Spa
* Correct line breaks for long attribute names and values in Product.vue ([ff7263](https://github.com/megapay-labs/megapay/commit/ff7263649c208449f0b0b65df3e6088115ec78f6))
* Correct privacy policy message margin ([79f234](https://github.com/megapay-labs/megapay/commit/79f23400d20ba3cb43160cf898ea589f30c7aa83))
---
## [2.1.0](https://github.com/megapay-labs/megapay/compare/v2.0.0...v2.1.0) (2025-12-24)
### Features
* Add setting to control category products button visibility ([c3994b](https://github.com/megapay-labs/megapay/commit/c3994b2291790f21cd219d1c5e820c274cb6e085))
---
## [2.0.0](https://github.com/megapay-labs/megapay/compare/v1.3.2...v2.0.0) (2025-12-23)
### ⚠ BREAKING CHANGES
* None ([9a93cc](https://github.com/megapay-labs/megapay/commit/9a93cc73421c9c85e3cfbe403cd2c8fb41ba3406))
### Features
* Add aspect ratio selector for products_carousel ([615e8c](https://github.com/megapay-labs/megapay/commit/615e8c54a60d076a65bc04e60d26c5cbb21c264f))
* Add cron service to run megapay schedule tasks ([16a258](https://github.com/megapay-labs/megapay/commit/16a258ab682947f9856459797bc99b0adbf0d335))
* Add debug mode for developers. Logs improvements ([fbccd5](https://github.com/megapay-labs/megapay/commit/fbccd506752e8cdada461e92a85b7603335a8f23))
* Add default configs ([2bc751](https://github.com/megapay-labs/megapay/commit/2bc751119cb5c55c7d29a90d28a24f015ba76692))
* Added new products_carousel bock type ([f0837e](https://github.com/megapay-labs/megapay/commit/f0837e5c94ef3327f0d249e1994dc73a6da1c42b))
* Add FormKit framework support and update dependencies ([6a59dc](https://github.com/megapay-labs/megapay/commit/6a59dcc0c9b4f8e6ee003c7e168b632d8199981e))
* Add hide keyboard button on search page ([17ff88](https://github.com/megapay-labs/megapay/commit/17ff888c053983a7ae334ba695338ccd8b2db3ab))
* Add html editor for telegram messages ([97df5b](https://github.com/megapay-labs/megapay/commit/97df5b4c0aa1d5fbf19c2132436045af0846b5f1))
* Add italy dump ([13f63e](https://github.com/megapay-labs/megapay/commit/13f63e09fcc3c33cb4de2e809a981a0bf532bb63))
* Add migrations, mantenance tasks, database cache, blocks cache ([c0a6cb](https://github.com/megapay-labs/megapay/commit/c0a6cb17b3fa5a75185ad2e42e8979b1c848c285))
* Add old browser checks ([76c32c](https://github.com/megapay-labs/megapay/commit/76c32c53200f33a5de8fee3587b6aa597ce6d04a))
* Add options to select aspect ratio and cron algo for product images ([e9c6ed](https://github.com/megapay-labs/megapay/commit/e9c6ed8ddf801d3cfbb91c08733ab118fec3de21))
* Add reactivity to formkit ([fdcfce](https://github.com/megapay-labs/megapay/commit/fdcfce0a79af94f5f7ff05e19b4edec0fad4d452))
* Add redis cache driver ([2b0f04](https://github.com/megapay-labs/megapay/commit/2b0f04eb9455e2f1abb5b9374f3348072ffd1d6a))
* Add scheduler module ([65973d](https://github.com/megapay-labs/megapay/commit/65973d2d79a8c6bfbfc367b56a2b83e465fa2e32))
* Add MegaPay Pulse heartbeat telemetry ([b60c77](https://github.com/megapay-labs/megapay/commit/b60c77e4539aab9d2cdb1e9916b7e60c9848d686))
* Add MegaPayPulse telemetry system and ETL endpoints ([e8d0f8](https://github.com/megapay-labs/megapay/commit/e8d0f8a8190c2877ac5aa1e0cc7a5a1663598fe5))
* Add Telegram customers management system with admin panel ([9a93cc](https://github.com/megapay-labs/megapay/commit/9a93cc73421c9c85e3cfbe403cd2c8fb41ba3406))
* Add texts configuration ([34dfe9](https://github.com/megapay-labs/megapay/commit/34dfe9028693ad488d40f2015af482d789f012c6))
* Add UI for CRON Scheduler ([7372b9](https://github.com/megapay-labs/megapay/commit/7372b9c330ba4ba83458ca8d722cc71f57316180))
* Add warmup images command ([ecd372](https://github.com/megapay-labs/megapay/commit/ecd372dad30e05c5913fa489e561475584b89079))
* Better algorythm for image resize ([13e5bc](https://github.com/megapay-labs/megapay/commit/13e5bce8a548439da3dcd892b0c5600ffc995be6))
* Button to show all products from category ([b2d29f](https://github.com/megapay-labs/megapay/commit/b2d29fd3e288991f77ba6c0bee4bc7c5092b6594))
* Change image crop algorythm for product view page ([262f52](https://github.com/megapay-labs/megapay/commit/262f52929063802404af6f0592741ca836c91bcd))
* Clear cache after settings update ([6f9855](https://github.com/megapay-labs/megapay/commit/6f9855995dd3603b622a9e601162ac0b6da9a694))
* Correct stats for megapay dashboard ([05af49](https://github.com/megapay-labs/megapay/commit/05af4949bfcf2a42ece30f1d77816a3c3018eae2))
* Design update, show avatar in navbar ([6ac6a4](https://github.com/megapay-labs/megapay/commit/6ac6a42e2105bb6f234c108e3a5d21096b87660f))
* Disable source maps for frontend production builds ([770ec8](https://github.com/megapay-labs/megapay/commit/770ec81fdcd1456ad7787c9d1e31d92383849f8f))
* Dont migrate tg messages from v1 ([b87797](https://github.com/megapay-labs/megapay/commit/b87797ee6728523d8e17eeab13b85b014c157d95))
* Expose module version ([f1a39e](https://github.com/megapay-labs/megapay/commit/f1a39eeb0023d9fdf99cfd95b21288d950730b23))
* Fixed width and preloader for product view page ([5d775e](https://github.com/megapay-labs/megapay/commit/5d775e8eb6710cc1ca3501be1fdfc957582e8663))
* Fix opecart module status, remove .vite ([e72948](https://github.com/megapay-labs/megapay/commit/e729484fd7a698fcadacdfc99a30beb9d4acbb09))
* Hide greeting image from frontend ([2ec683](https://github.com/megapay-labs/megapay/commit/2ec683f0163804e7562de14d098b8a0c0f0f28da))
* Image processing improve ([38668f](https://github.com/megapay-labs/megapay/commit/38668fb4a7f2a3f94a85e06e20ecdff98f5d160d))
* Images and products loading optimization ([bf6744](https://github.com/megapay-labs/megapay/commit/bf674473e97111aa7a2acad9c835fcea37c3b2ec))
* Improve mainpage ui/ux ([f5d9d4](https://github.com/megapay-labs/megapay/commit/f5d9d417b3b86c7b710da5751a1d50af10a42b6e))
* Increase default per_page products ([6ed2fd](https://github.com/megapay-labs/megapay/commit/6ed2fd2062295bc4296d9ef5c4852541e0e4138f))
* Integrate yandex metrika ecommerce ([2f74ab](https://github.com/megapay-labs/megapay/commit/2f74aba35f548d632beed65d81693483942289d5))
* Maintenance tasks, logs ([ae9771](https://github.com/megapay-labs/megapay/commit/ae9771dec436bd3ff619b26c9c6ce811b1e876dd))
* More fluent vuejs app error handler ([955747](https://github.com/megapay-labs/megapay/commit/955747334d7a7f4863e145f52bcc2864beb8818e))
* Move getImage response to admin ([f539bb](https://github.com/megapay-labs/megapay/commit/f539bbfbbf023995f88b684406ac5eb8f16fff66))
* New settings and mainpage blocks ([6176c7](https://github.com/megapay-labs/megapay/commit/6176c720b1f4c0ce9f06a3cc4ff50b72a52ab0fb))
* Provide current opencart timezone to App ([51f462](https://github.com/megapay-labs/megapay/commit/51f462922ec49c8cc5e1b0c7909a69180cbe8e72))
* Remove unused js libs ([08f0e2](https://github.com/megapay-labs/megapay/commit/08f0e24859c4e201e85075f2186ed741e3180b38))
* Send xdebug trigger from frontend ([2743b8](https://github.com/megapay-labs/megapay/commit/2743b83a2c624191d2b65a1b13f5b3645e69b71a))
* Separated coupon and voucher errors ([dd12cb](https://github.com/megapay-labs/megapay/commit/dd12cb8c3434cd3d6f3b8eed4e469db8cd02e3f5))
* Set environment variables ([3716e8](https://github.com/megapay-labs/megapay/commit/3716e89811f2a4135d644cb5a6bae0bb57c367ee))
* Show module version in admin ([116821](https://github.com/megapay-labs/megapay/commit/116821a20946bf3f341e1589af2b24ace1e904da))
* Store customer_id in with order ([8260d2](https://github.com/megapay-labs/megapay/commit/8260d2bc96bfb256e73673e13740b242756eede2))
* Tg bot start message customization ([152e6d](https://github.com/megapay-labs/megapay/commit/152e6d715bfff1cfd05bdab72c4d4b54f7878e4a))
* Track and push MegaPay Pulse events ([ef7856](https://github.com/megapay-labs/megapay/commit/ef785654b969e7abc955ed452d8367d6cf3aa55e))
* UI/UX, add reset cache to admin ([09f1e5](https://github.com/megapay-labs/megapay/commit/09f1e514a975fea5c4fcd3b8cc587f906ab30bd3))
* Update admin page ([cd818d](https://github.com/megapay-labs/megapay/commit/cd818d3356d5738a9fb534e056d2e1055b2016ce))
* Update design for product and product cards ([8a777c](https://github.com/megapay-labs/megapay/commit/8a777cd4d280b7049b60fdd0d3fa0586561e0a65))
* Update product page design ([c64170](https://github.com/megapay-labs/megapay/commit/c64170f2d8058d99ae60323d907579b59566c119))
* Update readme ([5fb450](https://github.com/megapay-labs/megapay/commit/5fb45000ac77de0019a256150089256d5c423d68), [540595](https://github.com/megapay-labs/megapay/commit/540595c9f0661a2ca4c16d8876be54f9258bc0a3), [1361fe](https://github.com/megapay-labs/megapay/commit/1361fea993bcc37b0495b8fff7c20a45ccbd8ca2))
* Update styles for swipe to back ([e6a9e6](https://github.com/megapay-labs/megapay/commit/e6a9e6797f518d27caba507ac79d07ac8c113b06))
* Use yaMetrika number in settings ([cedc49](https://github.com/megapay-labs/megapay/commit/cedc49f0d5c3107791c1e6ff87a2f024a8baf828))
* Visualize swipe back ([50bdb8](https://github.com/megapay-labs/megapay/commit/50bdb8601c04799a4ecdb1b854ee1151a02f00f1))
* WIP add yandex metrika goals ([4e59c4](https://github.com/megapay-labs/megapay/commit/4e59c4e7888925a87ce63eb53587d5e21fec4561))
* добавлена функциональность политики конфиденциальности и согласия на обработку ПД ([7a5eeb](https://github.com/megapay-labs/megapay/commit/7a5eebec91ee73a2d38509cfa4f9bbb87cb75225))
* добавлен жест swipe back для навигации назад ([179729](https://github.com/megapay-labs/megapay/commit/17972993ca815072ad5ded2bbc7a29e97f1abc6f))
##### Admin
* Add more details for admin errors ([17865d](https://github.com/megapay-labs/megapay/commit/17865d8af4ed4b7f1f02a5b065847281fa5ede5f))
* Refactor logs viewer with table display and detailed dialog ([b39a34](https://github.com/megapay-labs/megapay/commit/b39a344a7dac32225d6fe939ea81fcc67f4b5750))
* Remove legacy setting keys that not defined in defaults ([107741](https://github.com/megapay-labs/megapay/commit/1077417d717cbd601bdff82ab3dfbb61402c3640))
##### Banner
* Add banner feature ([05e7ca](https://github.com/megapay-labs/megapay/commit/05e7cafd0f36b204e0dea51a0f46f1a2c795dceb))
##### Customers
* Track order meta and OC sync ([952d8e](https://github.com/megapay-labs/megapay/commit/952d8e58da2972ff834d7f6609749b6dbd15a938))
##### Products-feed
* Replace fixed image dimensions with aspect ratio selection ([cd0606](https://github.com/megapay-labs/megapay/commit/cd060610fe991c7c6d0db81a24bfa2b062192d20))
##### Pulse
* Implement reliable event tracking and delivery system ([4a3dcc](https://github.com/megapay-labs/megapay/commit/4a3dcc11d161420c58494d744909f48982bd2582))
##### Search
* Add keyboard hide button and auto-hide Dock ([db8d13](https://github.com/megapay-labs/megapay/commit/db8d1360fc9d8702fa7f2607337ac447ea646c5d))
* Improve search UI with sticky bar and keyboard handling ([64ead2](https://github.com/megapay-labs/megapay/commit/64ead29583086dc55ae59e5d2b775dae31f36944))
##### Slider
* Add slider feature ([3049bd](https://github.com/megapay-labs/megapay/commit/3049bd3101a44259f2883b351244c6eb5564cf89))
##### Spa
* Add custom dock ([4936e6](https://github.com/megapay-labs/megapay/commit/4936e6f16c0cd44299d086911a347cd3626fa2af))
* Add dock ([2e699e](https://github.com/megapay-labs/megapay/commit/2e699eb0d6aca08d3f87030ea822c1fc79d3d477))
* Correct radius for floating panel, small ui fixes ([72ab84](https://github.com/megapay-labs/megapay/commit/72ab842a95f090b886787f551bee274fc2f6932c))
* Show navbar with app logo and app name ([c3c0d6](https://github.com/megapay-labs/megapay/commit/c3c0d6d2c179c83a1700d773c496ff7a44cce99c))
* UI changes ([ed8592](https://github.com/megapay-labs/megapay/commit/ed8592c19dabf4f26d6ed45e55a3c6f7398d667e))
##### Megapay
* Add vouchers and coupons (#9) ([ac24f0](https://github.com/megapay-labs/megapay/commit/ac24f0376bee13cc14db49a2904867ef173dcf95))
##### Texts
* Add options to redefine text for zero product prices ([1fbbb7](https://github.com/megapay-labs/megapay/commit/1fbbb7b6db13a9dac745c32d11f2e71ed79e854e))
##### Ya metrika
* WIP yandex metrika ([d7666f](https://github.com/megapay-labs/megapay/commit/d7666f94ba22fc1a808299e9a91ead14e6b58b25))
### Bug Fixes
* Admin mainpage builder drawer doesnot show ([ad54b1](https://github.com/megapay-labs/megapay/commit/ad54b14c6804fae8960a5e15dbceb0549d91c732))
* Base header color ([28d80d](https://github.com/megapay-labs/megapay/commit/28d80d0f19ee31fea5011c2b466edea1590ab71e))
* Browser check ([4cd49b](https://github.com/megapay-labs/megapay/commit/4cd49b17a6df65863dc9fd32efdd0bba4b4e44ff))
* Center image on product view ([dc198c](https://github.com/megapay-labs/megapay/commit/dc198c63b7c4f66b92eeb55958983d8eaed0260f))
* Correct cli.php path for phar ([57c840](https://github.com/megapay-labs/megapay/commit/57c8400904b74569c843cd898fe6c39552f91e6b))
* Correct counter id for yandex metrika test ([9870f2](https://github.com/megapay-labs/megapay/commit/9870f2f36364ec7d968b3aec14091aefae774199))
* Correct crontab line ([613ce5](https://github.com/megapay-labs/megapay/commit/613ce520ee53be47ee06e101daf54c8f5136184b))
* Correct path for cron ([185f30](https://github.com/megapay-labs/megapay/commit/185f3096e1e17507f4191104794991448d4d44bb))
* Correct url for hit ([515b82](https://github.com/megapay-labs/megapay/commit/515b82302ba603f61324ace576e23adbf82560fd))
* Disable fullscreen for desktop ([bf32d9](https://github.com/megapay-labs/megapay/commit/bf32d9081169206cef62d92f27338321d1cc1e69))
* Fix dock layout ([bdbdfc](https://github.com/megapay-labs/megapay/commit/bdbdfc3650ff24e77f7f35059ac72e87cd02ddf2))
* Fix errors and small improvements ([3b2e2c](https://github.com/megapay-labs/megapay/commit/3b2e2cb656bb8db6feebdbb23612202f96cdde3f))
* Fix search issues ([2f9a55](https://github.com/megapay-labs/megapay/commit/2f9a553ae356fe4fb7ee3a481010d14be7d94ad7))
* Fix type error ([836161](https://github.com/megapay-labs/megapay/commit/8361616dd647397777849fd87267134e0bc1fb9b))
* Glob not work with phar ([24db69](https://github.com/megapay-labs/megapay/commit/24db69fbbad6758d11dabdac54f075611cde9593))
* Grant +x permissions for cli.php ([0ee3b7](https://github.com/megapay-labs/megapay/commit/0ee3b7d091da970d19a755207c73c28689bfd2a4))
* Handle missing tags in workflow ([bc50cf](https://github.com/megapay-labs/megapay/commit/bc50cf064854ad0597f1d7a39b0eb32d88d2598a))
* Image picker component name type ([30b010](https://github.com/megapay-labs/megapay/commit/30b0108fe78b2a594db0c749f563577921c189d0))
* Many products in search ([a5e91d](https://github.com/megapay-labs/megapay/commit/a5e91dd488b1f13abf739797e75400ddf36ba7e1))
* Order creation ([82ab81](https://github.com/megapay-labs/megapay/commit/82ab8134e19f2cc4066de5241e7ff29905d79b17))
* Pulse ingest ([95dd54](https://github.com/megapay-labs/megapay/commit/95dd545dc5718046cd421d70ad2d4ea137919852))
* Scroll behaviour ([359395](https://github.com/megapay-labs/megapay/commit/359395b7e880d72dd34da504f6d9fe001d6f0aff))
* Search ([e5792a](https://github.com/megapay-labs/megapay/commit/e5792a059a0986b6d6c86df9dbcbda212bc0f548))
* Settings numeric error ([44d2af](https://github.com/megapay-labs/megapay/commit/44d2af3b30a7133b550e56385c3096c0e8848df5))
* Store error ([ab5c2f](https://github.com/megapay-labs/megapay/commit/ab5c2f42b907d19f0c52c631cea02b981a199c39))
* Switch between code and visual for custom forms ([0ab09a](https://github.com/megapay-labs/megapay/commit/0ab09aad10eb724cf0378bcc2f46001b5108fade))
* Test ([c4b192](https://github.com/megapay-labs/megapay/commit/c4b19286f36ad166a1092dda86e65d48e3390723))
* Use html for tg bot ([7e6502](https://github.com/megapay-labs/megapay/commit/7e6502b07e74e27e27326c9593f25c2c9c03418b))
##### Admin
* Fix error when chat_id is string ([8f6af0](https://github.com/megapay-labs/megapay/commit/8f6af04e732f853eb79504676dc5ae83ba151c93))
##### Spa
* Remove html in price for some opencart custom themes ([3423dd](https://github.com/megapay-labs/megapay/commit/3423dd172748845ce5177ea6bf5894a6da977c37), [d6a436](https://github.com/megapay-labs/megapay/commit/d6a43605acaff1cf335dc044e2b297132d6eb2ce))
##### Megapay
* Fix products search ([98ee6d](https://github.com/megapay-labs/megapay/commit/98ee6d9ecac4349cad847bdba1b10cf8660c251f))
---
## [1.3.2](https://github.com/megapay-labs/megapay/compare/v1.3.1...v1.3.2) (2025-10-24)
### Bug Fixes
##### Products
* Encode html for title on products page ([78ca4f](https://github.com/megapay-labs/megapay/commit/78ca4fd309e2254771a01ade75197d46e149c5f3))
---
## [1.3.1](https://github.com/megapay-labs/megapay/compare/v1.3.0...v1.3.1) (2025-10-19)
### Bug Fixes
##### App
* Fix unhandled exceptions ([aa4264](https://github.com/megapay-labs/megapay/commit/aa42643c34c1a7cb11aae2d3191ac63c0af3236a))
---
## [1.3.0](https://github.com/megapay-labs/megapay/compare/v1.2.0...v1.3.0) (2025-10-19)
### Features
* Add filters to mainpage ([1e2a9b](https://github.com/megapay-labs/megapay/commit/1e2a9bc7051e14c65eb44b392dba11f766b95d33))
* Handle start command for megapay bot ([c936d7](https://github.com/megapay-labs/megapay/commit/c936d727b495b06f63d7f15949d540f2c9a2b9c0))
##### Admin
* Do not log assets cleanup message if nothing deleted ([00165b](https://github.com/megapay-labs/megapay/commit/00165b3b61841303a6eff0447ee134d860f4a8b9))
* Remove old assets ([01368b](https://github.com/megapay-labs/megapay/commit/01368bbfce831cd8949500ffdf5f5e4614316459))
* Remove old maps ([31a990](https://github.com/megapay-labs/megapay/commit/31a9909cc37c953113c743d248c9dd4065f89acb))
##### Bot
* Add bot commands ([023ace](https://github.com/megapay-labs/megapay/commit/023acee68fb8f247a5e84f62aade44c77cfc0ed5))
##### Filters
* Add filters for the main page ([e7e045](https://github.com/megapay-labs/megapay/commit/e7e045b695d227d2d895242b4c0f19883d07f69e))
##### Spa
* Hide floating cart btn for filters page ([259154](https://github.com/megapay-labs/megapay/commit/259154e4f1ca2ac0e2e6a357e8a53be78a00441a))
* Lock vertical orientation ([646721](https://github.com/megapay-labs/megapay/commit/6467216775c44a7f4cc924d4551cc88ca246b757))
* Update Telegram Mini App to 59 version ([3ecb51](https://github.com/megapay-labs/megapay/commit/3ecb51b5cd1751f4e2ace73171225ee3a33e46c4))
### Bug Fixes
* Escape character for start message command ([a051ff](https://github.com/megapay-labs/megapay/commit/a051ff545e920760e3a0e6c34ef3cc94a0c1bfdb))
---
## [1.2.0](https://github.com/megapay-labs/megapay/compare/v1.1.0...v1.2.0) (2025-09-27)
### Features
##### Product
* Add option to disable store feature ([d7dd05](https://github.com/megapay-labs/megapay/commit/d7dd055e245a5bb0772b382ca8542394e92fecd5))
### Bug Fixes
* Correct update opencart config after defaults diff update ([e24e7c](https://github.com/megapay-labs/megapay/commit/e24e7c6d106597c627451abf8014723f42fdda34))
---
## [1.1.0](https://github.com/megapay-labs/megapay/compare/v1.0.7...v1.1.0) (2025-09-26)
---
## [1.0.7](https://github.com/megapay-labs/megapay/compare/v1.0.6...v1.0.7) (2025-09-26)
### Features
##### Categories
* Added animations for categories list ([b7b255](https://github.com/megapay-labs/megapay/commit/b7b255887db2d04b8ba70a966382f44c92475df0))
* Add skeleton for categories loading ([294e0c](https://github.com/megapay-labs/megapay/commit/294e0cd17e2038f3088b504331ad9b009129e8ed))
* Hide button from categories ([f06606](https://github.com/megapay-labs/megapay/commit/f066069a1b6cf186046e272bc7af61ab46f79c0e))
##### Design
* Add safe top padding for product page ([a3e5b8](https://github.com/megapay-labs/megapay/commit/a3e5b8b07a28813115662b566284f8622f0b3722))
* Product link in cart ([39a350](https://github.com/megapay-labs/megapay/commit/39a350d517d5d762720236f0e9b682299fd2b746))
##### Products
* Show correct product prices ([35dd0d](https://github.com/megapay-labs/megapay/commit/35dd0de261a4497c01cd6eb54ed0d7032cea5f8b))
### Bug Fixes
##### Product
* Decode html entities for product and category names ([acbfae](https://github.com/megapay-labs/megapay/commit/acbfaebcf415f42c6fb16c6a39d5e10f0776da90))
* Fix error when image not found ([a381b3](https://github.com/megapay-labs/megapay/commit/a381b3a6ee6972775815db382269ec8ab3d31a4f))
* Fix select product option UI ([22a783](https://github.com/megapay-labs/megapay/commit/22a783f0ef833f5797e798222dce65493d71b34b))
---
## [1.0.6](https://github.com/megapay-labs/megapay/compare/v1.0.5...v1.0.6) (2025-09-24)
### Bug Fixes
* Fix possible foreign error message on megapay page ([016eeb](https://github.com/megapay-labs/megapay/commit/016eeb445db7ce692825d323bed7c1dd815e30af))
##### Categories
* Fix nested lvl > 2 categories rendering ([0f04cb](https://github.com/megapay-labs/megapay/commit/0f04cbf105252b88358095ae5be33fedca6f1e63))
* Increase max categories count to display up to 100 ([9f6416](https://github.com/megapay-labs/megapay/commit/9f6416a1b7b7f065b558ecd3089c42ef397bd817))
##### Database
* Fix db connection error when not standard mysql port ([ec5cdf](https://github.com/megapay-labs/megapay/commit/ec5cdfcaa9321cb824c858df91ce4464d6158a2c))
---
## [1.0.5](https://github.com/megapay-labs/megapay/compare/v1.0.4...v1.0.5) (2025-09-24)
### Features
##### Categories
* Add options to select what categories to show on front page ([9e4022](https://github.com/megapay-labs/megapay/commit/9e4022f64856082fffa7a0264949373319cdf9ff))
---
## [1.0.4](https://github.com/megapay-labs/megapay/compare/v1.0.3...v1.0.4) (2025-09-24)
### Bug Fixes
* Error when category doesnt have image ([490cbf](https://github.com/megapay-labs/megapay/commit/490cbfacf72095001dccaf374034292ea247e21b))
---
## [1.0.3](https://github.com/megapay-labs/megapay/compare/v1.0.2...v1.0.3) (2025-09-24)
### Bug Fixes
* Init exception for some opencart versions ([0cf0c4](https://github.com/megapay-labs/megapay/commit/0cf0c438433f8c1895bef5f490bc0f9af86b0c04))
---
## [1.0.2](https://github.com/megapay-labs/megapay/compare/v1.0.1...v1.0.2) (2025-08-16)
### Bug Fixes
* UI fixes ([854dfd](https://github.com/megapay-labs/megapay/commit/854dfdf7f2dba7bc78b53c19f345c1909298c474))
---
## [1.0.1](https://github.com/megapay-labs/megapay/compare/v1.0.0...v1.0.1) (2025-08-16)
### Bug Fixes
* Check code phrase when configure chat_id ([a0abc1](https://github.com/megapay-labs/megapay/commit/a0abc14c6db91fb6cec14f8aa64297d671e88a7e))
---
## [1.0.0](https://github.com/megapay-labs/megapay/compare/v0.0.2...v1.0.0) (2025-08-16)
### Features
* Add bot_token validation ([d7df5a](https://github.com/megapay-labs/megapay/commit/d7df5a4b5c8abdf5117c07a9bb7fc7744c23eb1d))
* Add carousel for images ([a40089](https://github.com/megapay-labs/megapay/commit/a40089ef553eaf30d813a9e2b2495fe3aa7dd0d4))
* Add Categories ([6a8ea0](https://github.com/megapay-labs/megapay/commit/6a8ea048ea52e6bd3c146b4ec311e9633fce269a))
* Add custom BottomButton instead of TG ([b0cc02](https://github.com/megapay-labs/megapay/commit/b0cc0237af12ea5560835092bb808e4bc742c380))
* Add fullscreen viewer ([4ae8d5](https://github.com/megapay-labs/megapay/commit/4ae8d593280774527fbeda3e52d924bd23a12813))
* Add fulscreen mode, dark mode ([252854](https://github.com/megapay-labs/megapay/commit/252854e67ea93716c271e2e20d25b0d73e24e380))
* Add haptictouch to bottom buttons ([51ce6e](https://github.com/megapay-labs/megapay/commit/51ce6ed959e9b673a0cfd9fac614f743b24d582f))
* Add hero block ([3c819e](https://github.com/megapay-labs/megapay/commit/3c819e6c6cf9d25088c2a8024da13e8e0180bde7))
* Add manufacturer to product view ([b25f6d](https://github.com/megapay-labs/megapay/commit/b25f6d3c7335c42487702aa7fff2c5003fd63046))
* Add new mainpage products options, hide attributes ([d9fd26](https://github.com/megapay-labs/megapay/commit/d9fd26d3541e02d4656d32af547a3e338bbbc4ff))
* Add preloader for product page ([b66a02](https://github.com/megapay-labs/megapay/commit/b66a02fd57a2f0233b37bb76b30a360e71333256))
* Add product view page ([f13e12](https://github.com/megapay-labs/megapay/commit/f13e128d03831598ecd058217a0e8874f0831f75))
* Add telegram api ([b958fe](https://github.com/megapay-labs/megapay/commit/b958feaec751b2e3a4134f925a74c75d5d2d1b42))
* Add telegram safe content area ([1715c0](https://github.com/megapay-labs/megapay/commit/1715c01b1d1b99d4e99a8fe6f40107a384250326))
* Add validation and use opencart logger ([9f35ac](https://github.com/megapay-labs/megapay/commit/9f35acf39935416bfbb35735c3749baf0af20995))
* Allow only vertical orientation ([fe4188](https://github.com/megapay-labs/megapay/commit/fe4188eb8b3d58cb5fa25c267e0e0ba46effbbac))
* Cache frontpage products and categories ([5f785e](https://github.com/megapay-labs/megapay/commit/5f785e82e6689283526dd5a218d76908078e7942))
* Create new order ([c057f4](https://github.com/megapay-labs/megapay/commit/c057f4be76544466af62556237f7031c874f5f51))
* Deny direct access to the spa ([41e74b](https://github.com/megapay-labs/megapay/commit/41e74bad121d76b9a4be2a2f02822d8323e739cc))
* Diplicate webhook info request ([6249b2](https://github.com/megapay-labs/megapay/commit/6249b218a137e105e64fbfb0b6c8829e2ca01349))
* Display product options ([f47bb4](https://github.com/megapay-labs/megapay/commit/f47bb46751fea79e43a96e2b63afde4cb7ef801b))
* Do not check signature if bot token not set ([1d892f](https://github.com/megapay-labs/megapay/commit/1d892f7d090a1ff91f724871e688b18a40df768e))
* Encode images to webp for telegram mini app ([c282b6](https://github.com/megapay-labs/megapay/commit/c282b6ea3b5c04ae92708eb1984ad14d2ea46cfa))
* Expand mini app on mounted ([1e454b](https://github.com/megapay-labs/megapay/commit/1e454b8f2387d9a4e2e4316253d7f8bddadccc1c))
* Fix module name in admin ([9770a0](https://github.com/megapay-labs/megapay/commit/9770a09fc0abe57d7b97137c9fef4bfaf5687278))
* Infinity scroll, load more, resore scroll ([bb2ee3](https://github.com/megapay-labs/megapay/commit/bb2ee38118e8626f8d85070047e256ad8305c1e5))
* Make two columns grid for product list ([34bd64](https://github.com/megapay-labs/megapay/commit/34bd64e9025fbd61cd3c64c1e9a9bebb4bf98e5d))
* Product options, speedup home page, themes ([e3cc0d](https://github.com/megapay-labs/megapay/commit/e3cc0d4b10edf3a7c655a8e6d9a39ca587d6ecbc))
* Remove cache, refactor ([7404ec](https://github.com/megapay-labs/megapay/commit/7404ecb33e1289439a3b4b9b5926175fe5d3872d))
* Remove prefilled fields in checkout ([33b350](https://github.com/megapay-labs/megapay/commit/33b3500aa470438963af90ee2edccdff9a27233d))
* Safe-top and search ([a8bb5e](https://github.com/megapay-labs/megapay/commit/a8bb5eb493ab329bebca8c7903d4facf4a22d76a))
* Search component and loading splashscreen ([2fb841](https://github.com/megapay-labs/megapay/commit/2fb841ef08027eeabdade90d9a4725ea602b3f48))
* Show tg app link ([b1ea16](https://github.com/megapay-labs/megapay/commit/b1ea169e2f83cd3d3108d9d11d2b9bb8ee234211))
* UI changes ([d522cb](https://github.com/megapay-labs/megapay/commit/d522cbef8389adb05cc6e70ed6665db37915233c))
* Ui improvements, show only active products, limit max page for infinity scroll ([d499d7](https://github.com/megapay-labs/megapay/commit/d499d7d846d55cc158306160c51d4b871f5b6376))
* Update styles ([ca3a59](https://github.com/megapay-labs/megapay/commit/ca3a59f43ae19f9c8417993e45c63f29696f46c8))
##### Admin
* Correct getting chat_id ([1e80fd](https://github.com/megapay-labs/megapay/commit/1e80fdb2ebaf47e39a6cbd45438860428146aac6))
* Correct merge new default settings after initializing app ([469077](https://github.com/megapay-labs/megapay/commit/469077d0c9006f3bcfffcecf4454f2e5e4492fac))
* Update disclaimer text ([133bad](https://github.com/megapay-labs/megapay/commit/133badf45b9727fbf2bee7c9b9f74ff274fa3cc8))
##### App
* Add maintenance mode ([2752ec](https://github.com/megapay-labs/megapay/commit/2752ec3dd18261af9894c8a28a6775bdb22301c3))
* Telegram init data signature validator ([350ec4](https://github.com/megapay-labs/megapay/commit/350ec4f64bf6534e57cf613e6b38d39a052fd646))
##### Order
* Add success haptic for order created event ([858be6](https://github.com/megapay-labs/megapay/commit/858be67c89130ab291b34d8bd7fb4340b6fff422))
* Order default status and customer group ([14d42c](https://github.com/megapay-labs/megapay/commit/14d42c6ecb1967cc626c57ae7ccb60f66b361aec))
* Order process enchancements ([85101b](https://github.com/megapay-labs/megapay/commit/85101b988140c1d0114d3176115aab0864011b16))
* WIP: telegram notifications ([454bd3](https://github.com/megapay-labs/megapay/commit/454bd39f1f12a6fa004f80c3b13ebc17032a35f9))
##### Orders
* Tg notifications, ya metrika, meta tags ([86d0fa](https://github.com/megapay-labs/megapay/commit/86d0fa95941fd2b1d491de8280817d0e80b461f2))
##### Product
* Change router history driver, change add to cart behaviour ([ebc352](https://github.com/megapay-labs/megapay/commit/ebc352dcdfcf08694d2590ee94c9e799e795a2fc))
* Display attributes ([63adf9](https://github.com/megapay-labs/megapay/commit/63adf96908137ab0c173415f77278ee7483a2fb8))
##### Shop
* Change grid image resize algorythm ([c3c256](https://github.com/megapay-labs/megapay/commit/c3c25619326e292575236979e389f8ddb68b6958))
##### Style
* Change pagination swiper styles ([50bf90](https://github.com/megapay-labs/megapay/commit/50bf9061be778b37f7f6869f4c39a4833af31b1d))
### Bug Fixes
* Add CORS headers, make ci builds as preleases ([551535](https://github.com/megapay-labs/megapay/commit/55153531fb4899d0f3e699b70231d32290800ee2))
* Add route names ([47bb2c](https://github.com/megapay-labs/megapay/commit/47bb2cae85e9a16b0076898cd6265512c3adfc3c))
* Change hardcoded axios url ([4bb983](https://github.com/megapay-labs/megapay/commit/4bb983e4af53baf2a7a5aa39f15b5389906a4c71))
* Correct back button work ([08af20](https://github.com/megapay-labs/megapay/commit/08af204d7403572dbc45f3a74e13cf5d3d560a42))
* Correct controller class ([5af66d](https://github.com/megapay-labs/megapay/commit/5af66d228a3defbc6f0b4fd15a9e2a3c192bf41d))
* Corrent telegram mini app url in settings ([ea2a60](https://github.com/megapay-labs/megapay/commit/ea2a60b59b20d2bede9d6884349b09e55e345774))
* Exception if no images ([9bcf32](https://github.com/megapay-labs/megapay/commit/9bcf32841ebd4663b5c6bd5e855b18e8cd486e45))
* Fullscreen slide index ([4114c3](https://github.com/megapay-labs/megapay/commit/4114c3366e4090e41e29bf6e48fe5f54d0dd4a9c))
* Glitch ([db24be](https://github.com/megapay-labs/megapay/commit/db24be6f92bbe485985892ea017f4e4ef457cd52))
* Icon error ([19911c](https://github.com/megapay-labs/megapay/commit/19911c8f871e456c51836c3d07add3f066744ace))
* Infinity scroll, init data in base64 ([f2f161](https://github.com/megapay-labs/megapay/commit/f2f1618e0ee591bc58a830a333b1f759b0a860d6))
* Night theme ([06a6dc](https://github.com/megapay-labs/megapay/commit/06a6dca656871a920092dc6767990ab70b9fc6c2))
* Router in opencart ([ad92db](https://github.com/megapay-labs/megapay/commit/ad92dbfad48f993e2393c0e235083614581ae0c6))
* Router scroll scrollBehavior ([08d245](https://github.com/megapay-labs/megapay/commit/08d2453df92ffc89c5e6c4e264370d8b9c32a432))
* Totals ([eb1f1d](https://github.com/megapay-labs/megapay/commit/eb1f1dc9c1de7c4733d0117257f7902f145614b2))
* Watch router ([1ffb1c](https://github.com/megapay-labs/megapay/commit/1ffb1cef12df1bde4330a7c9531b6574a07d2fe6))
##### Admin
* Fix shop url ([c61dfd](https://github.com/megapay-labs/megapay/commit/c61dfd824a532512703c207c464954b51dbcce5a))
---
## [0.0.2](https://github.com/megapay-labs/megapay/compare/v0.0.1+a26c8ba...v0.0.2) (2025-07-10)
### Bug Fixes
* Add CORS headers, make ci builds as preleases ([551535](https://github.com/megapay-labs/megapay/commit/55153531fb4899d0f3e699b70231d32290800ee2))
* Correct controller class ([5af66d](https://github.com/megapay-labs/megapay/commit/5af66d228a3defbc6f0b4fd15a9e2a3c192bf41d))
---
## [0.0.1+a26c8ba](https://github.com/megapay-labs/megapay/compare/v0.0.1...v0.0.1+a26c8ba) (2025-07-10)
### Bug Fixes
* Add CORS headers, make ci builds as preleases ([551535](https://github.com/megapay-labs/megapay/commit/55153531fb4899d0f3e699b70231d32290800ee2))
* Correct controller class ([5af66d](https://github.com/megapay-labs/megapay/commit/5af66d228a3defbc6f0b4fd15a9e2a3c192bf41d))
* Move files to the correct folder ([9735d4](https://github.com/megapay-labs/megapay/commit/9735d48957b7d9947be5a1be18edba8aebc45531))
---
## [0.0.1](https://github.com/megapay-labs/megapay/compare/c3664025ba6b608920a0182799102a207980d7be...v0.0.1) (2025-07-10)
### Features
* WIP ([846fa6](https://github.com/megapay-labs/megapay/commit/846fa64fb4db9760c4264179098c43e7f53b557c))
---

83
Makefile Normal file
View File

@@ -0,0 +1,83 @@
.PHONY: build
fresh:
$(MAKE) stop && \
rm -rf ./src && \
./scripts/download_oc_store.sh && \
./scripts/install_ocstore.sh && \
$(MAKE) start
setup:
$(MAKE) stop && \
rm -rf ./src && \
./scripts/download_oc_store.sh && \
./scripts/install_ocstore.sh && \
$(MAKE) start && \
$(MAKE) link
stop:
docker compose down
start:
docker compose up -d
restart:
docker compose down && docker compose up -d
ssh:
docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash
link:
docker compose exec web bash -c "php ./scripts/link.php"
dev:
$(MAKE) link
@echo "Starting SPA + Admin..."
@make -j2 dev-spa dev-admin
dev-spa:
rm -rf module/oc_telegram_shop/upload/system/library/oc_telegram_shop && \
cd frontend/spa && npm run dev
dev-admin:
rm -rf module/oc_telegram_shop/upload/admin/view/javascript && \
rm -rf module/oc_telegram_shop/upload/system/library/oc_telegram_shop && \
rm -rf src/upload/admin/view/javascript/megapay && \
cd frontend/admin && npm run dev
lint:
docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpstan analyse"
phpcs:
docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpcs --standard=PSR12 bastion framework src"
phpcbf:
docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpcbf --standard=PSR12 bastion framework src"
test:
docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpunit --testdox tests/"
test-integration:
docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpunit --testdox tests/Integration"
test-unit:
docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpunit --testdox tests/Unit"
test-telegram:
docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpunit --testdox tests/Telegram"
test-coverage:
docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpunit --coverage-html coverage tests/"
phar:
docker build -t megapay_local_build -f ./deployment/build.dockerfile . && \
docker run -v "./src/upload/system/library/oc_telegram_shop:/build" megapay_local_build sh -c 'sh /scripts/build_phar.sh'
cli:
docker compose exec -w /module/oc_telegram_shop/upload web bash -c "/usr/local/bin/php cli.php $(ARGS)"
changelog:
php ./module/oc_telegram_shop/upload/oc_telegram_shop/vendor/bin/conventional-changelog
release:
php ./module/oc_telegram_shop/upload/oc_telegram_shop/vendor/bin/conventional-changelog --commit

33
README.md Normal file
View File

@@ -0,0 +1,33 @@
# Demo code for interviewing
This is my personal commercial pet project, so:
1. All sensitive data has been renamed and/or deleted. It can break autoloading and other cases, but the code is ready for reviewing anyway.
2. All git commits history has been squashed to the one by the previous reason.
3. The minimum php version is 7.4. This is requirement by the system (external CMS in which my code integrates). So the main PHP8 features not implemented. But that doesn't mean I don't have the latest PHP versions skills.
# Directory structure
The project has monorepo includes frontend (vuejs) and backend (php).
## Backend structure main points:
```
backend - native php code with my framework.
└── src
├── app - the main application code
├── bastion - the admin panel application code
├── cli.php - cli entrypoint
├── composer.json
├── composer.lock
├── configs
├── console - CLI application
├── database
├── framework - self-written php framework
├── phpstan.neon
├── phpunit.xml
├── stubs
└── tests
```

12
backend/src/.env.example Executable file
View File

@@ -0,0 +1,12 @@
APP_DEBUG=true
PULSE_API_HOST=https://pulse.megapay.pro/api/
PULSE_HEARTBEAT_SECRET=c5261f5d-529e-45ad-a69c-9778b755b7cb
MEGAPAY_CACHE_DRIVER=redis
#MEGAPAY_REDIS_HOST=redis
#MEGAPAY_REDIS_PORT=6379
#MEGAPAY_REDIS_DATABASE=0
SENTRY_ENABLED=false
SENTRY_DSN=
SENTRY_ENABLE_LOGS=false

11
backend/src/.env.production Executable file
View File

@@ -0,0 +1,11 @@
APP_DEBUG=false
PULSE_API_HOST=https://pulse.megapay.pro/api/
PULSE_HEARTBEAT_SECRET=c5261f5d-529e-45ad-a69c-9778b755b7cb
MEGAPAY_CACHE_DRIVER=mysql
MEGAPAY_REDIS_HOST=redis
MEGAPAY_REDIS_PORT=6379
MEGAPAY_REDIS_DATABASE=0
SENTRY_ENABLED=false
SENTRY_DSN=
SENTRY_ENABLE_LOGS=false

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Adapters;
class OcCartAdapter
{
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Adapters;
use ModelCatalogProduct;
use Proxy;
class OcModelCatalogProductAdapter
{
/** @var Proxy|ModelCatalogProduct */
private Proxy $model;
public function __construct($model)
{
$this->model = $model;
}
public function getProductOptions(int $productId): array
{
return $this->model->getProductOptions($productId);
}
/**
* @param int $productId
* @return array|false
*/
public function getProduct(int $productId)
{
return $this->model->getProduct($productId);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App;
use App\ServiceProviders\AppServiceProvider;
use App\ServiceProviders\SettingsServiceProvider;
use Openguru\OpenCartFramework\Application;
use Openguru\OpenCartFramework\Cache\CacheServiceProvider;
use Openguru\OpenCartFramework\ImageTool\ImageToolServiceProvider;
use Openguru\OpenCartFramework\QueryBuilder\QueryBuilderServiceProvider;
use Openguru\OpenCartFramework\Router\RouteServiceProvider;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\MegaPayPulse\MegaPayPulseServiceProvider;
use Openguru\OpenCartFramework\Scheduler\SchedulerServiceProvider;
use Openguru\OpenCartFramework\Telegram\TelegramServiceProvider;
use Openguru\OpenCartFramework\Telegram\TelegramValidateInitDataMiddleware;
use Openguru\OpenCartFramework\Validator\ValidatorServiceProvider;
class ApplicationFactory
{
public static function create(array $config): Application
{
$defaultConfig = require __DIR__ . '/../configs/app.php';
$routes = require __DIR__ . '/routes.php';
return (new Application(Arr::mergeArraysRecursively($defaultConfig, $config)))
->withRoutes(fn() => $routes)
->withServiceProviders([
SettingsServiceProvider::class,
QueryBuilderServiceProvider::class,
CacheServiceProvider::class,
RouteServiceProvider::class,
AppServiceProvider::class,
TelegramServiceProvider::class,
SchedulerServiceProvider::class,
ValidatorServiceProvider::class,
MegaPayPulseServiceProvider::class,
ImageToolServiceProvider::class,
])
->withMiddlewares([
TelegramValidateInitDataMiddleware::class,
]);
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\DTO\Settings;
final class AppDTO
{
private bool $appEnabled;
private string $appName;
private ?string $appIcon;
private string $themeLight;
private string $themeDark;
private bool $appDebug;
private int $languageId;
private string $shopBaseUrl;
private bool $hapticEnabled;
public function __construct(
bool $appEnabled,
string $appName,
?string $appIcon,
string $themeLight,
string $themeDark,
bool $appDebug,
int $languageId,
string $shopBaseUrl,
bool $hapticEnabled = true
) {
$this->appEnabled = $appEnabled;
$this->appName = $appName;
$this->appIcon = $appIcon;
$this->themeLight = $themeLight;
$this->themeDark = $themeDark;
$this->appDebug = $appDebug;
$this->languageId = $languageId;
$this->shopBaseUrl = $shopBaseUrl;
$this->hapticEnabled = $hapticEnabled;
}
public function isAppEnabled(): bool
{
return $this->appEnabled;
}
public function getAppName(): string
{
return $this->appName;
}
public function getAppIcon(): ?string
{
return $this->appIcon;
}
public function getThemeLight(): string
{
return $this->themeLight;
}
public function getThemeDark(): string
{
return $this->themeDark;
}
public function isAppDebug(): bool
{
return $this->appDebug;
}
public function getLanguageId(): int
{
return $this->languageId;
}
public function getShopBaseUrl(): string
{
return $this->shopBaseUrl;
}
public function isHapticEnabled(): bool
{
return $this->hapticEnabled;
}
public function toArray(): array
{
return [
'app_enabled' => $this->isAppEnabled(),
'app_name' => $this->getAppName(),
'app_icon' => $this->getAppIcon(),
'theme_light' => $this->getThemeLight(),
'theme_dark' => $this->getThemeDark(),
'app_debug' => $this->isAppDebug(),
'language_id' => $this->getLanguageId(),
'shop_base_url' => $this->getShopBaseUrl(),
'haptic_enabled' => $this->isHapticEnabled(),
];
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\DTO\Settings;
final class ConfigDTO
{
private AppDTO $app;
private TelegramDTO $telegram;
private MetricsDTO $metrics;
private StoreDTO $store;
private OrdersDTO $orders;
private TextsDTO $texts;
private DatabaseDTO $database;
private LogsDTO $logs;
public function __construct(
AppDTO $app,
TelegramDTO $telegram,
MetricsDTO $metrics,
StoreDTO $store,
OrdersDTO $orders,
TextsDTO $texts,
DatabaseDTO $database,
LogsDTO $logs
) {
$this->app = $app;
$this->telegram = $telegram;
$this->metrics = $metrics;
$this->store = $store;
$this->orders = $orders;
$this->texts = $texts;
$this->database = $database;
$this->logs = $logs;
}
public function getApp(): AppDTO
{
return $this->app;
}
public function getTelegram(): TelegramDTO
{
return $this->telegram;
}
public function getMetrics(): MetricsDTO
{
return $this->metrics;
}
public function getStore(): StoreDTO
{
return $this->store;
}
public function getOrders(): OrdersDTO
{
return $this->orders;
}
public function getTexts(): TextsDTO
{
return $this->texts;
}
public function getDatabase(): DatabaseDTO
{
return $this->database;
}
public function getLogs(): LogsDTO
{
return $this->logs;
}
public function toArray(): array
{
return [
'app' => $this->app->toArray(),
'database' => $this->database->toArray(),
'logs' => $this->logs->toArray(),
'metrics' => $this->metrics->toArray(),
'orders' => $this->orders->toArray(),
'store' => $this->store->toArray(),
'telegram' => $this->telegram->toArray(),
'texts' => $this->texts->toArray(),
];
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\DTO\Settings;
final class DatabaseDTO
{
private string $host;
private string $database;
private string $username;
private string $password;
private string $prefix;
private int $port;
public function __construct(
string $host,
string $database,
string $username,
string $password,
string $prefix,
int $port
) {
$this->host = $host;
$this->database = $database;
$this->username = $username;
$this->password = $password;
$this->prefix = $prefix;
$this->port = $port;
}
public function getHost(): string
{
return $this->host;
}
public function getDatabase(): string
{
return $this->database;
}
public function getUsername(): string
{
return $this->username;
}
public function getPassword(): string
{
return $this->password;
}
public function getPrefix(): string
{
return $this->prefix;
}
public function getPort(): int
{
return $this->port;
}
public function toArray(): array
{
return [
'host' => $this->host,
'database' => $this->database,
'username' => $this->username,
'password' => $this->password,
'prefix' => $this->prefix,
'port' => $this->port,
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\DTO\Settings;
final class LogsDTO
{
private string $path;
public function __construct(string $path)
{
$this->path = $path;
}
public function getPath(): string
{
return $this->path;
}
public function toArray(): array
{
return [
'path' => $this->path,
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\DTO\Settings;
final class MetricsDTO
{
private bool $yandexMetrikaEnabled;
private string $yandexMetrikaCounter;
public function __construct(
bool $yandexMetrikaEnabled,
string $yandexMetrikaCounter
) {
$this->yandexMetrikaEnabled = $yandexMetrikaEnabled;
$this->yandexMetrikaCounter = $yandexMetrikaCounter;
}
public function isYandexMetrikaEnabled(): bool
{
return $this->yandexMetrikaEnabled;
}
public function getYandexMetrikaCounter(): string
{
return $this->yandexMetrikaCounter;
}
public function toArray(): array
{
return [
'yandex_metrika_enabled' => $this->yandexMetrikaEnabled,
'yandex_metrika_counter' => $this->yandexMetrikaCounter,
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\DTO\Settings;
final class OrdersDTO
{
private int $orderDefaultStatusId;
private int $ocCustomerGroupId;
public function __construct(int $orderDefaultStatusId, int $ocCustomerGroupId)
{
$this->orderDefaultStatusId = $orderDefaultStatusId;
$this->ocCustomerGroupId = $ocCustomerGroupId;
}
public function getOrderDefaultStatusId(): int
{
return $this->orderDefaultStatusId;
}
public function getOcCustomerGroupId(): int
{
return $this->ocCustomerGroupId;
}
public function toArray(): array
{
return [
'order_default_status_id' => $this->orderDefaultStatusId,
'oc_customer_group_id' => $this->ocCustomerGroupId,
];
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\DTO\Settings;
final class StoreDTO
{
private bool $featureCoupons;
private bool $featureVouchers;
private bool $showCategoryProductsButton;
private string $productInteractionMode;
private ?string $managerUsername;
private string $ocDefaultCurrency;
private bool $ocConfigTax;
private int $ocStoreId;
public function __construct(
bool $featureCoupons,
bool $featureVouchers,
bool $showCategoryProductsButton,
string $productInteractionMode,
?string $managerUsername,
string $ocDefaultCurrency,
bool $ocConfigTax,
int $ocStoreId
) {
$this->featureCoupons = $featureCoupons;
$this->featureVouchers = $featureVouchers;
$this->showCategoryProductsButton = $showCategoryProductsButton;
$this->productInteractionMode = $productInteractionMode;
$this->managerUsername = $managerUsername;
$this->ocDefaultCurrency = $ocDefaultCurrency;
$this->ocConfigTax = $ocConfigTax;
$this->ocStoreId = $ocStoreId;
}
public function isFeatureCoupons(): bool
{
return $this->featureCoupons;
}
public function isFeatureVouchers(): bool
{
return $this->featureVouchers;
}
public function isShowCategoryProductsButton(): bool
{
return $this->showCategoryProductsButton;
}
public function getProductInteractionMode(): string
{
return $this->productInteractionMode;
}
public function getManagerUsername(): ?string
{
return $this->managerUsername;
}
public function getOcDefaultCurrency(): string
{
return $this->ocDefaultCurrency;
}
public function isOcConfigTax(): bool
{
return $this->ocConfigTax;
}
public function getOcStoreId(): int
{
return $this->ocStoreId;
}
public function toArray(): array
{
return [
// enable_store больше не сериализуется, так как заменен на product_interaction_mode
'feature_coupons' => $this->featureCoupons,
'feature_vouchers' => $this->featureVouchers,
'show_category_products_button' => $this->showCategoryProductsButton,
'product_interaction_mode' => $this->productInteractionMode,
'manager_username' => $this->managerUsername,
'oc_default_currency' => $this->ocDefaultCurrency,
'oc_config_tax' => $this->ocConfigTax,
'oc_store_id' => $this->ocStoreId,
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\DTO\Settings;
final class TelegramDTO
{
private string $botToken;
private ?int $chatId;
private string $ownerNotificationTemplate;
private string $customerNotificationTemplate;
private string $miniAppUrl;
public function __construct(
string $botToken,
?int $chatId,
string $ownerNotificationTemplate,
string $customerNotificationTemplate,
string $miniAppUrl
) {
$this->botToken = $botToken;
$this->chatId = $chatId;
$this->ownerNotificationTemplate = $ownerNotificationTemplate;
$this->customerNotificationTemplate = $customerNotificationTemplate;
$this->miniAppUrl = $miniAppUrl;
}
public function getBotToken(): string
{
return $this->botToken;
}
public function getChatId(): ?int
{
return $this->chatId;
}
public function getOwnerNotificationTemplate(): string
{
return $this->ownerNotificationTemplate;
}
public function getCustomerNotificationTemplate(): string
{
return $this->customerNotificationTemplate;
}
public function getMiniAppUrl(): string
{
return $this->miniAppUrl;
}
public function toArray(): array
{
return [
'bot_token' => $this->botToken,
'chat_id' => $this->chatId,
'owner_notification_template' => $this->ownerNotificationTemplate,
'customer_notification_template' => $this->customerNotificationTemplate,
'mini_app_url' => $this->miniAppUrl,
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\DTO\Settings;
final class TextsDTO
{
private string $textNoMoreProducts;
private string $textEmptyCart;
private string $textOrderCreatedSuccess;
private string $textManagerButton;
public function __construct(
string $textNoMoreProducts,
string $textEmptyCart,
string $textOrderCreatedSuccess,
string $textManagerButton
) {
$this->textNoMoreProducts = $textNoMoreProducts;
$this->textEmptyCart = $textEmptyCart;
$this->textOrderCreatedSuccess = $textOrderCreatedSuccess;
$this->textManagerButton = $textManagerButton;
}
public function getTextNoMoreProducts(): string
{
return $this->textNoMoreProducts;
}
public function getTextEmptyCart(): string
{
return $this->textEmptyCart;
}
public function getTextOrderCreatedSuccess(): string
{
return $this->textOrderCreatedSuccess;
}
public function getTextManagerButton(): string
{
return $this->textManagerButton;
}
public function toArray(): array
{
return [
'text_no_more_products' => $this->textNoMoreProducts,
'text_empty_cart' => $this->textEmptyCart,
'text_order_created_success' => $this->textOrderCreatedSuccess,
'text_manager_button' => $this->textManagerButton,
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Exceptions;
use Openguru\OpenCartFramework\Contracts\ExceptionHandlerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramInvalidSignatureException;
use Psr\Log\LoggerInterface;
use Throwable;
class CustomExceptionHandler implements ExceptionHandlerInterface
{
public function respond(Throwable $exception): ?JsonResponse
{
if ($exception instanceof TelegramInvalidSignatureException) {
return new JsonResponse([
'error' => 'Invalid Signature',
'code' => 'NO_INIT_DATA',
], Response::HTTP_BAD_REQUEST);
}
return null;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Exceptions;
use Openguru\OpenCartFramework\Validator\ErrorBag;
use RuntimeException;
use Throwable;
class OrderValidationFailedException extends RuntimeException
{
private ErrorBag $errorBag;
public function __construct(
ErrorBag $errorBag,
string $message = 'Validation failed',
int $code = 422,
Throwable $previous = null
) {
$this->errorBag = $errorBag;
parent::__construct($message, $code, $previous);
}
public function getErrorBag(): ErrorBag
{
return $this->errorBag;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use RuntimeException;
/**
* Исключение, выбрасываемое когда Telegram-кастомер не найден
*
* @package App\Exceptions
*/
class TelegramCustomerNotFoundException extends RuntimeException
{
public function __construct(int $customerId, ?\Throwable $previous = null)
{
parent::__construct(
"Telegram customer with record ID {$customerId} not found",
404,
$previous
);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use RuntimeException;
/**
* Исключение, выбрасываемое когда пользователь не разрешил писать ему в PM
*
* @package App\Exceptions
*/
class TelegramCustomerWriteNotAllowedException extends RuntimeException
{
public function __construct(int $telegramUserId, ?\Throwable $previous = null)
{
parent::__construct(
"User {$telegramUserId} has not allowed writing to PM",
400,
$previous
);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Filters;
use InvalidArgumentException;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
class ProductAttribute extends BaseRule
{
public const NAME = 'RULE_PRODUCT_ATTRIBUTE';
public static function initWithDefaults(): BaseRule
{
return new self(static::NAME, [
'product_attribute' => new Criterion(static::CRITERIA_OPTION_PRODUCT_ATTRIBUTE, [
'attribute_id' => null,
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
'keyword' => '',
'language_id' => config('language_id'),
]),
]);
}
public function apply(Builder $builder, $operand): void
{
foreach ($this->criteria as $criterion) {
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_ATTRIBUTE) {
$facetHash = md5(serialize($criterion));
$joinAlias = 'product_attributes_facet_' . $facetHash;
if ($builder->hasJoinAlias($joinAlias)) {
return;
}
$operator = static::$stringCompareOperators[$criterion->params['operator']];
$languageId = $criterion->params['language_id'] ?? null;
if (! $languageId) {
throw new InvalidArgumentException('language_id is required for the product attribute filter');
}
$builder->leftJoin(
db_table('product_attribute') . " AS $joinAlias",
function (JoinClause $join) use ($criterion, $joinAlias, $operator, $languageId) {
$join->on('products.product_id', '=', "$joinAlias.product_id")
->where("$joinAlias.attribute_id", '=', $criterion->params['attribute_id'])
->where("$joinAlias.language_id", '=', $languageId);
if ($operator !== 'is_empty' && $operator !== 'is_not_empty') {
$this->criterionStringCondition(
$join,
$criterion,
"$joinAlias.text",
'and'
);
}
}
);
if ($operator === 'is_empty') {
$builder->whereNull("$joinAlias.product_id", $operand);
} else {
$builder->whereNotNull("$joinAlias.product_id", $operand);
}
}
}
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Filters;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
class ProductCategories extends BaseRule
{
public const NAME = 'RULE_PRODUCT_CATEGORIES';
public static function initWithDefaults(): BaseRule
{
return new self(static::NAME, [
'product_category_ids' => new Criterion(static::CRITERIA_OPTION_PRODUCT_CATEGORIES, [
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
'value' => [],
])
]);
}
public function apply(Builder $builder, $operand): void
{
/** @var Criterion $criterion */
foreach ($this->criteria as $criterion) {
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_CATEGORIES) {
$uniqHash = md5(serialize($criterion));
$joinAlias = 'product_category_' . $uniqHash;
if ($builder->hasJoinAlias($joinAlias)) {
return;
}
$operator = $criterion->params['operator'];
$categoryIds = $criterion->params['value'];
$builder->join(
db_table('product_to_category') . " AS $joinAlias",
function (JoinClause $join) use ($joinAlias, $categoryIds) {
$join->on('products.product_id', '=', "$joinAlias.product_id");
if ($categoryIds) {
$join->whereIn("$joinAlias.category_id", $categoryIds);
}
},
'left'
);
if ($operator === 'contains' && ! $categoryIds) {
$builder->whereNull("$joinAlias.product_id", $operand);
} elseif ($operator === 'not_contains' && ! $categoryIds) {
$builder->whereNotNull("$joinAlias.product_id", $operand);
} elseif ($operator === 'contains') {
$builder->whereNotNull("$joinAlias.product_id", $operand);
} else {
$builder->whereNull("$joinAlias.product_id", $operand);
}
}
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Filters;
use InvalidArgumentException;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
class ProductCategory extends BaseRule
{
public const NAME = 'RULE_PRODUCT_CATEGORY';
public static function initWithDefaults(): BaseRule
{
return new self(static::NAME, [
'product_category_id' => new Criterion(static::CRITERIA_OPTION_PRODUCT_CATEGORIES, [
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
'value' => null,
])
]);
}
public function apply(Builder $builder, $operand): void
{
/** @var Criterion $criterion */
foreach ($this->criteria as $criterion) {
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_CATEGORY) {
$operator = $criterion->params['operator'];
$categoryId = $criterion->params['value'];
if (! $categoryId) {
return;
}
$uniqHash = md5(serialize($criterion));
$joinAlias = 'product_category_' . $uniqHash;
if ($builder->hasJoinAlias($joinAlias)) {
return;
}
$builder->join(
db_table('product_to_category') . " AS $joinAlias",
function (JoinClause $join) use ($joinAlias, $categoryId) {
$join
->on('products.product_id', '=', "$joinAlias.product_id")
->where("$joinAlias.category_id", '=', $categoryId);
},
'left'
);
if ($operator === 'contains') {
$builder->whereNotNull("$joinAlias.product_id", $operand);
} elseif ($operator === 'not_contains') {
$builder->whereNull("$joinAlias.product_id", $operand);
} else {
throw new InvalidArgumentException('Invalid operator: ' . $operator);
}
}
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Filters;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
class ProductManufacturer extends BaseRule
{
public const NAME = 'RULE_PRODUCT_MANUFACTURER';
public static function initWithDefaults(): BaseRule
{
return new self(static::NAME, [
'product_manufacturer_ids' => new Criterion(static::CRITERIA_OPTION_PRODUCT_MANUFACTURER, [
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
'value' => [],
])
]);
}
public function apply(Builder $builder, $operand): void
{
/** @var Criterion $criterion */
foreach ($this->criteria as $criterion) {
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_MANUFACTURER) {
$operator = $criterion->params['operator'];
$ids = $criterion->params['value'];
if ($ids) {
$builder->whereIn(
'products.manufacturer_id',
$ids,
$operator === static::CRITERIA_OPERATOR_NOT_CONTAINS
);
} else {
$builder->where(
'products.manufacturer_id',
'=',
0,
$operator === static::CRITERIA_OPERATOR_NOT_CONTAINS
);
}
}
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Filters;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
class ProductModel extends BaseRule
{
public const NAME = 'RULE_PRODUCT_MODEL';
public static function initWithDefaults(): BaseRule
{
return new self(static::NAME, [
'product_model' => new Criterion(static::CRITERIA_OPTION_PRODUCT_MODEL, [
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
'value' => [],
])
]);
}
public function apply(Builder $builder, $operand): void
{
/** @var Criterion $criterion */
foreach ($this->criteria as $criterion) {
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_MODEL) {
$operator = $criterion->params['operator'];
$models = $criterion->params['value'] ?? [];
if ($models) {
$builder->whereIn(
'products.model',
$models,
$operator === static::CRITERIA_OPERATOR_NOT_CONTAINS
);
} else {
$builder->whereRaw('TRUE = FALSE');
}
}
}
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Filters;
use InvalidArgumentException;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
use Openguru\OpenCartFramework\QueryBuilder\Table;
use Openguru\OpenCartFramework\Support\Arr;
use RuntimeException;
class ProductPrice extends BaseRule
{
public const NAME = 'RULE_PRODUCT_PRICE';
public static function initWithDefaults(): BaseRule
{
return new self(static::NAME, [
'product_price' => new Criterion(static::CRITERIA_OPTION_NUMBER, [
'operator' => static::CRITERIA_OPERATOR_GREATER_OR_EQUAL,
'value' => [
'from' => 0,
'to' => null,
],
]),
'include_discounts' => new Criterion(static::CRITERIA_OPTION_BOOLEAN, [
'value' => true,
]),
'include_specials' => new Criterion(static::CRITERIA_OPTION_BOOLEAN, [
'value' => true,
]),
]);
}
/**
* @return void
*/
public function apply(Builder $builder, $operand)
{
$includeSpecials = $this->criteria['include_specials']->params['value'] ?? true;
/** @var Criterion|null $productPriceCriterion */
$productPriceCriterion = $this->criteria['product_price'] ?? null;
if (! $productPriceCriterion) {
throw new RuntimeException('Invalid product price rule format. Criterion is not found. Check filter JSON.');
}
if (! isset(static::$numberCompareOperators[$productPriceCriterion->params['operator']])) {
throw new InvalidArgumentException('Invalid operator: ' . $productPriceCriterion->params['operator']);
}
$column = 'products.price';
if ($includeSpecials) {
$specialsFacetHash = md5(serialize($productPriceCriterion) . 'specials');
$joinAlias = 'product_specials_' . $specialsFacetHash;
if ($builder->hasJoinAlias($joinAlias)) {
return;
}
$customerGroupId = config('oc_customer_group_id', 1);
$sub2 = $builder->newQuery()
->select([
'product_id',
new RawExpression("MIN(CONCAT(LPAD(priority, 5, '0'), LPAD(price, 10, '0'))) AS sort_key"),
])
->from(db_table('product_special'), 'ps')
->where("ps.customer_group_id", '=', $customerGroupId)
->whereRaw(
"
(ps.date_start = '0000-00-00' OR ps.date_start < NOW())
AND (ps.date_end = '0000-00-00' OR ps.date_end > NOW())
"
)
->groupBy(['product_id']);
$sub = $builder->newQuery()
->select([
'ps1.product_id',
'ps1.price',
])
->from(db_table('product_special'), 'ps1')
->join(new Table($sub2, 'ps2'), function (JoinClause $join) {
$join->on('ps1.product_id', '=', 'ps2.product_id')
->whereRaw("CONCAT(LPAD(ps1.priority, 5, '0'), LPAD(ps1.price, 10, '0')) = ps2.sort_key");
});
$builder->join(new Table($sub, $joinAlias), function (JoinClause $join) use ($joinAlias) {
$join->on('products.product_id', '=', "$joinAlias.product_id");
}, Builder::JOIN_TYPE_LEFT);
$column = new RawExpression("COALESCE($joinAlias.price, products.price)");
}
$numberOperator = static::$numberCompareOperators[$productPriceCriterion->params['operator']];
$value = $this->prepareValue(
$numberOperator,
$productPriceCriterion->params['value']
);
if ($numberOperator === 'BETWEEN') {
[$min, $max] = $value; // $min = левая, $max = правая граница
// если обе границы не указаны — фильтр игнорируем
if ($min === null && $max === null) {
return;
}
// если только правая граница — "меньше или равно"
if ($min === null && $max !== null) {
$builder->where($column, '<=', $max, $operand);
return;
}
// если только левая граница — "больше или равно"
if ($min !== null && $max === null) {
$builder->where($column, '>=', $min, $operand);
return;
}
// левая и правая граница равны
if ($min !== null && $max !== null && $min === $max) {
$builder->where($column, '=', $min, $operand);
return;
}
// если обе границы есть — классический between (min ≤ x ≤ max)
if ($min !== null && $max !== null) {
$builder->whereBetween($column, [$min, $max], $operand);
}
} else {
$builder->where($column, $numberOperator, $value[0], $operand);
}
}
private function prepareValue($numberOperator, array $value): array
{
$from = null;
$to = null;
if (is_numeric($value['from'])) {
$from = (int) $value['from'];
}
if (is_numeric($value['to'])) {
$to = (int) $value['to'];
}
return [$from, $to];
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Filters;
use InvalidArgumentException;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\Exceptions\CriteriaBuilderException;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use RuntimeException;
class ProductQuantity extends BaseRule
{
public const NAME = 'RULE_PRODUCT_QUANTITY';
public static function initWithDefaults(): BaseRule
{
return new self(static::NAME, [
'product_quantity' => new Criterion(static::CRITERIA_OPTION_NUMBER, [
'operator' => static::CRITERIA_OPERATOR_GREATER_OR_EQUAL,
'value' => [0, null],
]),
]);
}
public function apply(Builder $builder, $operand): void
{
/** @var Criterion|null $productQuantityCriterion */
$productQuantityCriterion = $this->criteria['product_quantity'] ?? null;
if (! $productQuantityCriterion) {
throw new RuntimeException('Product Quantity rule criterion is not found.');
}
$column = 'products.quantity';
if (! isset(static::$numberCompareOperators[$productQuantityCriterion->params['operator']])) {
throw new InvalidArgumentException('Invalid operator: ' . $productQuantityCriterion->params['operator']);
}
$numberOperator = static::$numberCompareOperators[$productQuantityCriterion->params['operator']];
$value = $this->prepareValue(
$numberOperator,
$productQuantityCriterion->params['value'],
);
if ($numberOperator === 'BETWEEN') {
$builder->whereBetween($column, $value, $operand);
} else {
$builder->where($column, $numberOperator, $value[0], $operand);
}
}
private function prepareValue($numberOperator, array $value): array
{
if (
(isset($value[0]) && ! is_numeric($value[0]))
|| ($numberOperator === 'BETWEEN' && ! $value[1])
) {
throw new CriteriaBuilderException('Value is required.');
}
return array_map('intval', $value);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Filters;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
class ProductStatus extends BaseRule
{
public const NAME = 'RULE_PRODUCT_STATUS';
public static function initWithDefaults(): BaseRule
{
return new self(static::NAME, [
'product_status' => new Criterion(static::CRITERIA_OPTION_BOOLEAN, [
'operator' => static::CRITERIA_OPERATOR_EQUALS,
'value' => true,
]),
]);
}
public function apply(Builder $builder, $operand): void
{
/** @var Criterion $criterion */
foreach ($this->criteria as $criterion) {
if ($criterion->type === static::CRITERIA_OPTION_BOOLEAN) {
$value = $criterion->params['value'];
$builder->where('products.status', '=', $value, $operand);
}
}
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Handlers;
use App\Services\BlocksService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
class BlocksHandler
{
private BlocksService $blocksService;
public function __construct(BlocksService $blocksService)
{
$this->blocksService = $blocksService;
}
public function processBlock(Request $request): JsonResponse
{
$block = $request->json();
$data = $this->blocksService->process($block);
return new JsonResponse(compact('data'));
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Handlers;
use App\Services\CartService;
use Cart\Cart;
use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
class CartHandler
{
private Cart $cart;
private CartService $cartService;
public function __construct(Cart $cart, CartService $cartService)
{
$this->cart = $cart;
$this->cartService = $cartService;
}
public function index(): JsonResponse
{
$items = $this->cartService->getCart();
return new JsonResponse([
'data' => $items,
]);
}
public function checkout(Request $request): JsonResponse
{
$items = $request->json();
foreach ($items as $item) {
$options = [];
foreach ($item['options'] as $option) {
if (! empty($option['value']) && ! empty($option['value']['product_option_value_id'])) {
$options[$option['product_option_id']] = $option['value']['product_option_value_id'];
}
}
$this->cart->add(
$item['productId'],
$item['quantity'],
$options,
);
}
return new JsonResponse([
'data' => $items,
]);
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Handlers;
use App\Services\SettingsService;
use App\Support\Utils;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\ImageTool\ImageFactory;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Openguru\OpenCartFramework\QueryBuilder\Table;
use Openguru\OpenCartFramework\Support\Str;
use Symfony\Component\HttpFoundation\JsonResponse;
class CategoriesHandler
{
private const THUMB_SIZE = 150;
private Builder $queryBuilder;
private ImageFactory $image;
private SettingsService $settings;
private CacheInterface $cache;
public function __construct(
Builder $queryBuilder,
ImageFactory $ocImageTool,
SettingsService $settings,
CacheInterface $cache
) {
$this->queryBuilder = $queryBuilder;
$this->image = $ocImageTool;
$this->settings = $settings;
$this->cache = $cache;
}
public function index(Request $request): JsonResponse
{
$cacheKey = 'categories.index';
$categories = $this->cache->get($cacheKey);
if ($categories === null) {
$languageId = $this->settings->config()->getApp()->getLanguageId();
$storeId = $this->settings->get('store.oc_store_id', 0);
$perPage = $request->get('perPage', 100);
$categoriesFlat = $this->queryBuilder->newQuery()
->select([
'categories.category_id' => 'id',
'categories.parent_id' => 'parent_id',
'categories.image' => 'image',
'descriptions.name' => 'name',
'descriptions.description' => 'description',
])
->from(db_table('category'), 'categories')
->join(
db_table('category_description') . ' AS descriptions',
function (JoinClause $join) use ($languageId) {
$join->on('categories.category_id', '=', 'descriptions.category_id')
->where('descriptions.language_id', '=', $languageId);
}
)
->join(
new Table(db_table('category_to_store'), 'category_to_store'),
function (JoinClause $join) use ($storeId) {
$join->on('category_to_store.category_id', '=', 'categories.category_id')
->where('category_to_store.store_id', '=', $storeId);
}
)
->where('categories.status', '=', 1)
->orderBy('parent_id')
->orderBy('sort_order')
->get();
$categories = $this->buildCategoryTree($categoriesFlat);
$categories = array_slice($categories, 0, $perPage);
$this->cache->set($cacheKey, $categories, 60 * 60 * 24);
}
return new JsonResponse([
'data' => array_map(static function ($category) {
return [
'id' => (int) $category['id'],
'image' => $category['image'] ?? '',
'name' => Str::htmlEntityEncode($category['name']),
'description' => $category['description'],
'children' => $category['children'],
];
}, $categories),
]);
}
public function buildCategoryTree(array $flat, $parentId = 0): array
{
$branch = [];
foreach ($flat as $category) {
if ((int) $category['parent_id'] === (int) $parentId) {
$children = $this->buildCategoryTree($flat, $category['id']);
if ($children) {
$category['children'] = $children;
}
$image = $this->image
->make($category['image'])
->resize(self::THUMB_SIZE, self::THUMB_SIZE)
->url();
$branch[] = [
'id' => (int) $category['id'],
'image' => $image,
'name' => Utils::htmlEntityEncode($category['name']),
'description' => $category['description'],
'children' => $category['children'] ?? [],
];
}
}
return $branch;
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Handlers;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Scheduler\SchedulerService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class CronHandler
{
private Settings $settings;
private SchedulerService $schedulerService;
public function __construct(Settings $settings, SchedulerService $schedulerService)
{
$this->settings = $settings;
$this->schedulerService = $schedulerService;
}
/**
* Запуск планировщика по HTTP (для cron-job.org и аналогов).
* Требует api_key в query, совпадающий с настройкой cron.api_key.
*/
public function runSchedule(Request $request): JsonResponse
{
$mode = $this->settings->get('cron.mode', 'disabled');
if ($mode !== 'cron_job_org') {
return new JsonResponse(['error' => 'Scheduler is not in cron-job.org mode'], Response::HTTP_FORBIDDEN);
}
$apiKey = $request->get('api_key', '');
$expectedKey = $this->settings->get('cron.api_key', '');
if ($expectedKey === '' || $apiKey === '' || !hash_equals($expectedKey, $apiKey)) {
return new JsonResponse(['error' => 'Invalid or missing API key'], Response::HTTP_UNAUTHORIZED);
}
// Увеличиваем лимит времени выполнения при запуске по HTTP, чтобы снизить риск timeout
$limit = 300; // 5 минут
if (function_exists('set_time_limit')) {
@set_time_limit($limit);
}
if (function_exists('ini_set')) {
@ini_set('max_execution_time', (string) $limit);
}
$result = $this->schedulerService->run(true);
$data = [
'success' => true,
'executed' => count($result->executed),
'failed' => count($result->failed),
'skipped' => count($result->skipped),
];
return new JsonResponse($data);
}
}

View File

@@ -0,0 +1,187 @@
<?php
namespace App\Handlers;
use Carbon\Carbon;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Exceptions\InvalidApiTokenException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
use Openguru\OpenCartFramework\Support\DateUtils;
use Psr\Log\LoggerInterface;
class ETLHandler
{
private Builder $builder;
private Settings $settings;
private LoggerInterface $logger;
public function __construct(Builder $builder, Settings $settings, LoggerInterface $logger)
{
$this->builder = $builder;
$this->settings = $settings;
$this->logger = $logger;
}
private function getLastUpdatedAtSql(): string
{
return '
GREATEST(
COALESCE((
SELECT MAX(date_modified)
FROM oc_order as o
where o.customer_id = megapay_customers.oc_customer_id
), 0),
megapay_customers.updated_at
)
';
}
private function getCustomerQuery(?Carbon $updatedAt = null): Builder
{
$lastUpdatedAtSql = $this->getLastUpdatedAtSql();
return $this->builder->newQuery()
->from('megapay_customers')
->where('allows_write_to_pm', '=', 1)
->when($updatedAt !== null, function (Builder $builder) use ($lastUpdatedAtSql, $updatedAt) {
$builder->where(new RawExpression($lastUpdatedAtSql), '>=', $updatedAt);
});
}
/**
* @throws InvalidApiTokenException
*/
public function getCustomersMeta(Request $request): JsonResponse
{
$this->validateApiKey($request);
$updatedAt = $request->get('updated_at');
if ($updatedAt) {
$updatedAt = DateUtils::toSystemTimezone($updatedAt);
}
$query = $this->getCustomerQuery($updatedAt);
$total = $query->count();
return new JsonResponse([
'data' => [
'total' => $total,
],
]);
}
/**
* @throws InvalidApiTokenException
*/
public function customers(Request $request): JsonResponse
{
$this->validateApiKey($request);
$this->logger->debug('Get customers for ETL');
$page = (int)$request->get('page', 1);
$perPage = (int)$request->get('perPage', 10000);
$successOrderStatusIds = '5,3';
$updatedAt = $request->get('updated_at');
if ($updatedAt) {
$updatedAt = DateUtils::toSystemTimezone($updatedAt);
}
$lastUpdatedAtSql = $this->getLastUpdatedAtSql();
$query = $this->getCustomerQuery($updatedAt);
$query->orderBy('telegram_user_id');
$query->forPage($page, $perPage);
$query
->select([
'tracking_id',
'username',
'photo_url',
'telegram_user_id' => 'tg_user_id',
'megapay_customers.oc_customer_id',
'is_premium',
'last_seen_at',
'orders_count' => 'orders_count_total',
'created_at' => 'registered_at',
new RawExpression(
'(
SELECT MIN(date_added)
FROM oc_order
WHERE oc_order.customer_id = megapay_customers.oc_customer_id
) AS first_order_date'
),
new RawExpression(
'(
SELECT MAX(date_added)
FROM oc_order
WHERE oc_order.customer_id = megapay_customers.oc_customer_id
) AS last_order_date'
),
new RawExpression(
"COALESCE((
SELECT
SUM(total)
FROM
oc_order
WHERE
oc_order.customer_id = megapay_customers.oc_customer_id
AND oc_order.order_status_id IN ($successOrderStatusIds)
), 0) AS total_spent"
),
new RawExpression(
"COALESCE((
SELECT
COUNT(*)
FROM
oc_order
WHERE
oc_order.customer_id = megapay_customers.oc_customer_id
AND oc_order.order_status_id IN ($successOrderStatusIds)
), 0) AS orders_count_success"
),
new RawExpression("$lastUpdatedAtSql AS updated_at"),
]);
$items = $query->get();
return new JsonResponse([
'data' => array_map(static function ($item) {
return [
'tracking_id' => $item['tracking_id'],
'username' => $item['username'],
'photo_url' => $item['photo_url'],
'tg_user_id' => filter_var($item['tg_user_id'], FILTER_VALIDATE_INT),
'oc_customer_id' => filter_var($item['oc_customer_id'], FILTER_VALIDATE_INT),
'is_premium' => filter_var($item['is_premium'], FILTER_VALIDATE_BOOLEAN),
'last_seen_at' => DateUtils::toUTC($item['last_seen_at']),
'orders_count_total' => filter_var($item['orders_count_total'], FILTER_VALIDATE_INT),
'registered_at' => DateUtils::toUTC($item['registered_at']),
'first_order_date' => DateUtils::toUTC($item['first_order_date']),
'last_order_date' => DateUtils::toUTC($item['last_order_date']),
'total_spent' => (float)$item['total_spent'],
'orders_count_success' => filter_var($item['orders_count_success'], FILTER_VALIDATE_INT),
'updated_at' => DateUtils::toUTC($item['updated_at']),
];
}, $items),
]);
}
/**
* @throws InvalidApiTokenException
*/
private function validateApiKey(Request $request): void
{
$token = $request->getApiKey();
if (empty($token)) {
throw new InvalidApiTokenException('Invalid API Key.');
}
if (strcmp($token, $this->settings->get('pulse.api_key')) !== 0) {
throw new InvalidApiTokenException('Invalid API Key');
}
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Handlers;
use App\Filters\ProductCategory;
use App\Filters\ProductPrice;
use Symfony\Component\HttpFoundation\JsonResponse;
class FiltersHandler
{
public function getFiltersForMainPage(): JsonResponse
{
$filters = [
'operand' => 'AND',
'rules' => [
ProductPrice::NAME => [
'criteria' => [
'product_price' => [
'type' => 'number',
'params' => [
'operator' => 'between',
'value' => [
'from' => 0,
'to' => null,
],
],
],
],
],
ProductCategory::NAME => [
'criteria' => [
'product_category_id' => [
'type' => 'product_category',
'params' => [
'operator' => 'contains',
'value' => null,
],
],
]
],
],
];
return new JsonResponse([
'data' => $filters,
]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Handlers;
use JsonException;
use Openguru\OpenCartFramework\Exceptions\EntityNotFoundException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
class FormsHandler
{
private Builder $builder;
public function __construct(Builder $builder)
{
$this->builder = $builder;
}
/**
* @throws EntityNotFoundException
* @throws JsonException
*/
public function getForm(Request $request): JsonResponse
{
$alias = $request->json('alias');
if (! $alias) {
return new JsonResponse([
'error' => 'Form alias is required',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$form = $this->builder->newQuery()
->from('megapay_forms')
->where('alias', '=', $alias)
->firstOrNull();
if (! $form) {
throw new EntityNotFoundException("Form with alias `{$alias}` not found");
}
$schema = json_decode($form['schema'], true, 512, JSON_THROW_ON_ERROR);
return new JsonResponse([
'data' => [
'schema' => $schema,
],
]);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Handlers;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class HealthCheckHandler
{
public function handle(): JsonResponse
{
return new JsonResponse([
'status' => 'ok',
], Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Handlers;
use App\Exceptions\OrderValidationFailedException;
use App\Services\OrderCreateService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class OrderHandler
{
private OrderCreateService $orderCreateService;
public function __construct(OrderCreateService $orderCreateService)
{
$this->orderCreateService = $orderCreateService;
}
public function store(Request $request): JsonResponse
{
try {
$order = $this->orderCreateService->create($request->json(), [
'ip' => $request->getClientIp(),
'user_agent' => $request->getUserAgent(),
]);
return new JsonResponse([
'data' => $order,
], Response::HTTP_CREATED);
} catch (OrderValidationFailedException $exception) {
return new JsonResponse([
'data' => $exception->getErrorBag()->firstOfAll(),
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Handlers;
use App\Models\TelegramCustomer;
use Carbon\Carbon;
use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Telegram\Enums\TelegramHeader;
use Openguru\OpenCartFramework\Telegram\TelegramService;
use Psr\Log\LoggerInterface;
class PrivacyPolicyHandler
{
private TelegramService $telegramService;
private TelegramCustomer $telegramCustomer;
private LoggerInterface $logger;
public function __construct(
TelegramService $telegramService,
TelegramCustomer $telegramCustomer,
LoggerInterface $logger
) {
$this->telegramService = $telegramService;
$this->telegramCustomer = $telegramCustomer;
$this->logger = $logger;
}
public function checkIsUserPrivacyConsented(Request $request): JsonResponse
{
$isPrivacyConsented = false;
$telegramUserId = $this->telegramService->userId($request->header(TelegramHeader::INIT_DATA));
if (! $telegramUserId) {
return new JsonResponse([
'data' => [
'is_privacy_consented' => false,
],
]);
}
$customer = $this->telegramCustomer->findByTelegramUserId($telegramUserId);
if ($customer) {
$isPrivacyConsented = Arr::get($customer, 'privacy_consented_at') !== null;
}
return new JsonResponse([
'data' => [
'is_privacy_consented' => $isPrivacyConsented,
],
]);
}
public function userPrivacyConsent(Request $request): JsonResponse
{
$telegramUserId = $this->telegramService->userId($request->header(TelegramHeader::INIT_DATA));
if ($telegramUserId) {
$this->telegramCustomer->updateByTelegramUserId($telegramUserId, [
'privacy_consented_at' => Carbon::now()->toDateTimeString(),
]);
} else {
$this->logger->warning(
'Could not find customer with telegram user_id: ' . $telegramUserId . ' to give privacy consent.'
);
}
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Handlers;
use App\Services\ProductsService;
use App\Services\SettingsService;
use Exception;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Exceptions\EntityNotFoundException;
use Openguru\OpenCartFramework\Http\Request;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class ProductsHandler
{
private SettingsService $settings;
private ProductsService $productsService;
private LoggerInterface $logger;
private CacheInterface $cache;
public function __construct(
SettingsService $settings,
ProductsService $productsService,
LoggerInterface $logger,
CacheInterface $cache
) {
$this->settings = $settings;
$this->productsService = $productsService;
$this->logger = $logger;
$this->cache = $cache;
}
public function index(Request $request): JsonResponse
{
$page = (int) $request->json('page', 1);
$perPage = min((int) $request->json('perPage', 20), 20);
$maxPages = (int) $request->json('maxPages', 10);
$search = trim($request->json('search', ''));
$filters = $request->json('filters');
$storeId = $this->settings->get('store.oc_store_id', 0);
$languageId = $this->settings->config()->getApp()->getLanguageId();
$response = $this->productsService->getProductsResponse(
compact('page', 'perPage', 'search', 'filters', 'maxPages'),
$languageId,
$storeId,
);
return new JsonResponse($response);
}
public function show(Request $request): JsonResponse
{
$productId = (int) $request->get('id');
try {
$product = $this->productsService->getProductById($productId);
} catch (EntityNotFoundException $exception) {
return new JsonResponse([
'message' => 'Product with id ' . $productId . ' not found',
], Response::HTTP_NOT_FOUND);
} catch (Exception $exception) {
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
throw new RuntimeException('Error get product with id ' . $productId, 500);
}
return new JsonResponse([
'data' => $product,
]);
}
public function getProductImages(Request $request): JsonResponse
{
$productId = (int) $request->get('id');
try {
$images = $this->productsService->getProductImages($productId);
} catch (EntityNotFoundException $exception) {
return new JsonResponse([
'message' => 'Product with id ' . $productId . ' not found',
], Response::HTTP_NOT_FOUND);
} catch (Exception $exception) {
$this->logger->error('Could not load images for product ' . $productId, ['exception' => $exception]);
$images = [];
}
return new JsonResponse([
'data' => $images,
]);
}
public function getSearchPlaceholder(Request $request): JsonResponse
{
$storeId = $this->settings->get('store.oc_store_id', 0);
$languageId = $this->settings->config()->getApp()->getLanguageId();
$cacheKey = "products.search_placeholder.{$storeId}.{$languageId}";
$cached = $this->cache->get($cacheKey);
if ($cached !== null) {
return new JsonResponse($cached);
}
$response = $this->productsService->getProductsResponse(
[
'page' => 1,
'perPage' => 3,
'search' => '',
'filters' => [],
'maxPages' => 1,
],
$languageId,
$storeId,
);
// Кешируем на 24 часа
$this->cache->set($cacheKey, $response, 60 * 60 * 24);
return new JsonResponse($response);
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Handlers;
use App\Services\SettingsService;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\ImageTool\ImageFactory;
use Openguru\OpenCartFramework\Telegram\TelegramService;
use Symfony\Component\HttpFoundation\JsonResponse;
class SettingsHandler
{
private SettingsService $settings;
private ImageFactory $image;
private TelegramService $telegramService;
public function __construct(
SettingsService $settings,
ImageFactory $image,
TelegramService $telegramService
) {
$this->settings = $settings;
$this->image = $image;
$this->telegramService = $telegramService;
}
public function index(): JsonResponse
{
$appConfig = $this->settings->config()->getApp();
$appIcon = $appConfig->getAppIcon();
$hash = $this->settings->getHash();
if ($appIcon) {
$appIcon = $this->image->make($appIcon)->resize(null, 64)->url('png') . '?_v=' . $hash;
}
return new JsonResponse([
'app_name' => $appConfig->getAppName(),
'app_debug' => $appConfig->isAppDebug(),
'app_icon' => $appIcon,
'theme_light' => $appConfig->getThemeLight(),
'theme_dark' => $appConfig->getThemeDark(),
'ya_metrika_enabled' => $this->settings->config()->getMetrics()->isYandexMetrikaEnabled(),
'app_enabled' => $appConfig->isAppEnabled(),
'product_interaction_mode' => $this->settings->config()->getStore()->getProductInteractionMode(),
'manager_username' => $this->settings->config()->getStore()->getManagerUsername(),
'feature_coupons' => $this->settings->config()->getStore()->isFeatureCoupons(),
'feature_vouchers' => $this->settings->config()->getStore()->isFeatureVouchers(),
'show_category_products_button' => $this->settings->config()->getStore()->isShowCategoryProductsButton(),
'currency_code' => $this->settings->config()->getStore()->getOcDefaultCurrency(),
'texts' => $this->settings->config()->getTexts()->toArray(),
'mainpage_blocks' => $this->settings->get('mainpage_blocks', []),
'privacy_policy_link' => $this->settings->get('app.privacy_policy_link'),
'image_aspect_ratio' => $this->settings->get('app.image_aspect_ratio', '1:1'),
'haptic_enabled' => $appConfig->isHapticEnabled(),
]);
}
public function testTgMessage(Request $request): JsonResponse
{
$template = $request->json('template', 'Нет шаблона');
$token = $request->json('token');
$chatId = $request->json('chat_id');
if (! $token) {
return new JsonResponse([
'message' => 'Не задан Telegram BotToken',
]);
}
if (! $chatId) {
return new JsonResponse([
'message' => 'Не задан ChatID.',
]);
}
$variables = [
'{store_name}' => $this->settings->config()->getApp()->getAppName(),
'{order_id}' => 777,
'{customer}' => 'Иван Васильевич',
'{email}' => 'telegram@opencart.com',
'{phone}' => '+79999999999',
'{comment}' => 'Это тестовый заказ',
'{address}' => 'г. Москва',
'{total}' => 100000,
'{ip}' => '127.0.0.1',
'{created_at}' => date('Y-m-d H:i:s'),
];
$message = $this->telegramService->prepareMessage($template, $variables);
try {
$this->telegramService
->setBotToken($token)
->sendMessage($chatId, $message);
return new JsonResponse([
'message' => 'Сообщение отправлено. Проверьте Telegram.',
]);
} catch (ClientException $exception) {
$json = json_decode($exception->getResponse()->getBody(), true);
return new JsonResponse([
'message' => $json['description'],
]);
} catch (Exception $e) {
return new JsonResponse([
'message' => $e->getMessage(),
]);
}
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Handlers;
use App\Services\MegapayCustomerService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\MegaPayPulse\TrackingIdGenerator;
use Openguru\OpenCartFramework\Telegram\Enums\TelegramHeader;
use Openguru\OpenCartFramework\Telegram\Exceptions\DecodeTelegramInitDataException;
use Openguru\OpenCartFramework\Telegram\TelegramInitDataDecoder;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Throwable;
class TelegramCustomerHandler
{
private MegapayCustomerService $telegramCustomerService;
private LoggerInterface $logger;
private TelegramInitDataDecoder $initDataDecoder;
public function __construct(
MegapayCustomerService $telegramCustomerService,
LoggerInterface $logger,
TelegramInitDataDecoder $initDataDecoder
) {
$this->telegramCustomerService = $telegramCustomerService;
$this->logger = $logger;
$this->initDataDecoder = $initDataDecoder;
}
/**
* Сохранить или обновить Telegram-пользователя
*
* @param Request $request HTTP запрос с данными пользователя
* @return JsonResponse JSON ответ с результатом операции
*/
public function saveOrUpdate(Request $request): JsonResponse
{
try {
$customer = $this->telegramCustomerService->saveOrUpdate(
$this->extractTelegramUserData($request)
);
return new JsonResponse([
'data' => [
'tracking_id' => Arr::get($customer, 'tracking_id'),
'created_at' => Arr::get($customer, 'created_at'),
],
], Response::HTTP_OK);
} catch (Throwable $e) {
$this->logger->error('Could not save telegram customer data', ['exception' => $e]);
return new JsonResponse([], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Получить данные текущего пользователя
*
* @param Request $request HTTP запрос
* @return JsonResponse JSON ответ с данными пользователя
*/
public function getCurrent(Request $request): JsonResponse
{
try {
$telegramUserData = $this->extractUserDataFromInitData($request);
$telegramUserId = (int)Arr::get($telegramUserData, 'id');
if ($telegramUserId <= 0) {
return new JsonResponse([
'data' => null,
], Response::HTTP_OK);
}
$customer = $this->telegramCustomerService->getByTelegramUserId($telegramUserId);
if (!$customer) {
return new JsonResponse([
'data' => null,
], Response::HTTP_OK);
}
return new JsonResponse([
'data' => [
'created_at' => Arr::get($customer, 'created_at'),
],
], Response::HTTP_OK);
} catch (Throwable $e) {
$this->logger->error('Could not get current telegram customer data', ['exception' => $e]);
return new JsonResponse([
'data' => null,
], Response::HTTP_OK);
}
}
/**
* Извлечь данные Telegram пользователя из запроса
*
* @param Request $request HTTP запрос
* @return array Данные пользователя
* @throws RuntimeException|DecodeTelegramInitDataException невозможно извлечь данные пользователя из Request
*/
private function extractTelegramUserData(Request $request): array
{
$telegramUserData = $request->json('user');
if (! $telegramUserData) {
$telegramUserData = $this->extractUserDataFromInitData($request);
}
return $telegramUserData;
}
/**
* @throws DecodeTelegramInitDataException
*/
private function extractUserDataFromInitData(Request $request): array
{
$raw = $request->header(TelegramHeader::INIT_DATA);
if (! $raw) {
throw new RuntimeException('No init data found in http request header');
}
$initData = $this->initDataDecoder->decode($raw);
return Arr::get($initData, 'user');
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Handlers;
use GuzzleHttp\Exception\GuzzleException;
use Mockery\Exception;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Container\Container;
use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Psr\Log\LoggerInterface;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Telegram\Contracts\TelegramCommandInterface;
use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramCommandNotFoundException;
use Openguru\OpenCartFramework\Telegram\TelegramBotStateManager;
use Openguru\OpenCartFramework\Telegram\TelegramCommandsRegistry;
use Openguru\OpenCartFramework\Telegram\TelegramService;
class TelegramHandler
{
private CacheInterface $cache;
private TelegramCommandsRegistry $telegramCommandsRegistry;
private Container $container;
private TelegramBotStateManager $telegramBotStateManager;
private LoggerInterface $logger;
private TelegramService $telegramService;
public function __construct(
CacheInterface $cache,
TelegramCommandsRegistry $telegramCommandsRegistry,
Container $container,
TelegramBotStateManager $telegramBotStateManager,
LoggerInterface $logger,
TelegramService $telegramService
) {
$this->cache = $cache;
$this->telegramCommandsRegistry = $telegramCommandsRegistry;
$this->container = $container;
$this->telegramBotStateManager = $telegramBotStateManager;
$this->logger = $logger;
$this->telegramService = $telegramService;
}
/**
* @throws GuzzleException
* @throws \JsonException
*/
public function webhook(Request $request): JsonResponse
{
$this->logger->debug('Webhook received');
$update = $request->json();
$message = $update['message'] ?? null;
if (! $message) {
return new JsonResponse([]);
}
$userId = $update['message']['from']['id'];
$chatId = $update['message']['chat']['id'];
try {
$message = Arr::get($update, 'message', []);
$this->cache->set('tg_latest_msg', $message, 60);
$text = Arr::get($message, 'text', '');
// command starts from "/"
if (strpos($text, '/') === 0) {
$this->telegramBotStateManager->clearState($userId, $chatId);
$command = substr($text, 1);
$handler = $this->telegramCommandsRegistry->resolve($command);
/** @var TelegramCommandInterface $concrete */
$concrete = $this->container->get($handler);
$concrete->handle($update);
return new JsonResponse([]);
}
// Continue state
$hasState = $this->telegramBotStateManager->hasState($userId, $chatId);
if ($hasState) {
$handler = $this->telegramBotStateManager->getCurrentStateCommandHandler($userId, $chatId);
/** @var TelegramCommandInterface $concrete */
$concrete = $this->container->get($handler);
$concrete->handle($update);
return new JsonResponse([]);
}
} catch (TelegramCommandNotFoundException $exception) {
$this->telegramService->sendMessage($chatId, 'Неверная команда');
} catch (Exception $exception) {
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
}
return new JsonResponse([]);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Handlers;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\MegaPayPulse\PulseIngestException;
use Openguru\OpenCartFramework\MegaPayPulse\MegaPayPulseService;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
class TelemetryHandler
{
private MegaPayPulseService $megaPayPulseService;
private LoggerInterface $logger;
public function __construct(
MegaPayPulseService $megaPayPulseService,
LoggerInterface $logger
) {
$this->megaPayPulseService = $megaPayPulseService;
$this->logger = $logger;
}
/**
* @throws PulseIngestException
*/
public function ingest(Request $request): JsonResponse
{
$this->megaPayPulseService->handleIngest($request->json());
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
public function heartbeat(): JsonResponse
{
try {
$this->megaPayPulseService->handleHeartbeat();
} catch (Throwable $e) {
$this->logger->warning('MegaPay Pulse Heartbeat failed: ' . $e->getMessage(), ['exception' => $e]);
}
return new JsonResponse(['status' => 'ok']);
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Carbon\Carbon;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\MegaPayPulse\TrackingIdGenerator;
use RuntimeException;
class TelegramCustomer
{
private const TABLE_NAME = 'megapay_customers';
private ConnectionInterface $database;
private Builder $builder;
public function __construct(ConnectionInterface $database, Builder $builder)
{
$this->database = $database;
$this->builder = $builder;
}
/**
* Найти запись по ID
*
* @param int $id ID записи
* @return array|null Данные пользователя или null если не найдено
*/
public function findById(int $id): ?array
{
return $this->builder
->newQuery()
->select(['*'])
->from(self::TABLE_NAME)
->where('id', '=', $id)
->firstOrNull();
}
/**
* Найти запись по Telegram user ID
*
* @param int $telegramUserId Telegram user ID
* @return array|null Данные пользователя или null если не найдено
*/
public function findByTelegramUserId(int $telegramUserId): ?array
{
return $this->builder
->newQuery()
->select(['*'])
->from(self::TABLE_NAME)
->where('telegram_user_id', '=', $telegramUserId)
->firstOrNull();
}
/**
* Найти запись по oc_customer_id
*
* @param int $customerId ID покупателя в OpenCart
* @return array|null Данные пользователя или null если не найдено
*/
public function findByCustomerId(int $customerId): ?array
{
return $this->builder
->newQuery()
->select(['*'])
->from(self::TABLE_NAME)
->where('oc_customer_id', '=', $customerId)
->firstOrNull();
}
/**
* Создать новую запись
*
* @param array $data Данные для создания записи
* @return int ID созданной записи
* @throws RuntimeException Если не удалось создать запись
*/
public function create(array $data): int
{
$data['created_at'] = Carbon::now()->toDateTimeString();
$data['updated_at'] = Carbon::now()->toDateTimeString();
$data['tracking_id'] = TrackingIdGenerator::generate();
$success = $this->database->insert(self::TABLE_NAME, $data);
if (! $success) {
$error = $this->database->getLastError();
$errorMessage = $error ? $error[1] : 'Unknown error';
throw new RuntimeException("Failed to insert telegram customer. Error: {$errorMessage}");
}
return $this->database->lastInsertId();
}
/**
* Обновить запись по Telegram user ID
*
* @param int $telegramUserId Telegram user ID
* @param array $data Данные для обновления
* @return bool true если обновление успешно
*/
public function updateByTelegramUserId(int $telegramUserId, array $data): bool
{
$data['updated_at'] = Carbon::now()->toDateTimeString();
return $this->builder->newQuery()
->where('telegram_user_id', '=', $telegramUserId)
->update(self::TABLE_NAME, $data);
}
/**
* Обновить last_seen_at для пользователя
*
* @param int $telegramUserId Telegram user ID
* @return bool true если обновление успешно
*/
public function updateLastSeen(int $telegramUserId): bool
{
return $this->updateByTelegramUserId($telegramUserId, [
'last_seen_at' => Carbon::now()->toDateTimeString(),
]);
}
public function increase(int $id, string $field): bool
{
$now = Carbon::now()->toDateTimeString();
$table = self::TABLE_NAME;
$sql = "UPDATE `$table` SET `$field` = `$field` + 1, updated_at = '$now' WHERE id = ?";
return $this->database->statement($sql, [$id]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\ServiceProviders;
use App\Exceptions\CustomExceptionHandler;
use App\Filters\ProductAttribute;
use App\Filters\ProductCategories;
use App\Filters\ProductCategory;
use App\Filters\ProductManufacturer;
use App\Filters\ProductModel;
use App\Filters\ProductPrice;
use App\Filters\ProductQuantity;
use App\Filters\ProductStatus;
use App\Telegram\LinkCommand;
use Openguru\OpenCartFramework\Container\ServiceProvider;
use Openguru\OpenCartFramework\Contracts\ExceptionHandlerInterface;
use Openguru\OpenCartFramework\CriteriaBuilder\RulesRegistry;
use Openguru\OpenCartFramework\Telegram\Commands\ChatIdCommand;
use Openguru\OpenCartFramework\Telegram\Commands\StartCommand;
use Openguru\OpenCartFramework\Telegram\TelegramCommandsRegistry;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->container->singleton(ExceptionHandlerInterface::class, function () {
return new CustomExceptionHandler();
});
$this->registerTelegramCommands();
$this->registerFacetFilters();
}
private function registerTelegramCommands(): void
{
$this->container->singleton(TelegramCommandsRegistry::class, function () {
return new TelegramCommandsRegistry();
});
$registry = $this->container->get(TelegramCommandsRegistry::class);
$registry->addCommand('id', ChatIdCommand::class, 'Возвращает ChatID текущего чата.');
$registry->addCommand('link', LinkCommand::class, 'Генератор Telegram сообщений с кнопкой');
$registry->addCommand(
'start',
StartCommand::class,
'Базовая команда Telegram бота. Присылает ссылку на открытие Megapay магазина.'
);
}
private function registerFacetFilters(): void
{
$this->container->singleton(RulesRegistry::class, function () {
return new RulesRegistry();
});
$registry = $this->container->get(RulesRegistry::class);
$registry->register([
ProductAttribute::NAME => ProductAttribute::class,
ProductCategories::NAME => ProductCategories::class,
ProductManufacturer::NAME => ProductManufacturer::class,
ProductModel::NAME => ProductModel::class,
ProductPrice::NAME => ProductPrice::class,
ProductQuantity::NAME => ProductQuantity::class,
ProductStatus::NAME => ProductStatus::class,
ProductCategory::NAME => ProductCategory::class,
]);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\ServiceProviders;
use App\Services\SettingsSerializerService;
use App\Services\SettingsService;
use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Container\ServiceProvider;
class SettingsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->container->singleton(SettingsService::class, function (Container $container) {
return new SettingsService(
$container->getConfigValue(),
$container->get(SettingsSerializerService::class)
);
});
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Services;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\ImageTool\ImageFactory;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use RuntimeException;
class BlocksService
{
private static array $processors = [
'slider' => [self::class, 'processSlider'],
'categories_top' => [self::class, 'processCategoriesTop'],
'products_feed' => [self::class, 'processProductsFeed'],
'products_carousel' => [self::class, 'processProductsCarousel'],
];
private ImageFactory $image;
private CacheInterface $cache;
private SettingsService $settings;
private Builder $queryBuilder;
private ProductsService $productsService;
public function __construct(
ImageFactory $image,
CacheInterface $cache,
SettingsService $settings,
Builder $queryBuilder,
ProductsService $productsService
) {
$this->image = $image;
$this->cache = $cache;
$this->settings = $settings;
$this->queryBuilder = $queryBuilder;
$this->productsService = $productsService;
}
public function process(array $block): array
{
$blockType = $block['type'];
$cacheKey = "block_{$blockType}_" . md5(serialize($block['data']));
$cacheTtlSeconds = 3600;
$data = $this->cache->get($cacheKey);
if (! $data) {
$method = self::$processors[$block['type']] ?? null;
if (! $method) {
throw new RuntimeException('Processor for block type ' . $block['type'] . ' does not exist');
}
$data = call_user_func_array($method, [$block]);
$this->cache->set($cacheKey, $data, $cacheTtlSeconds);
}
return $data;
}
private function processSlider(array $block): array
{
$slides = $block['data']['slides'];
foreach ($slides as $slideIndex => $slide) {
if (is_file(DIR_IMAGE . $slide['image'])) {
$image = $this->image->make($slide['image']);
$block['data']['slides'][$slideIndex]['image'] = $image->cover(1110, 600)->url();
}
}
return $block;
}
private function processCategoriesTop(array $block): array
{
$count = $block['data']['count'];
$languageId = $this->settings->config()->getApp()->getLanguageId();
$categories = [];
if ($count > 0) {
$categories = $this->queryBuilder->newQuery()
->select([
'categories.category_id' => 'id',
'descriptions.name' => 'name',
])
->from(db_table('category'), 'categories')
->join(
db_table('category_description') . ' AS descriptions',
function (JoinClause $join) use ($languageId) {
$join->on('categories.category_id', '=', 'descriptions.category_id')
->where('descriptions.language_id', '=', $languageId);
}
)
->where('categories.status', '=', 1)
->where('categories.parent_id', '=', 0)
->orderBy('sort_order')
->orderBy('descriptions.name')
->limit($count)
->get();
$categories = array_map(static function ($category) {
$category['id'] = (int) $category['id'];
return $category;
}, $categories);
}
$block['data']['categories'] = $categories;
return $block;
}
private function processProductsFeed(array $block): array
{
return $block;
}
private function processProductsCarousel(array $block): array
{
$categoryId = $block['data']['category_id'];
$languageId = $this->settings->config()->getApp()->getLanguageId();
$params = [
'page' => 1,
'perPage' => 10,
'filters' => [
"operand" => "AND",
"rules" => [
"RULE_PRODUCT_CATEGORIES" => [
"criteria" => [
"product_category_ids" => [
"type" => "product_categories",
"params" => [
"operator" => "contains",
"value" => [$categoryId],
],
],
],
],
],
],
];
$storeId = $this->settings->get('store.store_id', 0);
$response = $this->productsService->getProductsResponse($params, $languageId, $storeId);
$block['data']['products'] = $response;
return $block;
}
}

View File

@@ -0,0 +1,314 @@
<?php
namespace App\Services;
use Cart\Cart;
use Cart\Currency;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
class CartService
{
private OcRegistryDecorator $oc;
private Cart $cart;
private Currency $currency;
public function __construct(OcRegistryDecorator $registry, Cart $cart, Currency $currency)
{
$this->oc = $registry;
$this->cart = $cart;
$this->currency = $currency;
}
public function getCart(): array
{
$this->oc->load->language('checkout/cart');
if ($this->oc->cart->hasProducts()) {
if (
! $this->oc->cart->hasStock()
&& (
! $this->oc->config->get('config_stock_checkout')
|| $this->oc->config->get('config_stock_warning')
)
) {
$data['error_warning'] = $this->oc->language->get('error_stock');
} elseif (isset($this->oc->session->data['error'])) {
$data['error_warning'] = $this->oc->session->data['error'];
unset($this->oc->session->data['error']);
} else {
$data['error_warning'] = '';
}
if ($this->oc->config->get('config_customer_price') && ! $this->oc->customer->isLogged()) {
$data['attention'] = sprintf(
$this->oc->language->get('text_login'),
$this->oc->url->link('account/login'),
$this->oc->url->link('account/register')
);
} else {
$data['attention'] = '';
}
if (isset($this->oc->session->data['success'])) {
$data['success'] = $this->oc->session->data['success'];
unset($this->oc->session->data['success']);
} else {
$data['success'] = '';
}
if ($this->oc->config->get('config_cart_weight')) {
$data['weight'] = $this->oc->weight->format(
$this->oc->cart->getWeight(),
$this->oc->config->get('config_weight_class_id'),
$this->oc->language->get('decimal_point'),
$this->oc->language->get('thousand_point')
);
} else {
$data['weight'] = '';
}
$this->oc->load->model('tool/image');
$this->oc->load->model('tool/upload');
$data['products'] = array();
$products = $this->oc->cart->getProducts();
foreach ($products as $product) {
$product_total = 0;
foreach ($products as $product_2) {
if ($product_2['product_id'] == $product['product_id']) {
$product_total += $product_2['quantity'];
}
}
if ($product['minimum'] > $product_total) {
$data['error_warning'] = sprintf(
$this->oc->language->get('error_minimum'),
$product['name'],
$product['minimum']
);
}
if ($product['image']) {
$image = $this->oc->model_tool_image->resize(
$product['image'],
$this->oc->config->get('theme_' . $this->oc->config->get('config_theme') . '_image_cart_width'),
$this->oc->config->get('theme_' . $this->oc->config->get('config_theme') . '_image_cart_height')
);
} else {
$image = '';
}
$option_data = array();
foreach ($product['option'] as $option) {
if ($option['type'] != 'file') {
$value = $option['value'];
} else {
$upload_info = $this->oc->model_tool_upload->getUploadByCode($option['value']);
if ($upload_info) {
$value = $upload_info['name'];
} else {
$value = '';
}
}
$option_data[] = [
'product_option_id' => (int) $option['product_option_id'],
'product_option_value_id' => (int) $option['product_option_value_id'],
'name' => $option['name'],
'value' => (strlen($value) > 20 ? substr($value, 0, 20) . '..' : $value),
'type' => $option['type'],
];
}
$priceNumeric = 0;
$totalNumeric = 0;
// Display prices
if ($this->oc->customer->isLogged() || ! $this->oc->config->get('config_customer_price')) {
$unit_price = $this->oc->tax->calculate(
$product['price'],
$product['tax_class_id'],
$this->oc->config->get('config_tax')
);
$priceNumeric = $unit_price;
$totalNumeric = $unit_price * $product['quantity'];
$price = $this->currency->format($unit_price, $this->oc->session->data['currency']);
$total = $this->currency->format($totalNumeric, $this->oc->session->data['currency']);
} else {
$price = false;
$total = false;
}
$recurring = '';
if ($product['recurring']) {
$frequencies = array(
'day' => $this->oc->language->get('text_day'),
'week' => $this->oc->language->get('text_week'),
'semi_month' => $this->oc->language->get('text_semi_month'),
'month' => $this->oc->language->get('text_month'),
'year' => $this->oc->language->get('text_year')
);
if ($product['recurring']['trial']) {
$recurring = sprintf(
$this->oc->language->get('text_trial_description'),
$this->currency->format(
$this->oc->tax->calculate(
$product['recurring']['trial_price'] * $product['quantity'],
$product['tax_class_id'],
$this->oc->config->get('config_tax')
),
$this->oc->session->data['currency']
),
$product['recurring']['trial_cycle'],
$frequencies[$product['recurring']['trial_frequency']],
$product['recurring']['trial_duration']
) . ' ';
}
if ($product['recurring']['duration']) {
$recurring .= sprintf(
$this->oc->language->get('text_payment_description'),
$this->currency->format(
$this->oc->tax->calculate(
$product['recurring']['price'] * $product['quantity'],
$product['tax_class_id'],
$this->oc->config->get('config_tax')
),
$this->oc->session->data['currency']
),
$product['recurring']['cycle'],
$frequencies[$product['recurring']['frequency']],
$product['recurring']['duration']
);
} else {
$recurring .= sprintf(
$this->oc->language->get('text_payment_cancel'),
$this->currency->format(
$this->oc->tax->calculate(
$product['recurring']['price'] * $product['quantity'],
$product['tax_class_id'],
$this->oc->config->get('config_tax')
),
$this->oc->session->data['currency']
),
$product['recurring']['cycle'],
$frequencies[$product['recurring']['frequency']],
$product['recurring']['duration']
);
}
}
$data['products'][] = array(
'product_id' => (int) $product['product_id'],
'cart_id' => (int) $product['cart_id'],
'thumb' => $image,
'name' => $product['name'],
'model' => $product['model'],
'option' => $option_data,
'recurring' => $recurring,
'quantity' => (int) $product['quantity'],
'stock' => $product['stock'] ? true : ! (! $this->oc->config->get(
'config_stock_checkout'
) || $this->oc->config->get('config_stock_warning')),
'reward' => ($product['reward'] ? sprintf(
$this->oc->language->get('text_points'),
$product['reward']
) : ''),
'price' => $price,
'total' => $total,
'href' => $this->oc->url->link('product/product', 'product_id=' . $product['product_id']),
'price_numeric' => $priceNumeric,
'total_numeric' => $totalNumeric,
'reward_numeric' => $product['reward'] ?? 0,
);
}
// Totals
$this->oc->load->model('setting/extension');
$totals = array();
$taxes = $this->oc->cart->getTaxes();
$total = 0;
// Because __call can not keep var references so we put them into an array.
$total_data = array(
'totals' => &$totals,
'taxes' => &$taxes,
'total' => &$total
);
$sort_order = array();
$results = $this->oc->model_setting_extension->getExtensions('total');
foreach ($results as $key => $value) {
$sort_order[$key] = $this->oc->config->get('total_' . $value['code'] . '_sort_order');
}
array_multisort($sort_order, SORT_ASC, $results);
foreach ($results as $result) {
if ($this->oc->config->get('total_' . $result['code'] . '_status')) {
$this->oc->load->model('extension/total/' . $result['code']);
// We have to put the totals in an array so that they pass by reference.
$this->oc->{'model_extension_total_' . $result['code']}->getTotal($total_data);
}
}
$sort_order = array();
foreach ($totals as $key => $value) {
$sort_order[$key] = $value['sort_order'];
}
array_multisort($sort_order, SORT_ASC, $totals);
$data['totals'] = array();
foreach ($totals as $total) {
$data['totals'][] = [
'code' => $total['code'],
'title' => $total['title'],
'value' => $total['value'],
'sort_order' => $total['sort_order'],
'text' => $this->currency->format($total['value'], $this->oc->session->data['currency']),
];
}
$lastTotal = $totals[count($totals) - 1] ?? false;
$data['total'] = $lastTotal ? $lastTotal['value'] : 0;
$data['total_text'] = $lastTotal
? $this->currency->format($lastTotal['value'], $this->oc->session->data['currency'])
: 0;
$data['total_products_count'] = $this->oc->cart->countProducts();
} else {
$data['text_error'] = $this->oc->language->get('text_empty');
$data['totals'] = [];
$data['total'] = 0;
$data['total_text'] = '';
$data['products'] = [];
$data['total_products_count'] = 0;
unset($this->oc->session->data['success']);
}
return $data;
}
public function flush(): void
{
$this->cart->clear();
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Services;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
class OcCustomerService
{
private Builder $builder;
private ConnectionInterface $database;
public function __construct(Builder $builder, ConnectionInterface $database)
{
$this->builder = $builder;
$this->database = $database;
}
public function create(array $orderData, ?int $megapayCustomerId): ?int
{
$customerData = [
'customer_group_id' => $orderData['customer_group_id'],
'store_id' => $orderData['store_id'],
'language_id' => $orderData['language_id'],
'firstname' => $orderData['firstname'] ?? '',
'lastname' => $orderData['lastname'] ?? '',
'email' => $orderData['email'] ?? '',
'telephone' => $orderData['telephone'] ?? '',
'fax' => $orderData['fax'] ?? '',
'password' => bin2hex(random_bytes(16)),
'salt' => bin2hex(random_bytes(9)),
'ip' => $orderData['ip'] ?? '',
'status' => 1,
'safe' => 0,
'token' => bin2hex(random_bytes(32)),
'code' => '',
'date_added' => $orderData['date_added'],
];
$this->database->insert(db_table('customer'), $customerData);
$lastInsertId = $this->database->lastInsertId();
if ($megapayCustomerId) {
$this->builder
->where('id', '=', $megapayCustomerId)
->update('megapay_customers', [
'oc_customer_id' => $lastInsertId,
]);
}
return $lastInsertId;
}
public function findByMegapayCustomerId(int $telegramCustomerId): ?array
{
return $this->builder->newQuery()
->select(['oc_customers.*'])
->from(db_table('customer'), 'oc_customers')
->join('megapay_customers', function (JoinClause $join) {
$join->on('megapay_customers.oc_customer_id', '=', 'oc_customers.customer_id');
})
->where('megapay_customers.id', '=', $telegramCustomerId)
->firstOrNull();
}
public function findById(int $ocCustomerId): ?array
{
return $this->builder->newQuery()
->select(['oc_customers.*'])
->from(db_table('customer'), 'oc_customers')
->where('oc_customers.customer_id', '=', $ocCustomerId)
->firstOrNull();
}
public function findOrCreateByMegapayCustomerId(int $megapayCustomerId, array $orderData): ?array
{
$ocCustomer = $this->findByMegapayCustomerId($megapayCustomerId);
if (! $ocCustomer) {
$ocCustomerId = $this->create($orderData, $megapayCustomerId);
return $this->findById($ocCustomerId);
}
return $ocCustomer;
}
}

View File

@@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Carbon\Carbon;
use Exception;
use JsonException;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Telegram\TelegramService;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Throwable;
class OrderCreateService
{
private ConnectionInterface $database;
private CartService $cartService;
private OcRegistryDecorator $oc;
private SettingsService $settings;
private TelegramService $telegramService;
private LoggerInterface $logger;
private MegapayCustomerService $megapayCustomerService;
private OcCustomerService $ocCustomerService;
private OrderMetaService $orderMetaService;
public function __construct(
ConnectionInterface $database,
CartService $cartService,
OcRegistryDecorator $registry,
SettingsService $settings,
TelegramService $telegramService,
LoggerInterface $logger,
MegapayCustomerService $telegramCustomerService,
OcCustomerService $ocCustomerService,
OrderMetaService $orderMetaService
) {
$this->database = $database;
$this->cartService = $cartService;
$this->oc = $registry;
$this->settings = $settings;
$this->telegramService = $telegramService;
$this->logger = $logger;
$this->megapayCustomerService = $telegramCustomerService;
$this->ocCustomerService = $ocCustomerService;
$this->orderMetaService = $orderMetaService;
}
/**
* @throws Throwable
* @throws JsonException
*/
public function create(array $data, array $meta = []): array
{
$now = Carbon::now();
$storeId = $this->settings->get('store.oc_store_id');
$storeName = $this->settings->config()->getApp()->getAppName();
$orderStatusId = $this->settings->config()->getOrders()->getOrderDefaultStatusId();
$customerGroupId = $this->settings->config()->getOrders()->getOcCustomerGroupId();
$languageId = $this->settings->config()->getApp()->getLanguageId();
$currencyId = $this->oc->currency->getId($this->oc->session->data['currency']);
$currencyCode = $this->oc->session->data['currency'];
$currencyValue = $this->oc->currency->getValue($this->oc->session->data['currency']);
$cart = $this->cartService->getCart();
$total = $cart['total'] ?? 0;
$products = $cart['products'] ?? [];
$totals = $cart['totals'] ?? [];
// Получаем telegram_user_id из tgData
$telegramUserId = Arr::get($data['tgData'] ?? [], 'user.id');
$telegramUserdata = Arr::get($data['tgData'] ?? [], 'user');
if (! $telegramUserId) {
throw new RuntimeException('Telegram user id is required.');
}
$customOrderFields = $this->customOrderFields($data);
$orderData = [
'store_id' => $storeId,
'store_name' => $storeName,
'firstname' => $data['firstname'] ?? '',
'lastname' => $data['lastname'] ?? '',
'email' => $data['email'] ?? '',
'telephone' => $data['telephone'] ?? '',
'comment' => $data['comment'] ?? '',
'payment_method' => $data['payment_method'] ?? '',
'shipping_address_1' => $data['shipping_address_1'] ?? '',
'shipping_city' => $data['shipping_city'] ?? '',
'shipping_zone' => $data['shipping_zone'] ?? '',
'shipping_postcode' => $data['shipping_postcode'] ?? '',
'total' => $total,
'order_status_id' => $orderStatusId,
'ip' => $meta['ip'] ?? '',
'forwarded_ip' => $meta['ip'] ?? '',
'user_agent' => $meta['user_agent'] ?? '',
'date_added' => $now,
'date_modified' => $now,
'language_id' => $languageId,
'currency_id' => $currencyId,
'currency_code' => $currencyCode,
'currency_value' => $currencyValue,
'customer_group_id' => $customerGroupId,
];
try {
$this->database->beginTransaction();
$megapayCustomer = $this->megapayCustomerService->saveOrUpdate($telegramUserdata);
$megapayCustomerId = (int) $megapayCustomer['id'];
$ocCustomer = $this->ocCustomerService->findOrCreateByMegapayCustomerId($megapayCustomerId, $orderData);
$ocCustomerId = (int) $ocCustomer['customer_id'];
$orderData['customer_id'] = $ocCustomerId;
$this->database->insert(db_table('order'), $orderData);
$orderId = $this->database->lastInsertId();
// Insert products
$this->insertProducts($products, $orderId);
// Insert totals
$this->insertTotals($totals, $orderId);
// Insert history
$this->insertHistory($orderId, $orderStatusId, $customOrderFields, $now);
// Insert order meta data
if ($customOrderFields) {
$this->orderMetaService->insert($orderId, $storeId, $customOrderFields, $megapayCustomerId);
}
$this->megapayCustomerService->increaseOrdersCount($megapayCustomerId);
$this->database->commitTransaction();
} catch (Throwable $exception) {
$this->database->rollBackTransaction();
throw $exception;
}
$this->cartService->flush();
$orderData['order_id'] = $orderId;
$orderData['total_numeric'] = $orderData['total'] ?? 0;
$orderData['total'] = $cart['total_text'] ?? '';
$this->sendNotifications($orderData, $data['tgData']);
$dateTimeFormatted = '';
try {
$dateTimeFormatted = $now->format('d.m.Y H:i');
} catch (Exception $exception) {
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
}
return [
'id' => $orderData['order_id'],
'created_at' => $dateTimeFormatted,
'total' => $orderData['total'],
'final_total_numeric' => $orderData['total_numeric'],
'currency' => $currencyCode,
'products' => $products,
];
}
private function sendNotifications(array $orderData, array $tgInitData): void
{
$variables = [
'{store_name}' => $orderData['store_name'],
'{order_id}' => $orderData['order_id'],
'{customer}' => $orderData['firstname'] . ' ' . $orderData['lastname'],
'{email}' => $orderData['email'],
'{phone}' => $orderData['telephone'],
'{comment}' => $orderData['comment'],
'{address}' => $orderData['shipping_address_1'],
'{total}' => $orderData['total'],
'{ip}' => $orderData['ip'],
'{created_at}' => $orderData['date_added'],
];
$chatId = $this->settings->config()->getTelegram()->getChatId();
$template = $this->settings->config()->getTelegram()->getOwnerNotificationTemplate();
if ($chatId && $template) {
$message = $this->telegramService->prepareMessage($template, $variables);
try {
$this->telegramService->sendMessage($chatId, $message);
} catch (Throwable $exception) {
$this->logger->error(
'Telegram sendMessage to owner error.',
[
'exception' => $exception,
'chat_id' => $chatId,
'message' => $message,
],
);
}
}
$allowsWriteToPm = Arr::get($tgInitData, 'user.allows_write_to_pm', false);
$customerChatId = Arr::get($tgInitData, 'user.id');
$template = $this->settings->config()->getTelegram()->getCustomerNotificationTemplate();
if ($allowsWriteToPm && $customerChatId && $template) {
$message = $this->telegramService->prepareMessage($template, $variables);
try {
$this->telegramService->sendMessage($customerChatId, $message);
} catch (Throwable $exception) {
$this->logger->error(
"Telegram sendMessage to customer error.",
[
'exception' => $exception,
'chat_id' => $chatId,
'message' => $message,
],
);
}
}
}
private function formatHistoryComment(array $customFields): string
{
$additionalString = '';
if ($customFields) {
$additionalString = "\n\nДополнительная информация по заказу:\n";
foreach ($customFields as $field => $value) {
$additionalString .= $field . ': ' . $value . "\n";
}
}
return "Заказ оформлен через Telegram Mini App.$additionalString";
}
private function customOrderFields(array $data): array
{
return Arr::except($data, [
'firstname',
'lastname',
'email',
'telephone',
'comment',
'shipping_address_1',
'shipping_city',
'shipping_zone',
'shipping_postcode',
'payment_method',
'tgData',
]);
}
public function insertTotals(array $totals, int $orderId): void
{
foreach ($totals as $total) {
$this->database->insert(db_table('order_total'), [
'order_id' => $orderId,
'code' => $total['code'],
'title' => $total['title'],
'value' => $total['value'],
'sort_order' => $total['sort_order'],
]);
}
}
/**
* @param int $orderId
* @param int $orderStatusId
* @param array $customOrderFields
* @param Carbon $now
* @return void
*/
public function insertHistory(int $orderId, int $orderStatusId, array $customOrderFields, Carbon $now): void
{
$history = [
'order_id' => $orderId,
'order_status_id' => $orderStatusId,
'notify' => 0,
'comment' => $this->formatHistoryComment($customOrderFields),
'date_added' => $now,
];
$this->database->insert(db_table('order_history'), $history);
}
private function insertProducts($products, int $orderId): void
{
foreach ($products as $product) {
$this->database->insert(db_table('order_product'), [
'order_id' => $orderId,
'product_id' => $product['product_id'],
'name' => $product['name'],
'model' => $product['model'],
'quantity' => $product['quantity'],
'price' => $product['price_numeric'],
'total' => $product['total_numeric'],
'reward' => $product['reward_numeric'],
]);
$orderProductId = $this->database->lastInsertId();
foreach ($product['option'] as $option) {
$this->database->insert(db_table('order_option'), [
'order_id' => $orderId,
'order_product_id' => $orderProductId,
'product_option_id' => $option['product_option_id'],
'product_option_value_id' => $option['product_option_value_id'],
'name' => $option['name'],
'value' => $option['value'],
'type' => $option['type'],
]);
}
}
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Services;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
class OrderMetaService
{
private ConnectionInterface $connection;
public function __construct(ConnectionInterface $connection)
{
$this->connection = $connection;
}
public function insert(int $orderId, int $storeId, array $fields, ?int $megapayCustomerId = null): void
{
$orderMeta = [
'oc_order_id' => $orderId,
'oc_store_id' => $storeId,
'megapay_customer_id' => $megapayCustomerId,
'meta_data' => json_encode($fields, JSON_THROW_ON_ERROR),
];
$this->connection->insert('megapay_order_meta', $orderMeta);
}
}

View File

@@ -0,0 +1,494 @@
<?php
namespace App\Services;
use Cart\Currency;
use Cart\Tax;
use Exception;
use Openguru\OpenCartFramework\CriteriaBuilder\CriteriaBuilder;
use Openguru\OpenCartFramework\Exceptions\EntityNotFoundException;
use Openguru\OpenCartFramework\ImageTool\ImageFactory;
use Openguru\OpenCartFramework\ImageTool\ImageNotFoundException;
use Openguru\OpenCartFramework\ImageTool\ImageUtils;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
use Openguru\OpenCartFramework\OpenCart\PriceCalculator;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
use Openguru\OpenCartFramework\QueryBuilder\Table;
use Openguru\OpenCartFramework\Sentry\SentryService;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Support\PaginationHelper;
use Openguru\OpenCartFramework\Support\Str;
use Psr\Log\LoggerInterface;
class ProductsService
{
private Builder $queryBuilder;
private Currency $currency;
private Tax $tax;
private SettingsService $settings;
private ImageFactory $image;
private OcRegistryDecorator $oc;
private LoggerInterface $logger;
private CriteriaBuilder $criteriaBuilder;
private PriceCalculator $priceCalculator;
public function __construct(
Builder $queryBuilder,
Currency $currency,
Tax $tax,
SettingsService $settings,
ImageFactory $image,
OcRegistryDecorator $registry,
LoggerInterface $logger,
CriteriaBuilder $criteriaBuilder,
PriceCalculator $priceCalculator
) {
$this->queryBuilder = $queryBuilder;
$this->currency = $currency;
$this->tax = $tax;
$this->settings = $settings;
$this->image = $image;
$this->oc = $registry;
$this->logger = $logger;
$this->criteriaBuilder = $criteriaBuilder;
$this->priceCalculator = $priceCalculator;
}
/**
* @throws ImageNotFoundException
*/
public function getProductsResponse(array $params, int $languageId, int $storeId): array
{
$page = $params['page'];
$perPage = $params['perPage'];
$search = $params['search'] ?? false;
$categoryName = '';
$maxPages = 200;
$filters = $params['filters'] ?? [];
$aspectRatio = $this->settings->get('app.image_aspect_ratio', '1:1');
$cropAlgorithm = $this->settings->get('app.image_crop_algorithm', 'cover');
[$imageWidth, $imageHeight] = ImageUtils::aspectRatioToSize($aspectRatio);
$customerGroupId = $this->settings->config()->getOrders()->getOcCustomerGroupId();
$currency = $this->settings->config()->getStore()->getOcDefaultCurrency();
$specialPriceSql = "(SELECT price
FROM oc_product_special ps
WHERE ps.product_id = products.product_id
AND ps.customer_group_id = $customerGroupId
AND ((ps.date_start = '0000-00-00' OR ps.date_start < NOW()) AND
(ps.date_end = '0000-00-00' OR ps.date_end > NOW()))
ORDER BY ps.priority ASC, ps.price ASC
LIMIT 1) AS special";
$productsQuery = $this->queryBuilder->newQuery()
->select([
'products.product_id' => 'product_id',
'products.quantity' => 'product_quantity',
'product_description.name' => 'product_name',
'products.price' => 'price',
'products.image' => 'product_image',
'products.tax_class_id' => 'tax_class_id',
'manufacturer.name' => 'manufacturer_name',
'category_description.name' => 'category_name',
new RawExpression($specialPriceSql),
])
->from(db_table('product'), 'products')
->join(
db_table('product_description') . ' AS product_description',
function (JoinClause $join) use ($languageId) {
$join->on('products.product_id', '=', 'product_description.product_id')
->where('product_description.language_id', '=', $languageId);
}
)
->join(
new Table(db_table('product_to_store'), 'product_to_store'),
function (JoinClause $join) use ($storeId) {
$join->on('product_to_store.product_id', '=', 'products.product_id')
->where('product_to_store.store_id', '=', $storeId);
}
)
->leftJoin(new Table(db_table('manufacturer'), 'manufacturer'), function (JoinClause $join) {
$join->on('products.manufacturer_id', '=', 'manufacturer.manufacturer_id');
})
->leftJoin(new Table(db_table('product_to_category'), 'product_to_category'), function (JoinClause $join) {
$join->on('products.product_id', '=', 'product_to_category.product_id')
->where('product_to_category.main_category', '=', 1);
})
->leftJoin(
new Table(db_table('category_description'), 'category_description'),
function (JoinClause $join) use ($languageId) {
$join->on('product_to_category.category_id', '=', 'category_description.category_id')
->where('category_description.language_id', '=', $languageId);
}
)
->where('products.status', '=', 1)
->whereRaw('products.date_available < NOW()')
->when($search, function (Builder $query) use ($search) {
$query->where('product_description.name', 'LIKE', '%' . $search . '%');
});
$this->criteriaBuilder->apply($productsQuery, $filters);
$total = $productsQuery->count();
$lastPage = min(PaginationHelper::calculateLastPage($total, $perPage), $maxPages);
$hasMore = $page + 1 <= $lastPage;
$products = $productsQuery
->forPage($page, $perPage)
->orderBy('date_modified', 'DESC')
->get();
$productIds = Arr::pluck($products, 'product_id');
$productsImages = [];
if ($productIds) {
$productsImages = $this->queryBuilder->newQuery()
->select([
'products_images.product_id' => 'product_id',
'products_images.image' => 'image',
])
->from(db_table('product_image'), 'products_images')
->orderBy('products_images.sort_order')
->whereIn('product_id', $productIds)
->get();
}
$span = SentryService::startSpan('crop_images', 'image.process');
$productsImagesMap = [];
foreach ($productsImages as $item) {
$productId = $item['product_id'];
// Ограничиваем количество картинок для каждого товара до 3
if (! isset($productsImagesMap[$productId])) {
$productsImagesMap[$productId] = [];
}
if (count($productsImagesMap[$productId]) < 2) {
$productsImagesMap[$productId][] = [
'url' => $this->image->make($item['image'])->crop($cropAlgorithm, $imageWidth, $imageHeight)->url(),
'alt' => 'Product Image',
];
}
}
SentryService::endSpan($span);
$debug = [];
if (env('APP_DEBUG')) {
$debug = [
'sql' => $productsQuery->toRawSql(),
];
}
return [
'data' => array_map(
function ($product) use ($productsImagesMap, $cropAlgorithm, $imageWidth, $imageHeight, $currency) {
$allImages = [];
$image = $this->image->make($product['product_image'], false)
->crop($cropAlgorithm, $imageWidth, $imageHeight)
->url();
$allImages[] = [
'url' => $image,
'alt' => Str::htmlEntityEncode($product['product_name']),
];
$price = $this->priceCalculator->format($product['price'], $product['tax_class_id']);
$priceNumeric = $this->priceCalculator->getPriceNumeric(
$product['price'],
$product['tax_class_id']
);
$special = false;
$specialPriceNumeric = null;
if ($product['special'] && (float) $product['special'] >= 0) {
$specialPriceNumeric = $this->tax->calculate(
$product['special'],
$product['tax_class_id'],
$this->settings->config()->getStore()->isOcConfigTax(),
);
$special = $this->currency->format(
$specialPriceNumeric,
$currency,
);
}
if (! empty($productsImagesMap[$product['product_id']])) {
$allImages = array_merge($allImages, $productsImagesMap[$product['product_id']]);
}
return [
'id' => (int) $product['product_id'],
'product_quantity' => (int) $product['product_quantity'],
'name' => Str::htmlEntityEncode($product['product_name']),
'price' => $price,
'special' => $special,
'image' => $image,
'images' => $allImages,
'special_numeric' => $specialPriceNumeric,
'price_numeric' => $priceNumeric,
'final_price_numeric' => $specialPriceNumeric ?: $priceNumeric,
'manufacturer_name' => $product['manufacturer_name'],
'category_name' => $product['category_name'],
];
},
$products
),
'meta' => [
'currentCategoryName' => $categoryName,
'hasMore' => $hasMore,
'debug' => $debug,
'total' => $total,
]
];
}
/**
* @throws EntityNotFoundException
* @throws Exception
*/
public function getProductById(int $productId): array
{
$this->oc->load->language('product/product');
$this->oc->load->model('catalog/category');
$this->oc->load->model('catalog/manufacturer');
$this->oc->load->model('catalog/product');
$this->oc->load->model('catalog/review');
$this->oc->load->model('tool/image');
$configTax = $this->oc->config->get('config_tax');
$product_info = $this->oc->model_catalog_product->getProduct($productId);
$currency = $this->oc->session->data['currency'];
if (! $product_info) {
throw new EntityNotFoundException('Product with id ' . $productId . ' not found');
}
$data = [];
$data['text_minimum'] = sprintf($this->oc->language->get('text_minimum'), $product_info['minimum']);
$data['tab_review'] = sprintf($this->oc->language->get('tab_review'), $product_info['reviews']);
$data['product_id'] = $productId;
$data['name'] = Str::htmlEntityEncode($product_info['name']);
$data['manufacturer'] = $product_info['manufacturer'];
$data['model'] = $product_info['model'];
$data['reward'] = $product_info['reward'];
$data['points'] = (int) $product_info['points'];
$data['description'] = Str::htmlEntityEncode($product_info['description']);
$data['share'] = Str::htmlEntityEncode(
$this->oc->url->link('product/product', [
'product_id' => $productId,
'utm_source' => 'megapay',
'utm_medium' => 'telegram',
'utm_campaign' => 'product_click',
'utm_content' => 'product_button',
]),
);
if ($product_info['quantity'] <= 0) {
$data['stock'] = $product_info['stock_status'];
} elseif ($this->oc->config->get('config_stock_display')) {
$data['stock'] = $product_info['quantity'];
} else {
$data['stock'] = $this->oc->language->get('text_instock');
}
$data['images'] = [];
$price = $this->priceCalculator->format($product_info['price'], $product_info['tax_class_id']);
$priceNumeric = $this->priceCalculator->getPriceNumeric($product_info['price'], $product_info['tax_class_id']);
$data['price'] = $price;
$data['currency'] = $currency;
$data['final_price_numeric'] = $priceNumeric;
if (! is_null($product_info['special']) && (float) $product_info['special'] >= 0) {
$productSpecialPrice = $this->tax->calculate(
$product_info['special'],
$product_info['tax_class_id'],
$configTax,
);
$data['special'] = $this->currency->format($productSpecialPrice, $currency);
$data['final_price_numeric'] = $productSpecialPrice;
$tax_price = (float) $product_info['special'];
} else {
$data['special'] = false;
$tax_price = (float) $product_info['price'];
}
if ($configTax) {
$data['tax'] = $this->currency->format($tax_price, $currency);
} else {
$data['tax'] = false;
}
$discounts = $this->oc->model_catalog_product->getProductDiscounts($productId);
$data['discounts'] = [];
foreach ($discounts as $discount) {
$data['discounts'][] = array(
'quantity' => $discount['quantity'],
'price' => $this->currency->format(
$this->tax->calculate(
$discount['price'],
$product_info['tax_class_id'],
$configTax,
),
$currency
)
);
}
$data['options'] = [];
foreach ($this->oc->model_catalog_product->getProductOptions($productId) as $option) {
$product_option_value_data = [];
foreach ($option['product_option_value'] as $option_value) {
if (! $option_value['subtract'] || ($option_value['quantity'] > 0)) {
$price = $this->currency->format(
$this->tax->calculate(
$option_value['price'],
$product_info['tax_class_id'],
$configTax ? 'P' : false
),
$currency
);
$product_option_value_data[] = array(
'product_option_value_id' => (int) $option_value['product_option_value_id'],
'option_value_id' => (int) $option_value['option_value_id'],
'name' => $option_value['name'],
'image' => $this->oc->model_tool_image->resize($option_value['image'], 50, 50),
'price' => $price,
'price_prefix' => $option_value['price_prefix'],
'selected' => false,
);
}
}
$data['options'][] = array(
'product_option_id' => $option['product_option_id'],
'product_option_value' => $product_option_value_data,
'option_id' => $option['option_id'],
'name' => $option['name'],
'type' => $option['type'],
'value' => $option['value'],
'required' => filter_var($option['required'], FILTER_VALIDATE_BOOLEAN),
);
}
if ($product_info['minimum']) {
$data['minimum'] = (int) $product_info['minimum'];
} else {
$data['minimum'] = 1;
}
$data['review_status'] = $this->oc->config->get('config_review_status');
$data['review_guest'] = true;
$data['customer_name'] = 'John Doe';
$data['reviews'] = sprintf($this->oc->language->get('text_reviews'), (int) $product_info['reviews']);
$data['rating'] = (int) $product_info['rating'];
$data['attribute_groups'] = $this->oc->model_catalog_product->getProductAttributes($productId);
$data['tags'] = array();
if ($product_info['tag']) {
$tags = explode(',', $product_info['tag']);
foreach ($tags as $tag) {
$data['tags'][] = array(
'tag' => trim($tag),
'href' => $this->oc->url->link('product/search', 'tag=' . trim($tag))
);
}
}
$data['recurrings'] = $this->oc->model_catalog_product->getProfiles($productId);
$data['category'] = $this->getProductMainCategory($productId);
$data['id'] = $productId;
$this->oc->model_catalog_product->updateViewed($productId);
return $data;
}
private function getProductMainCategory(int $productId): ?array
{
return $this->queryBuilder->newQuery()
->select([
'category_description.category_id' => 'id',
'category_description.name' => 'name',
])
->from(db_table('category_description'), 'category_description')
->join(new Table(db_table('product_to_category'), 'product_to_category'), function (JoinClause $join) {
$join->on('product_to_category.category_id', '=', 'category_description.category_id')
->where('product_to_category.main_category', '=', 1);
})
->where('product_to_category.product_id', '=', $productId)
->firstOrNull();
}
public function getProductImages(int $productId): array
{
$aspectRatio = $this->settings->get('app.image_aspect_ratio', '1:1');
$cropAlgorithm = $this->settings->get('app.image_crop_algorithm', 'cover');
[$imageWidth, $imageHeight] = ImageUtils::aspectRatioToSize($aspectRatio);
$imageFullWidth = 1000;
$imageFullHeight = 1000;
$product_info = $this->oc->model_catalog_product->getProduct($productId);
if (! $product_info) {
throw new EntityNotFoundException('Product with id ' . $productId . ' not found');
}
$allImages = [];
if ($product_info['image']) {
$allImages[] = $product_info['image'];
}
$results = $this->oc->model_catalog_product->getProductImages($productId);
foreach ($results as $result) {
$allImages[] = $result['image'];
}
$images = [];
foreach ($allImages as $imagePath) {
try {
[$width, $height] = $this->image->make($imagePath)->getRealSize();
$images[] = [
'thumbnailURL' => $this->image->make($imagePath)
->crop($cropAlgorithm, $imageWidth, $imageHeight)
->url(),
'largeURL' => $this->image->make($imagePath)->resize($imageFullWidth, $imageFullHeight)->url(),
'width' => $width,
'height' => $height,
'alt' => Str::htmlEntityEncode($product_info['name']),
];
} catch (Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
}
}
return $images;
}
}

View File

@@ -0,0 +1,449 @@
<?php
namespace App\Services;
use App\DTO\Settings\AppDTO;
use App\DTO\Settings\ConfigDTO;
use App\DTO\Settings\DatabaseDTO;
use App\DTO\Settings\LogsDTO;
use App\DTO\Settings\MetricsDTO;
use App\DTO\Settings\OrdersDTO;
use App\DTO\Settings\StoreDTO;
use App\DTO\Settings\TelegramDTO;
use App\DTO\Settings\TextsDTO;
use InvalidArgumentException;
class SettingsSerializerService
{
public function fromArray(array $data): ConfigDTO
{
$keys = ['app', 'telegram', 'metrics', 'store', 'orders', 'texts', 'database', 'logs'];
foreach ($keys as $key) {
if (! array_key_exists($key, $data)) {
throw new InvalidArgumentException("Settings key '$key' is required!");
}
}
$this->validateApp($data['app']);
$this->validateTelegram($data['telegram']);
$this->validateMetrics($data['metrics']);
$this->validateStore($data['store']);
$this->validateOrders($data['orders']);
$this->validateTexts($data['texts']);
$this->validateDatabase($data['database']);
$this->validateLogs($data['logs']);
return new ConfigDTO(
$this->deserializeApp($data['app']),
$this->deserializeTelegram($data['telegram']),
$this->deserializeMetrics($data['metrics']),
$this->deserializeStore($data['store']),
$this->deserializeOrders($data['orders']),
$this->deserializeTexts($data['texts']),
$this->deserializeDatabase($data['database']),
$this->deserializeLogs($data['logs']),
);
}
/**
* @throws \JsonException
*/
public function serialize(ConfigDTO $settings): string
{
return json_encode($settings->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
private function deserializeApp(array $data): AppDTO
{
if (! isset($data['language_id'])) {
throw new InvalidArgumentException('app.language_id is required');
}
if (! is_numeric($data['language_id'])) {
throw new InvalidArgumentException('app.language_id must be an integer');
}
if (! isset($data['shop_base_url'])) {
throw new InvalidArgumentException('app.shop_base_url is required');
}
if (! is_string($data['shop_base_url'])) {
throw new InvalidArgumentException('app.shop_base_url must be a string');
}
return new AppDTO(
$data['app_enabled'] ?? false,
$data['app_name'] ?? '',
$data['app_icon'] ?? null,
$data['theme_light'] ?? 'light',
$data['theme_dark'] ?? 'dark',
$data['app_debug'] ?? false,
$data['language_id'],
$data['shop_base_url'],
$data['haptic_enabled'] ?? true
);
}
private function deserializeTelegram(array $data): TelegramDTO
{
if (! isset($data['mini_app_url'])) {
throw new InvalidArgumentException('telegram.mini_app_url is required');
}
if (! is_string($data['mini_app_url'])) {
throw new InvalidArgumentException('telegram.mini_app_url must be a string');
}
return new TelegramDTO(
$data['bot_token'],
$data['chat_id'],
$data['owner_notification_template'],
$data['customer_notification_template'],
$data['mini_app_url']
);
}
private function deserializeMetrics(array $data): MetricsDTO
{
return new MetricsDTO(
$data['yandex_metrika_enabled'] ?? false,
$data['yandex_metrika_counter'] ?? ''
);
}
private function deserializeStore(array $data): StoreDTO
{
if (! isset($data['oc_default_currency'])) {
throw new InvalidArgumentException('store.oc_default_currency is required');
}
if (! is_string($data['oc_default_currency'])) {
throw new InvalidArgumentException('store.oc_default_currency must be a string');
}
if (! isset($data['oc_config_tax'])) {
throw new InvalidArgumentException('store.oc_config_tax is required');
}
if (! is_bool($data['oc_config_tax'])) {
throw new InvalidArgumentException('store.oc_config_tax must be a boolean');
}
if (! isset($data['oc_store_id'])) {
throw new InvalidArgumentException('store.oc_store_id is required');
}
if (! is_numeric($data['oc_store_id'])) {
throw new InvalidArgumentException('store.oc_store_id must be an integer');
}
return new StoreDTO(
$data['feature_coupons'] ?? true,
$data['feature_vouchers'] ?? true,
$data['show_category_products_button'] ?? true,
$data['product_interaction_mode'] ?? 'browser',
$data['manager_username'] ?? null,
$data['oc_default_currency'],
$data['oc_config_tax'],
$data['oc_store_id']
);
}
private function deserializeOrders(array $data): OrdersDTO
{
if (! isset($data['oc_customer_group_id'])) {
throw new InvalidArgumentException('orders.oc_customer_group_id is required');
}
if (! is_numeric($data['oc_customer_group_id'])) {
throw new InvalidArgumentException('orders.oc_customer_group_id must be an integer');
}
return new OrdersDTO(
$data['order_default_status_id'] ?? 1,
$data['oc_customer_group_id']
);
}
private function deserializeTexts(array $data): TextsDTO
{
return new TextsDTO(
$data['text_no_more_products'],
$data['text_empty_cart'],
$data['text_order_created_success'],
$data['text_manager_button'] ?? ''
);
}
// ==================== Validation Methods ====================
private function validateApp(array $data): void
{
if (! is_bool($data['app_enabled'])) {
throw new InvalidArgumentException('app.app_enabled must be a boolean');
}
if (! is_string($data['app_name'])) {
throw new InvalidArgumentException('app.app_name must be a string');
}
if (isset($data['app_icon']) && ! is_string($data['app_icon'])) {
throw new InvalidArgumentException('app.app_icon must be a string or null');
}
if (! is_string($data['theme_light'])) {
throw new InvalidArgumentException('app.theme_light must be a string');
}
if (! is_string($data['theme_dark'])) {
throw new InvalidArgumentException('app.theme_dark must be a string');
}
if (! is_bool($data['app_debug'])) {
throw new InvalidArgumentException('app.app_debug must be a boolean');
}
if (! isset($data['language_id'])) {
throw new InvalidArgumentException('app.language_id is required');
}
if (! is_numeric($data['language_id'])) {
throw new InvalidArgumentException('app.language_id must be an integer');
}
if ($data['language_id'] <= 0) {
throw new InvalidArgumentException('app.language_id must be a positive integer');
}
if (! isset($data['shop_base_url'])) {
throw new InvalidArgumentException('app.shop_base_url is required');
}
if (! is_string($data['shop_base_url'])) {
throw new InvalidArgumentException('app.shop_base_url must be a string');
}
}
private function validateTelegram(array $data): void
{
if (isset($data['bot_token']) && ! is_string($data['bot_token'])) {
throw new InvalidArgumentException('telegram.bot_token must be a string or null');
}
if (isset($data['chat_id']) && ! is_numeric($data['chat_id'])) {
throw new InvalidArgumentException('telegram.chat_id must be an integer or null');
}
if (
isset($data['owner_notification_template']) && ! is_string(
$data['owner_notification_template']
)
) {
throw new InvalidArgumentException('telegram.owner_notification_template must be a string or null');
}
if (
isset($data['customer_notification_template']) && ! is_string(
$data['customer_notification_template']
)
) {
throw new InvalidArgumentException('telegram.customer_notification_template must be a string or null');
}
if (! isset($data['mini_app_url'])) {
throw new InvalidArgumentException('telegram.mini_app_url is required');
}
if (! is_string($data['mini_app_url'])) {
throw new InvalidArgumentException('telegram.mini_app_url must be a string');
}
}
private function validateMetrics(array $data): void
{
if (isset($data['yandex_metrika_enabled']) && ! is_bool($data['yandex_metrika_enabled'])) {
throw new InvalidArgumentException('metrics.yandex_metrika_enabled must be a boolean');
}
if (isset($data['yandex_metrika_counter']) && ! is_string($data['yandex_metrika_counter'])) {
throw new InvalidArgumentException('metrics.yandex_metrika_counter must be a string');
}
}
private function validateStore(array $data): void
{
// enable_store больше не валидируется, так как заменен на product_interaction_mode
if (isset($data['feature_coupons']) && ! is_bool($data['feature_coupons'])) {
throw new InvalidArgumentException('store.feature_coupons must be a boolean');
}
if (isset($data['feature_vouchers']) && ! is_bool($data['feature_vouchers'])) {
throw new InvalidArgumentException('store.feature_vouchers must be a boolean');
}
if (isset($data['show_category_products_button']) && ! is_bool($data['show_category_products_button'])) {
throw new InvalidArgumentException('store.show_category_products_button must be a boolean');
}
if (isset($data['product_interaction_mode']) && ! is_string($data['product_interaction_mode'])) {
throw new InvalidArgumentException('store.product_interaction_mode must be a string');
}
if (
isset($data['product_interaction_mode'])
&& ! in_array($data['product_interaction_mode'], ['order', 'manager', 'browser'], true)
) {
throw new InvalidArgumentException(
'store.product_interaction_mode must be one of: order, manager, browser'
);
}
if (isset($data['manager_username']) && $data['manager_username'] !== null) {
if (! is_string($data['manager_username'])) {
throw new InvalidArgumentException('store.manager_username must be a string or null');
}
// Проверяем, что это username (не числовой ID)
$managerUsername = trim($data['manager_username']);
if ($managerUsername !== '' && preg_match('/^-?\d+$/', $managerUsername)) {
throw new InvalidArgumentException(
'store.manager_username must be a username (e.g., @username), not a numeric ID'
);
}
}
if (! isset($data['oc_default_currency'])) {
throw new InvalidArgumentException('store.oc_default_currency is required');
}
if (! is_string($data['oc_default_currency'])) {
throw new InvalidArgumentException('store.oc_default_currency must be a string');
}
if (! isset($data['oc_config_tax'])) {
throw new InvalidArgumentException('store.oc_config_tax is required');
}
if (! is_bool($data['oc_config_tax'])) {
throw new InvalidArgumentException('store.oc_config_tax must be a boolean');
}
if (! isset($data['oc_store_id'])) {
throw new InvalidArgumentException('store.oc_store_id is required');
}
if (! is_numeric($data['oc_store_id'])) {
throw new InvalidArgumentException('store.oc_store_id must be an integer');
}
if ($data['oc_store_id'] < 0) {
throw new InvalidArgumentException('store.oc_store_id must be a positive integer or equals 0');
}
}
private function validateOrders(array $data): void
{
if (isset($data['order_default_status_id'])) {
if (! is_numeric($data['order_default_status_id'])) {
throw new InvalidArgumentException('orders.order_default_status_id must be an integer');
}
if ($data['order_default_status_id'] <= 0) {
throw new InvalidArgumentException('orders.order_default_status_id must be a positive integer');
}
}
if (! isset($data['oc_customer_group_id'])) {
throw new InvalidArgumentException('orders.oc_customer_group_id is required');
}
if (! is_numeric($data['oc_customer_group_id'])) {
throw new InvalidArgumentException('orders.oc_customer_group_id must be an integer');
}
if ($data['oc_customer_group_id'] <= 0) {
throw new InvalidArgumentException('orders.oc_customer_group_id must be a positive integer');
}
}
private function validateTexts(array $data): void
{
if (isset($data['text_no_more_products']) && ! is_string($data['text_no_more_products'])) {
throw new InvalidArgumentException('texts.text_no_more_products must be a string');
}
if (isset($data['text_empty_cart']) && ! is_string($data['text_empty_cart'])) {
throw new InvalidArgumentException('texts.text_empty_cart must be a string');
}
if (isset($data['text_order_created_success']) && ! is_string($data['text_order_created_success'])) {
throw new InvalidArgumentException('texts.text_order_created_success must be a string');
}
if (isset($data['text_manager_button']) && ! is_string($data['text_manager_button'])) {
throw new InvalidArgumentException('texts.text_manager_button must be a string');
}
}
private function deserializeLogs(array $logs): LogsDTO
{
return new LogsDTO(
$logs['path'],
);
}
private function deserializeDatabase(array $data): DatabaseDTO
{
return new DatabaseDTO(
$data['host'] ?? '',
$data['database'] ?? '',
$data['username'] ?? '',
$data['password'] ?? '',
$data['prefix'] ?? '',
$data['port'] ?? 3306
);
}
private function validateDatabase(array $data): void
{
if (isset($data['host']) && ! is_string($data['host'])) {
throw new InvalidArgumentException('database.host must be a string');
}
if (isset($data['database']) && ! is_string($data['database'])) {
throw new InvalidArgumentException('database.database must be a string');
}
if (isset($data['username']) && ! is_string($data['username'])) {
throw new InvalidArgumentException('database.username must be a string');
}
if (isset($data['password']) && ! is_string($data['password'])) {
throw new InvalidArgumentException('database.password must be a string');
}
if (isset($data['prefix']) && ! is_string($data['prefix'])) {
throw new InvalidArgumentException('database.prefix must be a string');
}
if (isset($data['port'])) {
if (is_string($data['port']) && ctype_digit($data['port'])) {
$data['port'] = (int) $data['port'];
}
if (! is_numeric($data['port'])) {
throw new InvalidArgumentException('database.port must be an integer');
}
if ($data['port'] <= 0 || $data['port'] > 65535) {
throw new InvalidArgumentException('database.port must be between 1 and 65535');
}
}
}
private function validateLogs(array $logs): void
{
if (! isset($logs['path'])) {
throw new InvalidArgumentException('Logs path must be set');
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Services;
use App\DTO\Settings\ConfigDTO;
use Openguru\OpenCartFramework\Config\Settings;
class SettingsService extends Settings
{
private ConfigDTO $config;
public function __construct(array $config, SettingsSerializerService $serializer)
{
parent::__construct($config);
$this->config = $serializer->fromArray($config);
}
public function config(): ConfigDTO
{
return $this->config;
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\TelegramCustomer;
use Carbon\Carbon;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Support\Utils;
use RuntimeException;
class MegapayCustomerService
{
private TelegramCustomer $telegramCustomer;
private Settings $settings;
public function __construct(TelegramCustomer $telegramCustomer, Settings $settings)
{
$this->telegramCustomer = $telegramCustomer;
$this->settings = $settings;
}
/**
* Сохранить или обновить Telegram-пользователя
*
* @param array $telegramUserData Данные пользователя из Telegram.WebApp.initDataUnsafe
* @return array
* @throws RuntimeException Если данные невалидны или не удалось сохранить
*/
public function saveOrUpdate(array $telegramUserData): array
{
$telegramUserId = $this->extractTelegramUserId($telegramUserData);
$telegramCustomerData = $this->prepareCustomerData($telegramUserData, $telegramUserId);
$existingRecord = $this->telegramCustomer->findByTelegramUserId($telegramUserId);
if ($existingRecord) {
$this->telegramCustomer->updateByTelegramUserId($telegramUserId, $telegramCustomerData);
} else {
$this->telegramCustomer->create($telegramCustomerData);
}
return $this->telegramCustomer->findByTelegramUserId($telegramUserId);
}
/**
* Извлечь Telegram user ID из данных
*
* @param array $telegramUserData Данные пользователя
* @return int Telegram user ID
* @throws RuntimeException Если ID отсутствует или невалиден
*/
private function extractTelegramUserId(array $telegramUserData): int
{
$telegramUserId = (int)Arr::get($telegramUserData, 'id');
if ($telegramUserId <= 0) {
throw new RuntimeException('Telegram user ID is required and must be positive');
}
return $telegramUserId;
}
/**
* Подготовить данные для сохранения в БД
*
* @param array $telegramUserData Исходные данные пользователя
* @param int $telegramUserId Telegram user ID
* @return array Подготовленные данные
*/
private function prepareCustomerData(array $telegramUserData, int $telegramUserId): array
{
return [
'telegram_user_id' => $telegramUserId,
'username' => Arr::get($telegramUserData, 'username', $telegramUserId),
'first_name' => Arr::get($telegramUserData, 'first_name'),
'last_name' => Arr::get($telegramUserData, 'last_name'),
'language_code' => Arr::get($telegramUserData, 'language_code'),
'is_premium' => Utils::boolToInt(Arr::get($telegramUserData, 'is_premium', false)),
'allows_write_to_pm' => Utils::boolToInt(Arr::get($telegramUserData, 'allows_write_to_pm', false)),
'photo_url' => Arr::get($telegramUserData, 'photo_url'),
'last_seen_at' => date('Y-m-d H:i:s'),
'store_id' => $this->settings->get('store.oc_store_id', 0),
];
}
/**
* Assign OpenCart Customer to Telegram User ID and return Megapay Customer ID if it exists.
*
* @param $telegramUserId
* @param int $ocCustomerId
* @return int|null
*/
public function assignOcCustomer($telegramUserId, int $ocCustomerId): ?int
{
$customer = $this->telegramCustomer->findByTelegramUserId($telegramUserId);
if (! $customer) {
return null;
}
if ($customer['oc_customer_id'] === null) {
$this->telegramCustomer->updateByTelegramUserId($telegramUserId, [
'oc_customer_id' => $ocCustomerId,
'updated_at' => Carbon::now()->toDateTimeString(),
]);
}
return (int)$customer['id'];
}
public function increaseOrdersCount(int $megapayCustomerId): void
{
$this->telegramCustomer->increase($megapayCustomerId, 'orders_count');
}
/**
* Получить данные пользователя по Telegram user ID
*
* @param int $telegramUserId Telegram user ID
* @return array|null Данные пользователя или null если не найдено
*/
public function getByTelegramUserId(int $telegramUserId): ?array
{
return $this->telegramCustomer->findByTelegramUserId($telegramUserId);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Support;
final class Utils
{
/**
* @param string $string
* @return string
* @deprecated use Str::htmlEntityEncode instead
*/
public static function htmlEntityEncode(string $string): string
{
return html_entity_decode($string, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Telegram;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use JsonException;
use Psr\Log\LoggerInterface;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Telegram\Commands\TelegramCommand;
use Openguru\OpenCartFramework\Telegram\Enums\ChatAction;
use Openguru\OpenCartFramework\Telegram\TelegramBotStateManager;
use Openguru\OpenCartFramework\Telegram\TelegramService;
use Throwable;
class LinkCommand extends TelegramCommand
{
private LoggerInterface $logger;
public function __construct(
TelegramService $telegram,
TelegramBotStateManager $stateManager,
LoggerInterface $logger
) {
parent::__construct($telegram, $stateManager);
$this->logger = $logger;
}
/**
* @throws GuzzleException
* @throws JsonException
*/
public function handle(array $update): void
{
try {
$userId = $update['message']['from']['id'];
$chatId = $update['message']['chat']['id'];
$state = $this->state->getState($userId, $chatId);
if (! $state) {
$greeting = $this->telegram->escapeTgSpecialCharacters(
<<<HTML
Это удобный инструмент, который поможет вам 📎 создать красивое
сообщение с кнопкой для открытия вашего 🛒 Megapay магазина.
📌 Такое сообщение можно закрепить в канале или группе.
📤 Переслать клиентам в личные сообщения.
🚀 Или использовать повторно, когда нужно поделиться магазином.
Давайте начнём — отправьте текст, который вы хотите разместить в сообщении 👇
HTML
);
$this->telegram->sendMessage($chatId, $greeting);
$this->state->setState(self::class, $userId, $chatId, [
'step' => 'message_text',
'data' => [
'message_text' => '',
'btn_text' => '',
'btn_link' => '',
],
]);
return;
}
$step = $state['data']['step'];
if ($step === 'message_text') {
$message = Arr::get($update, 'message.text', 'Недопустимый текст сообщения');
$state['data']['data']['message_text'] = $message;
$state['data']['step'] = 'btn_text';
$this->state->setState(self::class, $userId, $chatId, $state['data']);
$text = <<<HTML
🔸 Отлично!
Теперь укажите, какой текст будет на кнопке 👇
✍️ Напишите короткую, понятную фразу, например:
• Открыть магазин
• Каталог товаров
• Начать покупки
HTML;
$this->telegram->sendMessage($chatId, $text);
return;
}
if ($step === 'btn_text') {
$message = $update['message']['text'];
$state['data']['data']['btn_text'] = $message;
$state['data']['step'] = 'btn_link';
$this->state->setState(self::class, $userId, $chatId, $state['data']);
$template = <<<MARKDOWN
🌐 Теперь отправьте ссылку на Telegram Mini App.
Ссылка должна начинаться с <pre>https://</pre>
📎 Инструкция, где взять ссылку:
👉 {LINK}
MARKDOWN;
$text = $this->telegram->prepareMessage($template, [
'{LINK}' => 'https://megapay-labs.github.io/docs/telegram/telegram/#direct-link',
]);
$this->telegram->sendMessage($chatId, $text);
return;
}
if ($step === 'btn_link') {
$message = $update['message']['text'];
$state['data']['data']['btn_link'] = $message;
$this->state->setState(self::class, $userId, $chatId, $state['data']);
$messageText = Arr::get($state, 'data.data.message_text', 'Текст сообщения');
$btnText = $this->telegram->escapeTgSpecialCharacters(
Arr::get($state, 'data.data.btn_text', 'Открыть магазин')
);
$btnLink = $message;
$replyMarkup = [
'inline_keyboard' => [
[
[
'text' => $btnText,
'url' => $btnLink,
]
]
],
];
$this->telegram->sendMessage(
$chatId,
$this->telegram->escapeTgSpecialCharacters($messageText),
$replyMarkup,
);
}
$this->state->clearState($userId, $chatId);
} catch (ClientException $exception) {
$this->telegram->sendMessage($chatId, 'Ошибка: ' . $exception->getResponse()->getBody()->getContents());
} catch (Throwable $exception) {
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
$this->telegram->sendMessage($chatId, 'Произошла ошибка');
}
}
}

44
backend/src/app/routes.php Executable file
View File

@@ -0,0 +1,44 @@
<?php
use App\Handlers\BlocksHandler;
use App\Handlers\CartHandler;
use App\Handlers\CategoriesHandler;
use App\Handlers\CronHandler;
use App\Handlers\ETLHandler;
use App\Handlers\FiltersHandler;
use App\Handlers\FormsHandler;
use App\Handlers\HealthCheckHandler;
use App\Handlers\OrderHandler;
use App\Handlers\PrivacyPolicyHandler;
use App\Handlers\ProductsHandler;
use App\Handlers\SettingsHandler;
use App\Handlers\TelegramCustomerHandler;
use App\Handlers\TelegramHandler;
use App\Handlers\TelemetryHandler;
return [
'categoriesList' => [CategoriesHandler::class, 'index'],
'checkIsUserPrivacyConsented' => [PrivacyPolicyHandler::class, 'checkIsUserPrivacyConsented'],
'checkout' => [CartHandler::class, 'checkout'],
'filtersForMainPage' => [FiltersHandler::class, 'getFiltersForMainPage'],
'getCart' => [CartHandler::class, 'index'],
'getForm' => [FormsHandler::class, 'getForm'],
'health' => [HealthCheckHandler::class, 'handle'],
'ingest' => [TelemetryHandler::class, 'ingest'],
'runSchedule' => [CronHandler::class, 'runSchedule'],
'heartbeat' => [TelemetryHandler::class, 'heartbeat'],
'processBlock' => [BlocksHandler::class, 'processBlock'],
'product_show' => [ProductsHandler::class, 'show'],
'products' => [ProductsHandler::class, 'index'],
'productsSearchPlaceholder' => [ProductsHandler::class, 'getSearchPlaceholder'],
'saveTelegramCustomer' => [TelegramCustomerHandler::class, 'saveOrUpdate'],
'getCurrentCustomer' => [TelegramCustomerHandler::class, 'getCurrent'],
'settings' => [SettingsHandler::class, 'index'],
'storeOrder' => [OrderHandler::class, 'store'],
'testTgMessage' => [SettingsHandler::class, 'testTgMessage'],
'userPrivacyConsent' => [PrivacyPolicyHandler::class, 'userPrivacyConsent'],
'webhook' => [TelegramHandler::class, 'webhook'],
'etlCustomers' => [ETLHandler::class, 'customers'],
'etlCustomersMeta' => [ETLHandler::class, 'getCustomersMeta'],
'getProductImages' => [ProductsHandler::class, 'getProductImages'],
];

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Bastion;
use App\ServiceProviders\AppServiceProvider;
use App\ServiceProviders\SettingsServiceProvider;
use Openguru\OpenCartFramework\Application;
use Openguru\OpenCartFramework\Cache\CacheServiceProvider;
use Openguru\OpenCartFramework\ImageTool\ImageToolServiceProvider;
use Openguru\OpenCartFramework\QueryBuilder\QueryBuilderServiceProvider;
use Openguru\OpenCartFramework\Router\RouteServiceProvider;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\MegaPayPulse\MegaPayPulseServiceProvider;
use Openguru\OpenCartFramework\Telegram\TelegramServiceProvider;
class ApplicationFactory
{
public static function create(array $settings): Application
{
$defaultConfig = require __DIR__ . '/../configs/app.php';
$routes = require __DIR__ . '/routes.php';
$merged = Arr::mergeArraysRecursively($defaultConfig, $settings);
return (new Application($merged))
->withRoutes(fn() => $routes)
->withServiceProviders([
SettingsServiceProvider::class,
QueryBuilderServiceProvider::class,
RouteServiceProvider::class,
AppServiceProvider::class,
CacheServiceProvider::class,
TelegramServiceProvider::class,
MegaPayPulseServiceProvider::class,
ImageToolServiceProvider::class,
]);
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Bastion\Exceptions;
use Exception;
class BotTokenConfiguratorException extends Exception
{
}

View File

@@ -0,0 +1,146 @@
<?php
namespace Bastion\Handlers;
use App\Services\SettingsService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Openguru\OpenCartFramework\Support\Str;
class AutocompleteHandler
{
private OcRegistryDecorator $registry;
private Builder $queryBuilder;
private SettingsService $settings;
public function __construct(
OcRegistryDecorator $registry,
Builder $queryBuilder,
SettingsService $settings
) {
$this->registry = $registry;
$this->queryBuilder = $queryBuilder;
$this->settings = $settings;
}
public function getCategoriesFlat(): JsonResponse
{
$languageId = $this->settings->config()->getApp()->getLanguageId();
$categoriesFlat = $this->getFlatCategories($languageId);
return new JsonResponse([
'data' => $categoriesFlat,
]);
}
public function getCategories(): JsonResponse
{
$languageId = $this->settings->config()->getApp()->getLanguageId();
$categoriesFlat = $this->getFlatCategories($languageId);
$categories = $this->buildCategoryTree($categoriesFlat);
return new JsonResponse([
'data' => $categories,
]);
}
public function getProductsById(Request $request): JsonResponse
{
$productIds = $request->json('product_ids', []);
$products = [];
if ($productIds) {
$products = array_map(function ($productId) {
$item = [
'id' => (int) $productId,
];
$product = $this->registry->model_catalog_product->getProduct($productId);
$item['name'] = $product ? Str::htmlEntityEncode($product['name']) : 'No name';
return $item;
}, $productIds);
}
return new JsonResponse([
'data' => $products,
]);
}
public function getCategoriesById(Request $request): JsonResponse
{
$ids = $request->json('category_ids', []);
$items = [];
if ($ids) {
$items = array_map(function ($id) {
$item = [
'id' => (int) $id,
];
$entity = $this->registry->model_catalog_category->getCategory($id);
$item['name'] = $entity ? Str::htmlEntityEncode($entity['name']) : 'No name';
return $item;
}, $ids);
}
return new JsonResponse([
'data' => $items,
]);
}
private function getFlatCategories(int $languageId): array
{
return $this->queryBuilder->newQuery()
->select([
'categories.category_id' => 'id',
'categories.parent_id' => 'parent_id',
'descriptions.name' => 'name',
'descriptions.description' => 'description',
])
->from(db_table('category'), 'categories')
->join(
db_table('category_description') . ' AS descriptions',
function (JoinClause $join) use ($languageId) {
$join->on('categories.category_id', '=', 'descriptions.category_id')
->where('descriptions.language_id', '=', $languageId);
}
)
->where('categories.status', '=', 1)
->orderBy('parent_id')
->orderBy('sort_order')
->get();
}
private function buildCategoryTree(array $flat, $parentId = 0): array
{
$branch = [];
foreach ($flat as $category) {
if ((int) $category['parent_id'] === (int) $parentId) {
$children = $this->buildCategoryTree($flat, $category['id']);
if ($children) {
$category['children'] = $children;
}
$branch[] = [
'key' => (int) $category['id'],
'label' => Str::htmlEntityEncode($category['name']),
'data' => [
'description' => Str::htmlEntityEncode($category['description']),
],
'icon' => null,
'children' => $category['children'] ?? [],
];
}
}
return $branch;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Bastion\Handlers;
use App\Services\SettingsService;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Symfony\Component\HttpFoundation\JsonResponse;
class DictionariesHandler
{
private Builder $queryBuilder;
private SettingsService $settings;
public function __construct(Builder $queryBuilder, SettingsService $settings)
{
$this->queryBuilder = $queryBuilder;
$this->settings = $settings;
}
public function getCategories(Request $request): JsonResponse
{
$perPage = $request->get('perPage', 20);
$categoryIds = $request->json('category_ids', []);
$languageId = $this->settings->config()->getApp()->getLanguageId();
$data = $this->queryBuilder->newQuery()
->select([
'categories.category_id' => 'id',
'categories.parent_id' => 'parent_id',
'categories.image' => 'image',
'descriptions.name' => 'name',
'descriptions.description' => 'description',
])
->from(db_table('category'), 'categories')
->join(
db_table('category_description') . ' AS descriptions',
function (JoinClause $join) use ($languageId) {
$join->on('categories.category_id', '=', 'descriptions.category_id')
->where('descriptions.language_id', '=', $languageId);
}
)
->where('categories.status', '=', 1)
->when($categoryIds, function (Builder $query) use ($categoryIds) {
$query->whereIn('categories.category_id', $categoryIds);
})
->orderBy('parent_id')
->orderBy('sort_order')
->limit($perPage)
->get();
return new JsonResponse(compact('data'));
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Bastion\Handlers;
use JsonException;
use Openguru\OpenCartFramework\Exceptions\EntityNotFoundException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
class FormsHandler
{
private Builder $builder;
public function __construct(Builder $builder)
{
$this->builder = $builder;
}
/**
* @throws EntityNotFoundException
* @throws JsonException
*/
public function getFormByAlias(Request $request): JsonResponse
{
$alias = 'checkout';
//$request->json('alias');
if (! $alias) {
return new JsonResponse([
'error' => 'Form alias is required',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$form = $this->builder->newQuery()
->from('megapay_forms')
->where('alias', '=', $alias)
->firstOrNull();
if (! $form) {
throw new EntityNotFoundException("Form with alias `{$alias}` not found");
}
$schema = json_decode($form['schema'], true, 512, JSON_THROW_ON_ERROR);
return new JsonResponse([
'data' => [
'alias' => $alias,
'friendly_name' => $form['friendly_name'],
'is_custom' => filter_var($form['is_custom'], FILTER_VALIDATE_BOOLEAN),
'schema' => $schema,
'created_at' => $form['created_at'],
'updated_at' => $form['updated_at'],
],
]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Bastion\Handlers;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\ImageTool\ImageFactory;
use Symfony\Component\HttpFoundation\Response;
class ImageHandler
{
private ImageFactory $image;
public function __construct(ImageFactory $image)
{
$this->image = $image;
}
public function getImage(Request $request): Response
{
$path = $request->query->get('path');
[$width, $height] = $this->parseSize($request->query->get('size'));
return $this->image
->make($path)
->resize($width, $height)
->response();
}
private function parseSize(?string $size = null): array
{
if (! $size) {
return [null, null];
}
$sizes = explode('x', $size);
return array_map(static fn($value) => is_numeric($value) ? (int) $value : null, $sizes);
}
}

View File

@@ -0,0 +1,205 @@
<?php
namespace Bastion\Handlers;
use Openguru\OpenCartFramework\Config\Settings;
use Symfony\Component\HttpFoundation\JsonResponse;
class LogsHandler
{
private Settings $settings;
public function __construct(Settings $settings)
{
$this->settings = $settings;
}
public function getLogs(): JsonResponse
{
$parsedLogs = [];
$logsPath = $this->findLastLogsFileInDir(
$this->settings->get('logs.path')
);
if ($logsPath) {
$lines = $this->readLastLogsRows($logsPath, 100);
$parsedLogs = $this->parseLogLines($lines);
}
return new JsonResponse(['data' => $parsedLogs]);
}
private function parseLogLines(array $lines): array
{
$parsed = [];
$pattern = '/^\[([^\]]+)\]\s+([^.]+)\.(\w+):\s+(.+)$/s';
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) {
continue;
}
if (preg_match($pattern, $line, $matches)) {
$datetime = $matches[1] ?? '';
$channel = $matches[2] ?? '';
$level = $matches[3] ?? '';
$rest = $matches[4] ?? '';
// Извлекаем сообщение и контекст
// Контекст начинается с { и заканчивается соответствующим }
$message = $rest;
$context = null;
// Ищем JSON контекст (начинается с {, может быть после пробела или сразу)
$jsonStart = strpos($rest, ' {');
if ($jsonStart === false) {
$jsonStart = strpos($rest, '{');
} else {
$jsonStart++; // Пропускаем пробел перед {
}
if ($jsonStart !== false) {
$message = trim(substr($rest, 0, $jsonStart));
$jsonPart = substr($rest, $jsonStart);
// Находим конец JSON объекта, учитывая вложенность
$jsonEnd = $this->findJsonEnd($jsonPart);
if ($jsonEnd !== false) {
$contextJson = substr($jsonPart, 0, $jsonEnd + 1);
$decoded = json_decode($contextJson, true);
if (json_last_error() === JSON_ERROR_NONE) {
$context = $decoded;
}
}
}
// Форматируем дату для отображения (убираем микросекунды и временную зону для читаемости)
$formattedDatetime = $this->formatDateTime($datetime);
$message = rtrim($message, ' [] []');
$parsed[] = [
'datetime' => $formattedDatetime,
'datetime_raw' => $datetime,
'channel' => $channel,
'level' => $level,
'message' => $message,
'context' => $context,
'raw' => $line,
];
} else {
// Если строка не соответствует формату, сохраняем как есть
$parsed[] = [
'datetime' => '',
'datetime_raw' => '',
'channel' => '',
'level' => '',
'message' => $line,
'context' => null,
'raw' => $line,
];
}
}
return $parsed;
}
/**
* Находит позицию конца JSON объекта, учитывая вложенность
* @param string $json JSON строка, начинающаяся с {
* @return int|false Позиция закрывающей скобки или false, если не найдено
*/
private function findJsonEnd(string $json)
{
$depth = 0;
$inString = false;
$escape = false;
$len = strlen($json);
for ($i = 0; $i < $len; $i++) {
$char = $json[$i];
if ($escape) {
$escape = false;
continue;
}
if ($char === '\\') {
$escape = true;
continue;
}
if ($char === '"') {
$inString = !$inString;
continue;
}
if ($inString) {
continue;
}
if ($char === '{') {
$depth++;
} elseif ($char === '}') {
$depth--;
if ($depth === 0) {
return $i;
}
}
}
return false;
}
private function formatDateTime(string $datetime): string
{
// Парсим ISO 8601 формат: 2025-11-23T14:28:21.772518+00:00
// Преобразуем в более читаемый формат: 2025-11-23 14:28:21
if (preg_match('/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/', $datetime, $dateMatches)) {
return $dateMatches[1] . ' ' . $dateMatches[2];
}
return $datetime;
}
private function readLastLogsRows(string $path, int $lines = 1000, int $buffer = 4096): array
{
$f = fopen($path, 'rb');
if (! $f) {
return [];
}
$lineCount = 0;
$chunk = '';
fseek($f, 0, SEEK_END);
$filesize = ftell($f);
while ($filesize > 0 && $lineCount < $lines) {
$seek = max($filesize - $buffer, 0);
$readLength = $filesize - $seek;
fseek($f, $seek);
$chunk = fread($f, $readLength) . $chunk;
$filesize = $seek;
$lineCount = substr_count($chunk, "\n");
}
fclose($f);
$linesArray = explode("\n", $chunk);
return array_slice($linesArray, -$lines);
}
private function findLastLogsFileInDir(string $dir): ?string
{
$files = glob($dir . '/megapay-*.log');
return $files ? end($files) : null;
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Bastion\Handlers;
use App\Exceptions\TelegramCustomerNotFoundException;
use App\Exceptions\TelegramCustomerWriteNotAllowedException;
use App\Models\TelegramCustomer;
use GuzzleHttp\Exception\GuzzleException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Telegram\TelegramService;
use Psr\Log\LoggerInterface;
use RuntimeException;
/**
* Handler для отправки сообщений Telegram-пользователям из админ-панели
*
* @package Bastion\Handlers
*/
class SendMessageHandler
{
private TelegramService $telegramService;
private TelegramCustomer $telegramCustomerModel;
private LoggerInterface $logger;
public function __construct(
TelegramService $telegramService,
TelegramCustomer $telegramCustomerModel,
LoggerInterface $logger
) {
$this->telegramService = $telegramService;
$this->telegramCustomerModel = $telegramCustomerModel;
$this->logger = $logger;
}
/**
* Отправить сообщение Telegram-пользователю
*
* @param Request $request HTTP запрос с id (ID записи в таблице) и message
* @return JsonResponse JSON ответ с результатом операции
* @throws TelegramCustomerNotFoundException Если пользователь не найден
* @throws TelegramCustomerWriteNotAllowedException Если пользователь не разрешил писать в PM
* @throws RuntimeException Если данные невалидны
* @throws \Exception
* @throws GuzzleException
*/
public function sendMessage(Request $request): JsonResponse
{
$customerId = $this->extractCustomerId($request);
$message = $this->extractMessage($request);
// Находим запись по ID
$customer = $this->telegramCustomerModel->findById($customerId);
if (! $customer) {
throw new TelegramCustomerNotFoundException($customerId);
}
$telegramUserId = (int) $customer['telegram_user_id'];
// Проверяем, что пользователь разрешил писать ему в PM
if (! $customer['allows_write_to_pm']) {
throw new TelegramCustomerWriteNotAllowedException($telegramUserId);
}
// Отправляем сообщение (telegram_user_id используется как chat_id)
// Используем пустую строку для parse_mode чтобы отправлять обычный текст
$this->telegramService->sendMessage(
$telegramUserId,
$message,
);
$this->logger->info('Message sent to Telegram user', [
'oc_customer_id' => $customerId,
'telegram_user_id' => $telegramUserId,
'message_length' => strlen($message),
]);
return new JsonResponse([
'success' => true,
'message' => 'Message sent successfully',
]);
}
/**
* Извлечь ID записи из запроса
*
* @param Request $request HTTP запрос
* @return int ID записи в таблице megapay_customers
* @throws RuntimeException Если ID отсутствует или невалиден
*/
private function extractCustomerId(Request $request): int
{
$jsonData = $request->json();
$customerId = isset($jsonData['id']) ? (int) $jsonData['id'] : 0;
if ($customerId <= 0) {
throw new RuntimeException('Customer ID is required and must be positive');
}
return $customerId;
}
/**
* Извлечь сообщение из запроса
*
* @param Request $request HTTP запрос
* @return string Текст сообщения
* @throws RuntimeException Если сообщение отсутствует или пустое
*/
private function extractMessage(Request $request): string
{
$jsonData = $request->json();
$message = isset($jsonData['message']) ? trim($jsonData['message']) : '';
if (empty($message)) {
throw new RuntimeException('Message is required and cannot be empty');
}
return $message;
}
}

View File

@@ -0,0 +1,306 @@
<?php
namespace Bastion\Handlers;
use Bastion\Exceptions\BotTokenConfiguratorException;
use Bastion\Services\BotTokenConfigurator;
use Bastion\Services\CronApiKeyRegenerator;
use Bastion\Services\SettingsService;
use Carbon\Carbon;
use Exception;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Config\Settings;
use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\Scheduler\Models\ScheduledJob;
use Openguru\OpenCartFramework\Support\Arr;
use Psr\Log\LoggerInterface;
class SettingsHandler
{
private BotTokenConfigurator $botTokenConfigurator;
private CronApiKeyRegenerator $cronApiKeyRegenerator;
private Settings $settings;
private SettingsService $settingsUpdateService;
private CacheInterface $cache;
private LoggerInterface $logger;
private Builder $builder;
private ConnectionInterface $connection;
private ScheduledJob $scheduledJob;
public function __construct(
BotTokenConfigurator $botTokenConfigurator,
CronApiKeyRegenerator $cronApiKeyRegenerator,
Settings $settings,
SettingsService $settingsUpdateService,
CacheInterface $cache,
LoggerInterface $logger,
Builder $builder,
ConnectionInterface $connection,
ScheduledJob $scheduledJob
) {
$this->botTokenConfigurator = $botTokenConfigurator;
$this->cronApiKeyRegenerator = $cronApiKeyRegenerator;
$this->settings = $settings;
$this->settingsUpdateService = $settingsUpdateService;
$this->cache = $cache;
$this->logger = $logger;
$this->builder = $builder;
$this->connection = $connection;
$this->scheduledJob = $scheduledJob;
}
/**
* Перегенерировать секретный ключ в URL для cron-job.org (сохраняет cron.api_key).
*/
public function regenerateCronScheduleUrl(Request $request): JsonResponse
{
$newApiKey = $this->cronApiKeyRegenerator->regenerate();
$scheduleUrl = $this->buildCronScheduleUrl(
$this->settings->get('app.shop_base_url', ''),
$newApiKey
);
return new JsonResponse(['api_key' => $newApiKey, 'schedule_url' => $scheduleUrl]);
}
public function configureBotToken(Request $request): JsonResponse
{
try {
$data = $this->botTokenConfigurator->configure(trim($request->json('botToken', '')));
return new JsonResponse($data);
} catch (BotTokenConfiguratorException $e) {
return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
} catch (Exception $e) {
return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function getSettingsForm(): JsonResponse
{
$data = Arr::getWithKeys($this->settings->getAll(), [
'app',
'telegram',
'metrics',
'store',
'orders',
'texts',
'sliders',
'mainpage_blocks',
'pulse',
'cron',
]);
if (!isset($data['cron']['mode'])) {
$data['cron']['mode'] = 'disabled';
}
$data['forms'] = [];
// Add CRON system details (read-only)
$data['cron']['cli_path'] = BP_REAL_BASE_PATH . '/cli.php';
$data['cron']['last_run'] = $this->getLastCronRunDate();
$data['cron']['schedule_url'] = $this->buildCronScheduleUrl(
$this->settings->get('app.shop_base_url', ''),
$this->settings->get('cron.api_key', '')
);
$data['scheduled_jobs'] = $this->scheduledJob->all();
$forms = $this->builder->newQuery()
->from('megapay_forms')
->get();
if ($forms) {
foreach ($forms as $form) {
try {
$schema = json_decode($form['schema'] ?? '[]', true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $exception) {
$schema = [];
}
$data['forms'][$form['alias']] = [
'alias' => $form['alias'],
'friendly_name' => $form['friendly_name'],
'is_custom' => filter_var($form['is_custom'], FILTER_VALIDATE_BOOLEAN),
'schema' => $schema,
];
}
}
return new JsonResponse(compact('data'));
}
private function buildCronScheduleUrl(string $shopBaseUrl, string $apiKey): string
{
$base = rtrim($shopBaseUrl, '/');
if ($base === '') {
return '';
}
$params = http_build_query([
'route' => 'extension/tgshop/handle',
'api_action' => 'runSchedule',
'api_key' => $apiKey,
]);
return $base . '/index.php?' . $params;
}
public function saveSettingsForm(Request $request): JsonResponse
{
$input = $request->json();
$this->validate($input);
// Remove dynamic properties before saving
if (isset($input['cron'])) {
unset($input['cron']['cli_path']);
unset($input['cron']['last_run']);
unset($input['cron']['schedule_url']);
}
$this->settingsUpdateService->update(
Arr::getWithKeys($input, [
'app',
'telegram',
'metrics',
'store',
'orders',
'texts',
'sliders',
'mainpage_blocks',
'pulse',
'cron',
]),
);
// Update forms
$forms = Arr::get($input, 'forms', []);
foreach ($forms as $form) {
$schema = json_encode($form['schema'], JSON_THROW_ON_ERROR);
$this->builder->newQuery()
->where('alias', '=', $form['alias'])
->update('megapay_forms', [
'friendly_name' => $form['friendly_name'],
'is_custom' => $form['is_custom'],
'schema' => $schema,
]);
}
// Update scheduled jobs is_enabled and cron_expression
$scheduledJobs = Arr::get($input, 'scheduled_jobs', []);
foreach ($scheduledJobs as $job) {
$id = (int) ($job['id'] ?? 0);
if ($id <= 0) {
continue;
}
$isEnabled = filter_var($job['is_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
if ($isEnabled) {
$this->scheduledJob->enable($id);
} else {
$this->scheduledJob->disable($id);
}
$cronExpression = trim((string) ($job['cron_expression'] ?? ''));
if ($cronExpression !== '') {
$this->scheduledJob->updateCronExpression($id, $cronExpression);
}
}
return new JsonResponse([], Response::HTTP_ACCEPTED);
}
private function validate(array $input): void
{
}
public function resetCache(): JsonResponse
{
$this->cache->clear();
$this->logger->info('Cache cleared manually.');
return new JsonResponse([], Response::HTTP_ACCEPTED);
}
private function getLastCronRunDate(): ?string
{
try {
// Since we are in SettingsHandler, we already have access to container or we can inject SchedulerService
// But SettingsHandler is constructed via DI. Let's add SchedulerService to constructor.
// For now, let's use global retrieval via cache if possible, or assume it's injected.
// But wait, getLastCronRunDate logic was in controller.
// SchedulerService stores last run in cache. We have $this->cache here.
$lastRunTimestamp = $this->cache->get("scheduler.global_last_run");
if ($lastRunTimestamp) {
return Carbon::createFromTimestamp($lastRunTimestamp)->toDateTimeString();
}
return null;
} catch (Exception $e) {
return null;
}
}
public function getSystemInfo(): JsonResponse
{
$info = [];
$info['PHP Version'] = PHP_VERSION;
$info['PHP SAPI'] = PHP_SAPI;
$info['PHP Memory Limit'] = ini_get('memory_limit');
$info['PHP Memory Usage'] = $this->formatBytes(memory_get_usage(true));
$info['PHP Peak Memory Usage'] = $this->formatBytes(memory_get_peak_usage(true));
$info['PHP Max Execution Time'] = ini_get('max_execution_time') . 's';
$info['PHP Upload Max Filesize'] = ini_get('upload_max_filesize');
$info['PHP Post Max Size'] = ini_get('post_max_size');
try {
$mysqlVersion = $this->connection->select('SELECT VERSION() as version');
$info['MySQL Version'] = $mysqlVersion[0]['version'] ?? 'Unknown';
} catch (Exception $e) {
$info['MySQL Version'] = 'Error: ' . $e->getMessage();
}
$cacheDriver = env('MEGAPAY_CACHE_DRIVER', 'mysql');
$cacheClass = get_class($this->cache);
$info['Cache Driver'] = $cacheDriver . ' (' . basename(str_replace('\\', '/', $cacheClass)) . ')';
$info['Module Version'] = module_version();
$info['OpenCart Version'] = defined('VERSION') ? VERSION : 'Unknown';
$info['OpenCart Core Version'] = defined('VERSION_CORE') ? VERSION_CORE : 'Unknown';
$info['Operating System'] = PHP_OS;
$info['Server Software'] = $_SERVER['SERVER_SOFTWARE'] ?? 'Unknown';
$info['Document Root'] = $_SERVER['DOCUMENT_ROOT'] ?? 'Unknown';
$info['PHP Timezone'] = date_default_timezone_get();
$info['Server Time'] = date('Y-m-d H:i:s');
$info['UTC Time'] = gmdate('Y-m-d H:i:s');
$info['Loaded PHP Extensions'] = implode(', ', get_loaded_extensions());
$infoText = '';
foreach ($info as $key => $value) {
$infoText .= $key . ': ' . $value . "\n";
}
return new JsonResponse(['data' => $infoText]);
}
private function formatBytes(int $bytes, int $precision = 2): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
$bytes /= 1024;
}
return round($bytes, $precision) . ' ' . $units[$i];
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Bastion\Handlers;
use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
class StatsHandler
{
private Builder $builder;
public function __construct(Builder $builder)
{
$this->builder = $builder;
}
public function getDashboardStats(): JsonResponse
{
$data = [
'orders_count' => 0,
'orders_total_amount' => 0,
'customers_count' => 0,
];
$ordersTotalAmount = $this->builder->newQuery()
->select([
new RawExpression('COUNT(DISTINCT orders.order_id) AS orders_total_count'),
new RawExpression('SUM(orders.total) AS orders_total_amount'),
])
->from(db_table('order'), 'orders')
->join('megapay_customers', function (JoinClause $join) {
$join->on('orders.customer_id', '=', 'megapay_customers.oc_customer_id');
})
->join('megapay_order_meta', function (JoinClause $join) {
$join->on('orders.order_id', '=', 'megapay_order_meta.oc_order_id')
->whereRaw('orders.store_id = megapay_order_meta.oc_store_id');
})
->firstOrNull();
if ($ordersTotalAmount) {
$data = [
'orders_count' => (int) $ordersTotalAmount['orders_total_count'],
'orders_total_amount' => (int) $ordersTotalAmount['orders_total_amount'],
'customers_count' => $this->countCustomersCount(),
];
}
return new JsonResponse(compact('data'));
}
private function countCustomersCount(): int
{
return $this->builder->newQuery()
->from('megapay_customers')
->count();
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Bastion\Handlers;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\MegaPayPulse\MegaPayEvent;
use Symfony\Component\HttpFoundation\JsonResponse;
class MegaPayPulseStatsHandler
{
private MegaPayEvent $eventModel;
private CacheInterface $cache;
private const CACHE_KEY = 'megapay_pulse_stats';
private const CACHE_TTL = 3600; // 1 час
public function __construct(MegaPayEvent $eventModel, CacheInterface $cache)
{
$this->eventModel = $eventModel;
$this->cache = $cache;
}
public function getStats(): JsonResponse
{
$stats = $this->cache->get(self::CACHE_KEY);
if ($stats === null) {
$stats = $this->eventModel->getStats();
$this->cache->set(self::CACHE_KEY, $stats, self::CACHE_TTL);
}
return new JsonResponse(['data' => $stats]);
}
}

View File

@@ -0,0 +1,344 @@
<?php
declare(strict_types=1);
namespace Bastion\Handlers;
use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
use Openguru\OpenCartFramework\Support\Arr;
class TelegramCustomersHandler
{
private const TABLE_NAME = 'megapay_customers';
private const DEFAULT_PAGE = 1;
private const DEFAULT_ROWS = 20;
private const DEFAULT_SORT_FIELD = 'last_seen_at';
private const DEFAULT_SORT_ORDER = 'DESC';
private Builder $builder;
public function __construct(Builder $builder)
{
$this->builder = $builder;
}
/**
* Получить список Telegram-кастомеров с пагинацией, фильтрацией и сортировкой
*
* @param Request $request HTTP запрос с параметрами пагинации, сортировки и фильтров
* @return JsonResponse JSON ответ с данными и метаинформацией
*/
public function getCustomers(Request $request): JsonResponse
{
$page = max(1, (int) $request->json('page', self::DEFAULT_PAGE));
$rows = max(1, (int) $request->json('rows', self::DEFAULT_ROWS));
$first = ($page - 1) * $rows;
$sortField = $request->json('sortField', self::DEFAULT_SORT_FIELD) ?? self::DEFAULT_SORT_FIELD;
$sortOrder = $this->normalizeSortOrder((string)$request->json('sortOrder', self::DEFAULT_SORT_ORDER));
$filters = $request->json('filters', []);
$globalFilter = Arr::get($filters, 'global.value');
// Создаем базовый query с фильтрами
$query = $this->buildBaseQuery();
$this->applyFilters($query, $filters, $globalFilter);
// Получаем общее количество записей
$countQuery = $this->buildCountQuery();
$this->applyFilters($countQuery, $filters, $globalFilter);
$totalRecords = (int) ($countQuery->value('total') ?? 0);
// Применяем сортировку и пагинацию
$customers = $query
->orderBy($sortField, $sortOrder)
->offset($first)
->limit($rows)
->get();
return new JsonResponse([
'data' => [
'data' => $this->mapToResponse($customers),
'totalRecords' => $totalRecords,
],
]);
}
/**
* Создать базовый query для выборки данных
*
* @return Builder
*/
private function buildBaseQuery(): Builder
{
return $this->builder->newQuery()
->select([
'id',
'telegram_user_id',
'oc_customer_id',
'tracking_id',
'username',
'first_name',
'last_name',
'language_code',
'is_premium',
'allows_write_to_pm',
'photo_url',
'last_seen_at',
'referral',
'orders_count',
'privacy_consented_at',
'created_at',
'updated_at',
])
->from(self::TABLE_NAME);
}
/**
* Создать query для подсчета общего количества записей
*
* @return Builder
*/
private function buildCountQuery(): Builder
{
return $this->builder->newQuery()
->select([new RawExpression('COUNT(*) as total')])
->from(self::TABLE_NAME);
}
/**
* Применить фильтры к query
*
* @param Builder $query Query builder
* @param array $filters Массив фильтров
* @param string|null $globalFilter Глобальный фильтр поиска
* @return void
*/
private function applyFilters(Builder $query, array $filters, ?string $globalFilter): void
{
// Применяем глобальный фильтр
if ($globalFilter) {
$this->applyGlobalFilter($query, $globalFilter);
}
// Применяем фильтры по колонкам
$this->applyColumnFilters($query, $filters);
}
/**
* Применить глобальный фильтр поиска
*
* @param Builder $query Query builder
* @param string $searchTerm Поисковый запрос
* @return void
*/
private function applyGlobalFilter(Builder $query, string $searchTerm): void
{
$query->whereNested(function ($q) use ($searchTerm) {
$q->where('telegram_user_id', 'LIKE', "%{$searchTerm}%")
->orWhere('username', 'LIKE', "%{$searchTerm}%")
->orWhere('first_name', 'LIKE', "%{$searchTerm}%")
->orWhere('last_name', 'LIKE', "%{$searchTerm}%")
->orWhere('language_code', 'LIKE', "%{$searchTerm}%");
});
}
/**
* Применить фильтры по колонкам
*
* @param Builder $query Query builder
* @param array $filters Массив фильтров
* @return void
*/
private function applyColumnFilters(Builder $query, array $filters): void
{
foreach ($filters as $field => $filter) {
if ($field === 'global') {
continue;
}
// Обработка сложных фильтров (constraints)
if (isset($filter['constraints']) && is_array($filter['constraints'])) {
$this->applyConstraintFilters($query, $field, $filter);
continue;
}
// Обработка простых фильтров (обратная совместимость)
if (! isset($filter['value']) || $filter['value'] === null || $filter['value'] === '') {
continue;
}
$value = $filter['value'];
$matchMode = Arr::get($filter, 'matchMode', 'contains');
$this->applyColumnFilter($query, $field, $value, $matchMode);
}
}
/**
* Применить сложные фильтры с условиями (AND/OR)
*
* @param Builder $query Query builder
* @param string $field Имя поля
* @param array $filter Данные фильтра
* @return void
*/
private function applyConstraintFilters(Builder $query, string $field, array $filter): void
{
$operator = strtolower($filter['operator'] ?? 'and');
$constraints = $filter['constraints'];
// Фильтруем пустые значения (но учитываем false как валидное значение для boolean полей)
$activeConstraints = array_filter($constraints, function ($constraint) {
if (!isset($constraint['value'])) {
return false;
}
$value = $constraint['value'];
// null означает "любой", пропускаем
if ($value === null) {
return false;
}
// Пустая строка пропускаем
if ($value === '') {
return false;
}
// false - валидное значение для boolean полей
return true;
});
if (empty($activeConstraints)) {
return;
}
$query->whereNested(function ($q) use ($field, $activeConstraints, $operator) {
// Для первого элемента всегда используем where, чтобы начать группу
$first = true;
foreach ($activeConstraints as $constraint) {
$value = $constraint['value'];
$matchMode = $constraint['matchMode'] ?? 'contains';
if ($first) {
$this->applyColumnFilter($q, $field, $value, $matchMode);
$first = false;
continue;
}
if ($operator === 'or') {
$q->orWhere(function ($subQ) use ($field, $value, $matchMode) {
$this->applyColumnFilter($subQ, $field, $value, $matchMode);
});
} else {
$this->applyColumnFilter($q, $field, $value, $matchMode);
}
}
});
}
/**
* Применить фильтр для одной колонки
*
* @param Builder $query Query builder
* @param string $field Имя поля
* @param mixed $value Значение фильтра
* @param string $matchMode Режим совпадения (contains, startsWith, endsWith, equals, notEquals)
* @return void
*/
private function applyColumnFilter(Builder $query, string $field, $value, string $matchMode): void
{
if (in_array($matchMode, ['contains', 'startsWith', 'endsWith'], true)) {
$likeValue = $this->buildLikeValue($value, $matchMode);
$query->where($field, 'LIKE', $likeValue);
} elseif ($matchMode === 'equals') {
$query->where($field, '=', $value);
} elseif ($matchMode === 'notEquals') {
$query->where($field, '!=', $value);
} elseif ($matchMode === 'gt') {
$query->where($field, '>', $value);
} elseif ($matchMode === 'lt') {
$query->where($field, '<', $value);
} elseif ($matchMode === 'gte') {
$query->where($field, '>=', $value);
} elseif ($matchMode === 'lte') {
$query->where($field, '<=', $value);
} elseif ($matchMode === 'dateIs') {
// Для точного совпадения даты используем диапазон от 00:00:00 до 23:59:59
$date = date('Y-m-d', strtotime($value));
$query->where($field, '>=', $date . ' 00:00:00')
->where($field, '<=', $date . ' 23:59:59');
} elseif ($matchMode === 'dateIsNot') {
// Для отрицания проверяем, что дата меньше начала дня ИЛИ больше конца дня
$date = date('Y-m-d', strtotime($value));
$query->whereNested(function ($q) use ($field, $date) {
$q->where($field, '<', $date . ' 00:00:00')
->orWhere($field, '>', $date . ' 23:59:59');
});
} elseif ($matchMode === 'dateBefore') {
$query->where($field, '<', date('Y-m-d 00:00:00', strtotime($value)));
} elseif ($matchMode === 'dateAfter') {
// "После" означает после конца указанного дня
$query->where($field, '>', date('Y-m-d 23:59:59', strtotime($value)));
}
}
/**
* Построить значение для LIKE запроса
*
* @param string $value Значение
* @param string $matchMode Режим совпадения
* @return string
*/
private function buildLikeValue(string $value, string $matchMode): string
{
if ($matchMode === 'startsWith') {
return "{$value}%";
}
if ($matchMode === 'endsWith') {
return "%{$value}";
}
return "%{$value}%";
}
/**
* Нормализовать порядок сортировки
*
* @param string $sortOrder Порядок сортировки
* @return string 'ASC' или 'DESC'
*/
private function normalizeSortOrder(string $sortOrder): string
{
$normalized = strtoupper($sortOrder);
return in_array($normalized, ['ASC', 'DESC'], true) ? $normalized : self::DEFAULT_SORT_ORDER;
}
private function mapToResponse(array $customers): array
{
return array_map(static function (array $customer) {
return [
'id' => (int) $customer['id'],
'telegram_user_id' => (int) $customer['telegram_user_id'],
'oc_customer_id' => (int) $customer['oc_customer_id'],
'tracking_id' => $customer['tracking_id'],
'username' => $customer['username'],
'first_name' => $customer['first_name'],
'last_name' => $customer['last_name'],
'language_code' => $customer['language_code'],
'is_premium' => filter_var($customer['is_premium'], FILTER_VALIDATE_BOOLEAN),
'allows_write_to_pm' => filter_var($customer['allows_write_to_pm'], FILTER_VALIDATE_BOOLEAN),
'photo_url' => $customer['photo_url'],
'last_seen_at' => $customer['last_seen_at'],
'referral' => $customer['referral'],
'orders_count' => (int) $customer['orders_count'],
'privacy_consented_at' => $customer['privacy_consented_at'],
'created_at' => $customer['created_at'],
'updated_at' => $customer['updated_at'],
];
}, $customers);
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace Bastion\Handlers;
use App\Services\SettingsService;
use Exception;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Telegram\Enums\ChatAction;
use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramClientException;
use Openguru\OpenCartFramework\Telegram\TelegramService;
class TelegramHandler
{
private CacheInterface $cache;
private TelegramService $telegramService;
private SettingsService $settings;
public function __construct(CacheInterface $cache, TelegramService $telegramService, SettingsService $settings)
{
$this->cache = $cache;
$this->telegramService = $telegramService;
$this->settings = $settings;
}
public function getChatId(): JsonResponse
{
$message = $this->cache->get('tg_latest_msg');
if (! $message) {
return new JsonResponse([
// phpcs:ignore Generic.Files.LineLength
'message' => 'Сообщение не найдено. Убедитесь что отправили кодовое слово в чат с ботом и повторите через 10 секунд. У Вас есть 60 секунд после отправки сообщения в чат, чтобы нажать на кнопку! Это сделано в целях безопасности.'
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$text = Arr::get($message, 'text');
if ($text !== 'opencart_get_chatid') {
return new JsonResponse(
['message' => 'Последнее сообщение в чате не содержит кодовое слово.'],
Response::HTTP_UNPROCESSABLE_ENTITY
);
}
$chatId = Arr::get($message, 'chat.id');
if (! $chatId) {
return new JsonResponse([
// phpcs:ignore Generic.Files.LineLength
'message' => 'ChatID не найден. Убедитесь что отправили кодовое слово в чат с ботом и повторите через 10 секунд.'
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return new JsonResponse([
'data' => [
'chat_id' => $chatId,
],
]);
}
public function testTgMessage(Request $request): JsonResponse
{
$template = $request->json('template', 'Нет шаблона');
$token = $request->json('token');
$chatId = $request->json('chat_id');
if (! $token) {
return new JsonResponse([
'message' => 'Не задан Telegram BotToken',
]);
}
if (! $chatId) {
return new JsonResponse([
'message' => 'Не задан ChatID.',
]);
}
$variables = [
'{store_name}' => $this->settings->config()->getApp()->getAppName(),
'{order_id}' => 777,
'{customer}' => 'Иван Васильевич',
'{email}' => 'telegram@opencart.com',
'{phone}' => '+79999999999',
'{comment}' => 'Это тестовый заказ',
'{address}' => 'г. Москва',
'{total}' => 100000,
'{ip}' => '127.0.0.1',
'{created_at}' => date('Y-m-d H:i:s'),
];
$message = $this->telegramService->prepareMessage($template, $variables);
try {
$this->telegramService
->setBotToken($token)
->sendMessage($chatId, $message);
return new JsonResponse([
'message' => 'Сообщение отправлено. Проверьте Telegram.',
]);
} catch (ClientException $exception) {
$json = json_decode($exception->getResponse()->getBody(), true);
return new JsonResponse([
'message' => $json['description'],
]);
} catch (Exception $e) {
return new JsonResponse([
'message' => $e->getMessage(),
]);
}
}
/**
* @throws GuzzleException
* @throws TelegramClientException
* @throws \JsonException
*/
public function tgGetMe(): JsonResponse
{
if (! $this->settings->config()->getTelegram()->getBotToken()) {
return new JsonResponse(['data' => null]);
}
$data = $this->cache->get('tg_me_info');
if (! $data) {
$data = $this->telegramService->exec('getMe');
$this->cache->set('tg_me_info', $data, 60 * 5);
}
return new JsonResponse(compact('data'));
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace Bastion\ScheduledTasks;
use GuzzleHttp\Exception\GuzzleException;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Scheduler\TaskInterface;
use Openguru\OpenCartFramework\MegaPayPulse\MegaPayEvent;
use Openguru\OpenCartFramework\MegaPayPulse\MegaPayPulseEventsSender;
use Psr\Log\LoggerInterface;
use Throwable;
class MegaPayPulseSendEventsTask implements TaskInterface
{
private MegaPayEvent $eventModel;
private MegaPayPulseEventsSender $eventsSender;
private LoggerInterface $logger;
private CacheInterface $cache;
private Settings $settings;
private int $maxAttempts;
private int $batchSize;
public function __construct(
Settings $settings,
MegaPayEvent $eventModel,
MegaPayPulseEventsSender $eventsSender,
LoggerInterface $logger,
CacheInterface $cache
) {
$this->settings = $settings;
$this->eventModel = $eventModel;
$this->eventsSender = $eventsSender;
$this->logger = $logger;
$this->cache = $cache;
// Получаем конфигурацию из настроек пользователя
$this->maxAttempts = (int) $this->settings->get('pulse.max_attempts', env('PULSE_MAX_ATTEMPTS', 3));
$this->batchSize = (int) $this->settings->get('pulse.batch_size', env('PULSE_BATCH_SIZE', 50));
}
public function execute(): void
{
try {
// Получаем события со статусом pending
$events = $this->eventModel->findPending($this->batchSize);
if (empty($events)) {
$this->logger->debug('No pending events to send');
return;
}
$count = count($events);
$this->logger->info("Processing pending events: $count", [
'count' => $count,
]);
$processed = 0;
$succeeded = 0;
$failed = 0;
foreach ($events as $event) {
try {
$result = $this->processEvent($event);
$result ? $succeeded++ : $failed++;
} catch (Throwable $e) {
$this->logger->error("Failed to process event {$event['id']}: " . $e->getMessage(), [
'event_id' => $event['id'],
'event' => $event['event'] ?? null,
'payload' => $event['payload'] ?? null,
'exception' => $e,
]);
$failed++;
} finally {
$processed++;
}
}
$this->logger->info("Events processing completed", [
'processed' => $processed,
'succeeded' => $succeeded,
'failed' => $failed,
]);
} catch (Throwable $e) {
$this->logger->error("MegaPayPulseSendEventsTask failed: " . $e->getMessage(), [
'exception' => $e,
]);
} finally {
// Сбрасываем кеш статистики после каждого прогона
$this->clearStatsCache();
}
}
/**
* Обработать одно событие
*
* @param array $event Данные события из БД
* @return bool true если событие успешно отправлено, false если требуется повторная попытка
* @throws Throwable
*/
private function processEvent(array $event): bool
{
$eventId = (int) $event['id'];
$attemptsCount = (int) $event['attempts_count'];
try {
// Пытаемся отправить событие
$success = $this->eventsSender->sendEvent($event);
if ($success) {
// Успешная отправка
$this->eventModel->updateStatus($eventId, 'sent');
$this->logger->debug("Event {$eventId} sent successfully", [
'event_id' => $eventId,
'event' => $event['event'],
]);
return true;
}
// MegaPay Pulse не вернул подтверждение
$errorReason = 'No confirmation received from MegaPay Pulse';
$this->handleFailedAttempt($eventId, $attemptsCount, $errorReason);
} catch (GuzzleException $e) {
// Ошибка HTTP запроса
$errorReason = 'HTTP error: ' . $e->getMessage();
$this->handleFailedAttempt($eventId, $attemptsCount, $errorReason);
} catch (Throwable $e) {
// Другие ошибки (валидация, подпись и т.д.)
$errorReason = 'Error: ' . $e->getMessage();
$this->handleFailedAttempt($eventId, $attemptsCount, $errorReason);
}
return false;
}
/**
* Обработать неудачную попытку отправки
*
* @param int $eventId ID события
* @param int $currentAttempts Текущее количество попыток
* @param string $errorReason Причина ошибки
*/
private function handleFailedAttempt(int $eventId, int $currentAttempts, string $errorReason): void
{
$newAttempts = $currentAttempts + 1;
if ($newAttempts >= $this->maxAttempts) {
// Превышен лимит попыток - переводим в failed
$this->eventModel->updateStatus($eventId, 'failed', $errorReason);
$this->logger->warning("Event {$eventId} marked as failed after {$newAttempts} attempts", [
'event_id' => $eventId,
'attempts' => $newAttempts,
'error' => $errorReason,
]);
return;
}
// Увеличиваем счетчик попыток, оставляем статус pending
$this->eventModel->incrementAttempts($eventId);
$this->logger->debug("Event {$eventId} attempt failed, will retry", [
'event_id' => $eventId,
'attempts' => $newAttempts,
'max_attempts' => $this->maxAttempts,
'error' => $errorReason,
]);
}
/**
* Сбросить кеш статистики
*/
private function clearStatsCache(): void
{
$this->cache->delete('megapay_pulse_stats');
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Bastion\Services;
use App\Services\SettingsService;
use Bastion\Exceptions\BotTokenConfiguratorException;
use Exception;
use GuzzleHttp\Exception\GuzzleException;
use Psr\Log\LoggerInterface;
use Openguru\OpenCartFramework\Router\Router;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramClientException;
use Openguru\OpenCartFramework\Telegram\TelegramService;
class BotTokenConfigurator
{
private TelegramService $telegramService;
private SettingsService $settings;
private Router $router;
private LoggerInterface $logger;
public function __construct(
TelegramService $telegramService,
SettingsService $settings,
Router $router,
LoggerInterface $logger
) {
$this->telegramService = $telegramService;
$this->settings = $settings;
$this->router = $router;
$this->logger = $logger;
}
/**
* @throws BotTokenConfiguratorException
*/
public function configure(string $botToken): array
{
$this->telegramService->setBotToken($botToken);
try {
$me = $this->telegramService->exec('getMe');
$webhookUrl = $this->telegramService->getWebhookUrl();
if (! $webhookUrl) {
$this->telegramService->exec('setWebhook', [
'url' => $this->getWebhookUrl(),
]);
$webhookUrl = $this->telegramService->getWebhookUrl();
}
return [
'first_name' => Arr::get($me, 'result.first_name'),
'username' => Arr::get($me, 'result.username'),
'id' => Arr::get($me, 'result.id'),
'webhook_url' => $webhookUrl,
];
} catch (TelegramClientException $exception) {
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
if ($exception->getCode() === 404 || $exception->getCode() === 401) {
throw new BotTokenConfiguratorException(
'Telegram сообщает, что BotToken не верный. Проверьте корректность.'
);
}
throw new BotTokenConfiguratorException($exception->getMessage());
} catch (Exception | GuzzleException $exception) {
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
throw new BotTokenConfiguratorException($exception->getMessage());
}
}
/**
* @throws BotTokenConfiguratorException
*/
private function getWebhookUrl(): string
{
$publicUrl = rtrim($this->settings->config()->getApp()->getShopBaseUrl(), '/');
if (! $publicUrl) {
throw new BotTokenConfiguratorException('Public URL is not set in configuration.');
}
$webhook = $this->router->url('webhook');
return $publicUrl . $webhook;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Bastion\Services;
use Openguru\OpenCartFramework\Config\Settings;
class CronApiKeyRegenerator
{
private Settings $settings;
private SettingsService $settingsUpdateService;
public function __construct(Settings $settings, SettingsService $settingsUpdateService)
{
$this->settings = $settings;
$this->settingsUpdateService = $settingsUpdateService;
}
/**
* Генерирует новый API-ключ для URL cron-job.org и сохраняет в настройки.
*
* @return string новый api_key
*/
public function regenerate(): string
{
$newApiKey = bin2hex(random_bytes(32));
$all = $this->settings->getAll();
if (! isset($all['cron'])) {
$all['cron'] = [];
}
$all['cron']['api_key'] = $newApiKey;
$this->settingsUpdateService->update($all);
return $newApiKey;
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Bastion\Services;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\Support\Arr;
class SettingsService
{
private OcRegistryDecorator $registry;
private CacheInterface $cache;
private ConnectionInterface $connection;
public function __construct(OcRegistryDecorator $registry, CacheInterface $cache, ConnectionInterface $connection)
{
$this->registry = $registry;
$this->cache = $cache;
$this->connection = $connection;
}
public function update(array $data): void
{
$this->connection->transaction(function () use ($data) {
$this->registry->model_setting_setting->editSetting('module_megapay', [
'module_megapay_settings' => $data,
]);
$this->registry->model_setting_setting->editSetting('module_tgshop', [
'module_tgshop_status' => Arr::get($data, 'app.app_enabled', false) ? 1 : 0,
]);
});
$this->cache->clear();
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Bastion\Tasks;
use DateInterval;
use Exception;
use JsonException;
use Openguru\OpenCartFramework\MaintenanceTasks\BaseMaintenanceTask;
use RuntimeException;
class CleanUpOldAssetsTask extends BaseMaintenanceTask
{
public function handle(): void
{
$spaPath = rtrim(DIR_IMAGE, '/') . '/catalog/tgshopspa';
$assetsPath = $spaPath . '/assets';
$manifestPath = $spaPath . '/manifest.json';
if (! file_exists($manifestPath)) {
return;
}
try {
$contents = json_decode(file_get_contents($manifestPath), true, 512, JSON_THROW_ON_ERROR);
$entry = $contents['index.html'] ?? null;
if (! $entry) {
throw new RuntimeException('Некорректный manifest.json — отсутствует ключ index.html.');
}
$keep = [$entry['file']];
if (! empty($entry['css'])) {
foreach ($entry['css'] as $css) {
$keep[] = $css;
}
}
$deletedFiles = 0;
$keptFiles = 0;
foreach (glob($assetsPath . '/*') as $file) {
$ext = pathinfo($file, PATHINFO_EXTENSION);
if (! in_array($ext, ['js', 'css', 'map'])) {
continue;
}
$relative = 'assets/' . basename($file);
if (in_array($relative, $keep, true)) {
$keptFiles++;
continue;
}
if (is_file($file)) {
unlink($file);
$deletedFiles++;
}
}
if ($deletedFiles > 0) {
$this->logger->info(
sprintf('Очистка assets завершена. Удалено: %d, оставлено: %d', $deletedFiles, $keptFiles)
);
}
} catch (JsonException $e) {
$this->logger->error('Ошибка декодирования файла manifest.json: ' . $e->getMessage());
} catch (Exception $e) {
$this->logger->error('Ошибка удаления старых assets: ' . $e->getMessage(), ['exception' => $e]);
}
}
public function interval(): ?DateInterval
{
return new DateInterval('PT1H');
}
}

37
backend/src/bastion/routes.php Executable file
View File

@@ -0,0 +1,37 @@
<?php
use Bastion\Handlers\AutocompleteHandler;
use Bastion\Handlers\DictionariesHandler;
use Bastion\Handlers\FormsHandler;
use Bastion\Handlers\ImageHandler;
use Bastion\Handlers\LogsHandler;
use Bastion\Handlers\SendMessageHandler;
use Bastion\Handlers\SettingsHandler;
use Bastion\Handlers\StatsHandler;
use Bastion\Handlers\MegaPayPulseStatsHandler;
use Bastion\Handlers\TelegramCustomersHandler;
use Bastion\Handlers\TelegramHandler;
return [
'configureBotToken' => [SettingsHandler::class, 'configureBotToken'],
'getAutocompleteCategories' => [AutocompleteHandler::class, 'getCategories'],
'getAutocompleteCategoriesFlat' => [AutocompleteHandler::class, 'getCategoriesFlat'],
'getCategories' => [DictionariesHandler::class, 'getCategories'],
'getCategoriesById' => [AutocompleteHandler::class, 'getCategoriesById'],
'getChatId' => [TelegramHandler::class, 'getChatId'],
'getDashboardStats' => [StatsHandler::class, 'getDashboardStats'],
'getFormByAlias' => [FormsHandler::class, 'getFormByAlias'],
'getImage' => [ImageHandler::class, 'getImage'],
'getLogs' => [LogsHandler::class, 'getLogs'],
'getProductsById' => [AutocompleteHandler::class, 'getProductsById'],
'getSettingsForm' => [SettingsHandler::class, 'getSettingsForm'],
'getTelegramCustomers' => [TelegramCustomersHandler::class, 'getCustomers'],
'resetCache' => [SettingsHandler::class, 'resetCache'],
'regenerateCronScheduleUrl' => [SettingsHandler::class, 'regenerateCronScheduleUrl'],
'saveSettingsForm' => [SettingsHandler::class, 'saveSettingsForm'],
'getSystemInfo' => [SettingsHandler::class, 'getSystemInfo'],
'sendMessageToCustomer' => [SendMessageHandler::class, 'sendMessage'],
'testTgMessage' => [TelegramHandler::class, 'testTgMessage'],
'tgGetMe' => [TelegramHandler::class, 'tgGetMe'],
'getMegaPayPulseStats' => [MegaPayPulseStatsHandler::class, 'getStats'],
];

101
backend/src/cli.php Executable file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env php
<?php
use Console\ApplicationFactory;
use Console\Commands\CacheClearCommand;
use Console\Commands\CustomerCountsCommand;
use Console\Commands\PulseSendEventsCommand;
use Console\Commands\ScheduleRunCommand;
use Console\Commands\VersionCommand;
use Console\Commands\ImagesWarmupCacheCommand;
use Console\Commands\ImagesCacheClearCommand;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Logger;
use Openguru\OpenCartFramework\QueryBuilder\Connections\MySqlConnection;
use Openguru\OpenCartFramework\Support\Arr;
use Symfony\Component\Console\Application;
if (PHP_SAPI !== 'cli') {
die("This script can only be run from CLI.\n");
}
$baseDir = __DIR__;
$debug = false;
if (is_readable($baseDir . '/oc_telegram_shop.phar')) {
require_once "phar://{$baseDir}/oc_telegram_shop.phar/vendor/autoload.php";
require_once $baseDir . '/../../../admin/config.php';
} elseif (is_dir("$baseDir/oc_telegram_shop")) {
require_once "$baseDir/oc_telegram_shop/vendor/autoload.php";
require_once '/web/upload/admin/config.php';
} else {
throw new RuntimeException('Unable to locate application directory.');
}
// Get Settings from Database
$host = DB_HOSTNAME;
$username = DB_USERNAME;
$password = DB_PASSWORD;
$port = (int) DB_PORT;
$dbName = DB_DATABASE;
$prefix = DB_PREFIX;
$dsn = "mysql:host=$host;port=$port;dbname=$dbName";
$pdo = new PDO($dsn, $username, $password);
$connection = new MySqlConnection($pdo);
$raw = $connection->select("SELECT value FROM `{$prefix}setting` WHERE `key` = 'module_megapay_settings'");
$timezone = $connection->select("SELECT value FROM `{$prefix}setting` WHERE `key` = 'config_timezone'");
$timezone = $timezone[0]['value'] ?? 'UTC';
$json = json_decode($raw[0]['value'], true, 512, JSON_THROW_ON_ERROR);
$items = Arr::mergeArraysRecursively($json, [
'app' => [
'shop_base_url' => HTTPS_CATALOG, // for catalog: HTTPS_SERVER, for admin: HTTPS_CATALOG
'language_id' => 1,
'oc_timezone' => $timezone,
],
'paths' => [
'images' => DIR_IMAGE,
],
'logs' => [
'path' => DIR_LOGS,
],
'database' => [
'host' => DB_HOSTNAME,
'database' => DB_DATABASE,
'username' => DB_USERNAME,
'password' => DB_PASSWORD,
'prefix' => DB_PREFIX,
'port' => (int) DB_PORT,
],
'store' => [
'oc_store_id' => 0,
'oc_default_currency' => 'RUB',
'oc_config_tax' => false,
],
'orders' => [
'oc_customer_group_id' => 1,
],
'telegram' => [
'mini_app_url' => rtrim(HTTPS_CATALOG, '/') . '/image/catalog/tgshopspa/#/',
],
]);
$logger = new Logger('MegaPay_CLI', [], [], new DateTimeZone('UTC'));
$logger->pushHandler(
new RotatingFileHandler(
DIR_LOGS . '/megapay.log', 14, $debug ? Logger::DEBUG : Logger::INFO
),
);
$app = ApplicationFactory::create($items);
$app->setLogger($logger);
$app->boot();
$console = new Application('MegaPay', module_version());
$console->add($app->get(VersionCommand::class));
$console->add($app->get(ScheduleRunCommand::class));
$console->add($app->get(PulseSendEventsCommand::class));
$console->add($app->get(ImagesWarmupCacheCommand::class));
$console->add($app->get(ImagesCacheClearCommand::class));
$console->add($app->get(CacheClearCommand::class));
$console->add($app->get(CustomerCountsCommand::class));
$console->run();

51
backend/src/composer.json Executable file
View File

@@ -0,0 +1,51 @@
{
"name": "nikitakiselev/oc_telegram_shop",
"version": "v2.2.1",
"autoload": {
"psr-4": {
"Openguru\\OpenCartFramework\\": "framework/",
"App\\": "app/",
"Bastion\\": "bastion/",
"Console\\": "console/",
"Tests\\": "tests/"
},
"files": [
"framework/Support/helpers.php"
]
},
"authors": [
{
"name": "Nikita Kiselev",
"email": "mail@nikitakiselev.ru"
}
],
"require": {
"doctrine/dbal": "^3.10",
"ext-json": "*",
"ext-pdo": "*",
"guzzlehttp/guzzle": "^7.9",
"intervention/image": "^2.7",
"monolog/monolog": "^2.10",
"nesbot/carbon": "^2.73",
"php": "^7.4",
"predis/predis": "^2.0",
"psr/container": "^2.0",
"psr/log": "^1.1",
"symfony/cache": "^5.4",
"vlucas/phpdotenv": "^5.6",
"ramsey/uuid": "^4.2",
"symfony/http-foundation": "^5.4",
"symfony/console": "^5.4",
"dragonmantank/cron-expression": "^3.5",
"sentry/sentry": "^4.19"
},
"require-dev": {
"doctrine/sql-formatter": "^1.3",
"mockery/mockery": "^1.6",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^9.6",
"roave/security-advisories": "dev-latest",
"squizlabs/php_codesniffer": "*",
"marcocesarato/php-conventional-changelog": "^1.17"
}
}

6584
backend/src/composer.lock generated Executable file

File diff suppressed because it is too large Load Diff

122
backend/src/configs/app.php Executable file
View File

@@ -0,0 +1,122 @@
<?php
return [
'app' => [
'app_enabled' => true,
'app_name' => 'Megapay',
'app_icon' => null,
"theme_light" => "light",
"theme_dark" => "dark",
"app_debug" => false,
'image_aspect_ratio' => '1:1',
'image_crop_algorithm' => 'cover',
'haptic_enabled' => true,
],
'telegram' => [
"bot_token" => "",
"chat_id" => null,
"owner_notification_template" => <<<HTML
📦 <b>Новый заказ {order_id}</b>
Магазин: <b>{store_name}</b>
<b>Покупатель</b>
Имя: {customer}
Email: {email}
Телефон: {phone}
IP: {ip}
<b>Комментарий к заказу</b>
{comment}
<b>Сумма заказа:</b> {total}
<b>Дата оформления:</b> {created_at}
HTML,
"customer_notification_template" => <<<HTML
<b>Заказ оформлен</b>
Спасибо за ваш заказ в магазине <b>{store_name}</b>.
<b>Номер заказа:</b> {order_id}
<b>Сумма заказа:</b> {total}р.
<b>Дата оформления:</b> {created_at}
Информация о заказе сохранена.
При необходимости с вами свяжутся представители магазина.
HTML,
"mini_app_url" => "",
],
"metrics" => [
"yandex_metrika_enabled" => false,
"yandex_metrika_counter" => "",
],
'store' => [
'feature_coupons' => true,
'feature_vouchers' => true,
'show_category_products_button' => true,
'product_interaction_mode' => 'browser',
'manager_username' => null,
],
'texts' => [
'text_no_more_products' => 'Это всё по текущему запросу. Попробуйте уточнить фильтры или поиск.',
'text_empty_cart' => 'Ваша корзина пуста.',
'text_order_created_success' => 'Ваш заказ успешно оформлен и будет обработан в ближайшее время.',
'text_manager_button' => '💬 Связаться с менеджером',
'start_message' => <<<HTML
👋 <b>Добро пожаловать!</b>
Вы находитесь в официальном магазине.
Здесь вы можете ознакомиться с товарами, узнать подробности и оформить заказ прямо в Telegram.
Нажмите кнопку ниже, чтобы перейти в каталог.
HTML,
'start_image' => null,
'start_button' => [
'text' => '🛍 Перейти в каталог',
],
],
'orders' => [
'order_default_status_id' => 1,
],
'pulse' => [
'api_key' => '',
'batch_size' => 50,
'max_attempts' => 3,
],
'mainpage_blocks' => [
[
'type' => 'products_feed',
'title' => '',
'description' => '',
'is_enabled' => true,
'goal_name' => '',
'data' => [
'max_page_count' => 10,
'image_aspect_ratio' => '1:1',
],
],
],
'cache' => [
'namespace' => 'megapay',
'default_lifetime' => 60 * 60 * 24,
'options' => [
'db_table' => 'megapay_cache_items',
],
],
'paths' => [
'images_cache' => 'cache/megapay',
],
'cron' => [
'mode' => 'disabled',
'api_key' => '',
],
];

View File

@@ -0,0 +1,9 @@
<?php
use Bastion\Tasks\CleanUpOldAssetsTask;
return [
'tasks' => [
CleanUpOldAssetsTask::class,
],
];

View File

@@ -0,0 +1,36 @@
<?php
namespace Console;
use App\ServiceProviders\AppServiceProvider;
use App\ServiceProviders\SettingsServiceProvider;
use Openguru\OpenCartFramework\Application;
use Openguru\OpenCartFramework\Cache\CacheServiceProvider;
use Openguru\OpenCartFramework\ImageTool\ImageToolServiceProvider;
use Openguru\OpenCartFramework\QueryBuilder\QueryBuilderServiceProvider;
use Openguru\OpenCartFramework\Scheduler\SchedulerServiceProvider;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\MegaPayPulse\MegaPayPulseServiceProvider;
use Openguru\OpenCartFramework\Telegram\TelegramServiceProvider;
class ApplicationFactory
{
public static function create(array $settings): Application
{
$defaultConfig = require __DIR__ . '/../configs/app.php';
$merged = Arr::mergeArraysRecursively($defaultConfig, $settings);
return (new Application($merged))
->withServiceProviders([
SettingsServiceProvider::class,
QueryBuilderServiceProvider::class,
AppServiceProvider::class,
CacheServiceProvider::class,
TelegramServiceProvider::class,
MegaPayPulseServiceProvider::class,
SchedulerServiceProvider::class,
ImageToolServiceProvider::class,
]);
}
}

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