4 Commits

Author SHA1 Message Date
980f656a0a feat(admin): telegram settings improvements (#63)
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
2026-02-23 15:17:34 +03:00
c0ca0c731d feat(cron): add scheduled jobs configuration in admin (#59)
* feat(cron): add database schedule jobs instead of file

* feat(cron): add scheduled jobs configuration in admin (#59)

* reformat: fix codestyle (#59)

* reformat: fix codestyle (#59)

* feat: disable cron debug (#59)
2026-02-23 15:17:33 +03:00
0295a4b28b feat(spa): Increase max page limit to product lists 2026-02-23 15:16:13 +03:00
91c5d56903 feat(sentry): Implement Sentry tracing (#50) 2026-02-23 15:16:11 +03:00
77 changed files with 4881 additions and 3196 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 перед названием таблицы. Так ты получишь название таблицы с префиксом.
- Все таблицы моего модуля TeleCart начинаются с префикса `telecart_`. Примеры миграций лежат в `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": {
"telecart_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",
"telecartHandlers": "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 telecart_ 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": "telecart_",
"noForeignKeys": true
}
}

View File

@@ -0,0 +1,38 @@
## TeleCart Pulse Heartbeat Telemetry
### Цель
Раз в час отправлять телеметрию (heartbeat) на TeleCart Pulse, чтобы фиксировать состояние магазина и версии окружения без участия пользователя.
### Backend (`module/oc_telegram_shop/upload/oc_telegram_shop`)
- `framework/TeleCartPulse/TeleCartPulseService.php`
- Новый метод `handleHeartbeat()` собирает данные: домен (через `Utils::getCurrentDomain()`), username бота (через `TelegramService::getMe()`), версии PHP, модуля (из `composer.json`), OpenCart (`VERSION` и `VERSION_CORE`), текущий UTC timestamp.
- Последний успешный пинг кешируется (ключ `telecart_pulse_heartbeat`, TTL 1 час) через существующий `CacheInterface`.
- Подпись heartbeat выполняется через отдельный `PayloadSigner`, который использует секрет `pulse.heartbeat_secret`/`PULSE_HEARTBEAT_SECRET`. Логируются предупреждения при ошибках кеша/бота/подписи.
- Отправка идет на эндпоинт `heartbeat` с таймаутом 2 секунды и заголовком `X-TELECART-VERSION`, взятым из `composer.json`.
- `framework/TeleCartPulse/TeleCartPulseServiceProvider.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 TeleCart 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/
- Используй фиксированный префикс telecart_
- Добавь индексы где необходимо
Следуй архитектуре 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. Используй фиксированный префикс telecart_ для таблицы
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 с префиксом `telecart_`
### 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('telecart_customers')
->where('status', '=', 'active')
->orderBy('created_at', 'DESC')
->get();
// В крайних случаях можно использовать прямые SQL
$result = $this->database->query("SELECT * FROM telecart_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>
```

View File

@@ -4,6 +4,7 @@ on:
push: push:
branches: branches:
- master - master
- 'issue/**'
- develop - develop
pull_request: pull_request:
types: types:
@@ -134,11 +135,6 @@ jobs:
MODULE_ROOT="module/oc_telegram_shop/upload/oc_telegram_shop" MODULE_ROOT="module/oc_telegram_shop/upload/oc_telegram_shop"
echo "${{ needs.version_meta.outputs.tag }}" > "${MODULE_ROOT}/version.txt" echo "${{ needs.version_meta.outputs.tag }}" > "${MODULE_ROOT}/version.txt"
- name: Set version in install.xml
run: |
TAG="${{ needs.version_meta.outputs.tag }}"
sed -i "s#<version>.*</version>#<version>${TAG}</version>#" module/oc_telegram_shop/install.xml
- name: Build module - name: Build module
run: | run: |
bash scripts/ci/build.sh "${GITHUB_WORKSPACE}" bash scripts/ci/build.sh "${GITHUB_WORKSPACE}"

View File

@@ -4,43 +4,6 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
<!--- END HEADER --> <!--- END HEADER -->
## [2.3.0](https://github.com/telecart-labs/telecart/compare/v2.2.2...v2.3.0) (2026-05-25)
### Features
##### Admin
* Telegram settings improvements (#63) ([80c246](https://github.com/telecart-labs/telecart/commit/80c2467b4a7c2cb1c4101c483fe3ac31a5ad0cf1))
##### Ocmod
* Add TeleCart admin sidebar menu and local dev setup (#57) ([28c8ce](https://github.com/telecart-labs/telecart/commit/28c8ce3b6841ca1d4cc98c8e7bb275e1ad3adcf3))
##### Spa
* Increase max page limit to product lists (#88) ([a3f54a](https://github.com/telecart-labs/telecart/commit/a3f54a87dc8799b3355b294fc155c3cf9d22c3eb))
### Bug Fixes
##### Spa
* Handle empty category and products in carousel block ([d43564](https://github.com/telecart-labs/telecart/commit/d43564481d1e35ea8bb92310d20bff2989ca6ea7))
* Visual fixes for SPA (#106) ([c24e30](https://github.com/telecart-labs/telecart/commit/c24e30f14cef8a4b6956aaa2ada5dc9b12fbb9cb))
---
## [2.2.2](https://github.com/telecart-labs/telecart/compare/v2.2.1...v2.2.2) (2026-03-15)
### Bug Fixes
* Normalize order response to valid UTF-8 before JSON encoding (#83) ([f894d3](https://github.com/telecart-labs/telecart/commit/f894d33c464e28a15cbdcd5c72dfd90bd5379e92))
---
## [2.2.1](https://github.com/telecart-labs/telecart/compare/v2.2.0...v2.2.1) (2026-02-22) ## [2.2.1](https://github.com/telecart-labs/telecart/compare/v2.2.0...v2.2.1) (2026-02-22)

147
CLAUDE.md
View File

@@ -1,147 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Overview
TeleCart — коммерческий модуль для OpenCart, превращающий магазин в Telegram Mini App. Это монорепозиторий из трёх
частей:
- **PHP-модуль OpenCart** — `module/oc_telegram_shop/upload/` (вся бизнес-логика и собственный фреймворк).
- **SPA** — `frontend/spa/` (Telegram Mini App, витрина для покупателей).
- **Admin** — `frontend/admin/` (панель управления настройками модуля).
`src/` в корне — это распакованная установка OpenCart (генерируется скриптами, **не редактировать**; код модуля
«линкуется» в неё через `make link`).
## Документация по фичам (`docs/features/`)
Контекстная документация хранится в `docs/features/` — по одной поддиректории на фичу, внутри индексный
`CLAUDE.md`. Вложенные `CLAUDE.md` Claude Code подхватывает автоматически при чтении файлов из их директории,
поэтому индекс фичи читается «по требованию», когда работа касается этой фичи.
Перед тем как трогать незнакомую часть системы — загляни в её папку в `docs/features/`. Конвенции, шаблон новой
фичи и **реестр всех фич с прямыми путями** — в [`docs/features/CLAUDE.md`](docs/features/CLAUDE.md). Шаблон для
новой фичи — `docs/features/_TEMPLATE.md`. При изменении фичи поддерживай её доку в актуальном состоянии (код —
источник истины; при расхождении правь доку).
## Где живёт код модуля
Сердце проекта — `module/oc_telegram_shop/upload/oc_telegram_shop/` (далее «корень модуля»). Его структура:
| Каталог | Namespace | Назначение |
|------------------------|-------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
| `framework/` | `Openguru\OpenCartFramework\` | Собственный Laravel-подобный фреймворк: Container (DI), Router, QueryBuilder, Cache, Migrations, Telegram, Http, Translator |
| `src/` | `App\` | API витрины/SPA (catalog). Handlers, Services, Models, DTO, Filters, ServiceProviders |
| `bastion/` | `Bastion\` | API админ-панели. Handlers, Services, Tasks, ScheduledTasks |
| `console/` | `Console\` | CLI-команды (Symfony Console), точка входа `upload/cli.php` |
| `database/migrations/` | — | Миграции БД |
| `configs/` | — | `app.php`, `maintenance.php`, `schedule.php` |
| `tests/` | `Tests\` | `Unit/`, `Integration/`, `Telegram/` |
**Маршрутизация.** Запросы приходят через action-параметр и резолвятся по двум картам:
- `src/routes.php` — действия SPA (`products`, `getCart`, `checkout`, `webhook`, …)
- `bastion/routes.php` — действия админки (`getSettingsForm`, `getTelegramCustomers`, `getDashboardStats`, …)
Каждая запись — `'actionName' => [HandlerClass::class, 'method']`.
**Точки входа OpenCart** (тонкие обёртки, грузят autoload и поднимают `App\ApplicationFactory`):
- `module/.../upload/catalog/controller/extension/tgshop/handle.php` — SPA API
- `module/.../upload/admin/controller/extension/module/tgshop.php` — админка
В продакшене модуль собирается в `oc_telegram_shop.phar` и подключается из `system/library/oc_telegram_shop/`; в dev —
из исходников через `vendor/autoload.php`.
## Архитектурные правила (бэкенд)
Паттерн — MVC-L поверх собственного фреймворка. Соблюдай строго:
- **Совместимость с PHP 7.4.** Минимальные требования OpenCart и модуля — PHP 7.4 (`composer.json`: `^7.4`). Не используй синтаксис и функции PHP 8.0+ (named arguments, `match`, constructor property promotion, `enum`, `readonly`, nullsafe `?->`, union types и т.п.).
- **Dependency Injection через конструктор.** Никогда не создавай зависимости через `new` внутри классов — внедряй их.
Регистрация сервисов идёт в Service Providers (`src/ServiceProviders/`) через `$this->app->singleton(...)`.
- **Query Builder вместо сырого SQL.** Используй `$this->builder->newQuery()->from(...)->where(...)->get()`. Прямой
SQL — только в крайних случаях; никогда не конкатенируй значения в запрос (SQL injection).
- **Слои:** Handler (валидация + вызов сервиса + `JsonResponse`) → Service (бизнес-логика) → Model (доступ к данным). Не
смешивай.
- **Строгая типизация:** `declare(strict_types=1)`, типы аргументов и возвращаемых значений, nullable где нужно. PSR-12.
- **Безопасный доступ к массивам:** `Arr::get($data, 'key', $default)` вместо `$data['key']`.
- **Таблицы:** префикс OpenCart (`oc_`) + собственные таблицы модуля.
## Архитектура фронтенда
Оба приложения — Vue 3, **только `<script setup>` + Composition API**, Pinia (setup-stores), Vue Router.
- **SPA** (`frontend/spa/`): Tailwind CSS, FormKit, Swiper, `ofetch`. Сборка →
`module/.../upload/image/catalog/tgshopspa/`. Тесты — Vitest.
- **Admin** (`frontend/admin/`): PrimeVue (основной UI-кит), Tailwind/DaisyUI, axios, CodeMirror, `vuedraggable`.
Сборка → `module/.../upload/admin/view/javascript/telecart/`.
Конвенции: компоненты — PascalCase, файлы логики — camelCase; обработчики событий с префиксом `handle`; `computed` для
производных значений; `??` (не `||`) для дефолтов чисел/булевых; сложную логику выносить в composables. Ошибки API
ловить и показывать через toast (admin: `useToast` из PrimeVue).
**FormBuilder** (admin) — отдельная подсистема визуального конструктора форм. Единственный источник истины — FormKit
Schema JSON (без промежуточных DTO). Drag-and-drop строго на `vuedraggable` (не `@formkit/drag-and-drop`).
Tailwind-классы там идут с префиксом `tw:`.
## Команды
Все `make`-цели выполняются **внутри Docker-контейнера `web`** — нужен запущенный `docker compose`.
**Старт / окружение:**
```bash
make setup # первичная установка: скачать OpenCart, установить, поднять, link
make start # поднять контейнеры
make dev # link + параллельно vite dev для SPA и Admin
make dev-spa # только SPA (vite)
make dev-admin # только Admin (vite)
make link # «слинковать» код модуля в установку OpenCart (после правок структуры)
make ssh # шелл в контейнер в каталоге модуля
```
**Тесты (PHP, PHPUnit в контейнере):**
```bash
make test # все тесты (--testdox)
make test-unit # tests/Unit
make test-integration # tests/Integration
make test-telegram # tests/Telegram
make test-coverage # HTML-покрытие в coverage/
```
Запуск одного теста — через ssh в контейнер:
```bash
make ssh
./vendor/bin/phpunit --filter testMethodName tests/Unit/SomeTest.php
```
**Тесты фронтенда (SPA):** `cd frontend/spa && npm run test` (Vitest), `npm run test:run` для однократного прогона.
**Линтеры / статический анализ:**
```bash
make lint # PHPStan (level 1) по src, bastion, framework, console
make phpcs # PHP_CodeSniffer PSR-12 (проверка)
make phpcbf # PHP_CodeSniffer PSR-12 (автофикс)
```
Admin-фронтенд: `cd frontend/admin && npm run lint` (oxlint + eslint), `npm run format` (prettier).
**Сборка / релиз:**
```bash
make phar # собрать oc_telegram_shop.phar (через docker/build.dockerfile)
make ocmod # применить OCMOD-модификации
make cli ARGS="..." # запустить console-команду модуля
make changelog # сгенерировать CHANGELOG из conventional commits
make release # changelog + commit релиза
```
## Коммиты
Используются **Conventional Commits** (`feat:`, `fix:`, `chore:`, `build(deps):` …) — CHANGELOG и версии генерируются
автоматически. **Не делать `git commit`/`git push` без явного указания пользователя.**

View File

@@ -45,9 +45,6 @@ dev-admin:
rm -rf src/upload/admin/view/javascript/telecart && \ rm -rf src/upload/admin/view/javascript/telecart && \
cd frontend/admin && npm run dev cd frontend/admin && npm run dev
ocmod:
./scripts/apply_ocmod.sh
lint: lint:
docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpstan analyse" docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpstan analyse"

View File

@@ -1,43 +0,0 @@
# docs/features — документация по фичам
Эта директория хранит описания **фич** проекта TeleCart: по одной поддиректории на фичу.
Цель — дать агенту (и человеку) возможность быстро восстановить контекст по конкретной
части системы, не перечитывая весь код: что фича делает, через какие точки входит, какие
файлы за неё отвечают и как данные текут по слоям.
Вложенные `CLAUDE.md` подхватываются Claude Code автоматически при чтении файлов из их
директории — поэтому индекс фичи читается «по требованию», когда работа касается этой фичи.
## Структура
```
docs/features/
├── CLAUDE.md # этот файл: конвенции + реестр фич
├── _TEMPLATE.md # шаблон для новой фичи (копировать при описании)
└── <feature-slug>/
└── CLAUDE.md # индексный файл конкретной фичи
```
## Конвенции
- **Имя папки** — `kebab-case`, по сути фичи: `cart`, `telegram-webhook`, `form-builder`.
- **Один индекс на фичу** — `<feature-slug>/CLAUDE.md`, заполняется по `_TEMPLATE.md`.
- **Ссылки на код — обязательны.** Указывай реальные пути (`module/.../src/Handlers/CartHandler.php`)
и `action` из карт маршрутов. Код — источник истины; документация лишь навигирует к нему.
- **Не дублируй код.** Описывай назначение, поток и нюансы, а не построчный пересказ — он устареет.
- **Связывай фичи** через ссылки на их папки (например `[checkout](../checkout/CLAUDE.md)`).
- Если описанное в доке расходится с кодом — прав код; обнови доку.
## Как добавить фичу
1. `cp _TEMPLATE.md <feature-slug>/CLAUDE.md` (создав папку).
2. Заполни секции шаблона, опираясь на реальный код.
3. Добавь строку в реестр ниже.
## Реестр фич
| Фича | Путь | Краткое описание |
|------|----------------------------------|-------------------------------------------------------------|
| cart | [cart/CLAUDE.md](cart/CLAUDE.md) | Корзина: наполнение, просмотр, передача в оформление заказа |
<!-- Новые фичи добавляй строкой в таблицу выше, по алфавиту. -->

View File

@@ -1,38 +0,0 @@
# <Название фичи>
> Скопируй этот файл в `docs/features/<feature-slug>/CLAUDE.md` и заполни секции.
> Удали подсказки в кавычках после заполнения. Не оставляй пустых «TODO».
## Назначение
> 13 предложения: какую задачу решает фича и для кого (SPA-покупатель / админ / системное).
## Точки входа
> `action` из карт маршрутов и где они объявлены. Таблица:
| action | Метод | Где объявлен |
|-----------------|-------------------|-------------------------------------------|
| `exampleAction` | `Handler::method` | `src/routes.php` или `bastion/routes.php` |
## Ключевые файлы по слоям
> Путь от корня репозитория. Только то, что реально относится к фиче.
- **Handler:** `module/.../src/Handlers/XxxHandler.php` — валидация, вызов сервиса, `JsonResponse`.
- **Service:** `module/.../src/Services/XxxService.php` — бизнес-логика.
- **Model / данные:** `module/.../src/Models/Xxx.php` или таблицы БД.
- **Фронтенд:** `frontend/spa/...` или `frontend/admin/...` (store, view, компоненты).
- **Регистрация в DI:** `src/ServiceProviders/...` (если есть нестандартная привязка).
## Поток данных
> Кратко: запрос → слои → ответ. Можно списком шагов или стрелками.
## Связанные фичи
> Ссылки на соседние папки: `[checkout](../checkout/CLAUDE.md)`.
## Нюансы и подводные камни
> Неочевидное: ограничения PHP 7.4, особенности OpenCart-реестра, кэш, edge-cases, известные баги.

View File

@@ -1,59 +0,0 @@
# Cart — корзина
## Назначение
Корзина покупателя в SPA (Telegram Mini App): отдаёт текущее содержимое корзины с
посчитанными ценами/итогами, принимает набор позиций из SPA и наполняет ими корзину
OpenCart перед оформлением заказа. Создание самого заказа — за пределами фичи (см.
[order](../order/CLAUDE.md), если описан).
## Точки входа
| action | Метод | Где объявлен |
|------------|-------------------------|------------------|
| `getCart` | `CartHandler::index` | `src/routes.php` |
| `checkout` | `CartHandler::checkout` | `src/routes.php` |
> `storeOrder` (`OrderHandler::store`) относится к оформлению заказа, не к корзине.
## Ключевые файлы по слоям
- **Handler:** `module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/CartHandler.php`
`index()` возвращает содержимое корзины, `checkout()` принимает позиции из SPA
(`productId`, `quantity`, `options`) и кладёт их в корзину через `Cart\Cart::add()`.
- **Service:** `module/oc_telegram_shop/upload/oc_telegram_shop/src/Services/CartService.php`
`getCart()` собирает данные через OpenCart-реестр (`OcRegistryDecorator`): язык
`checkout/cart`, проверки стока, веса, цен, форматирование через `Cart\Currency`.
- **OpenCart-зависимости:** `Cart\Cart`, `Cart\Currency` — нативные классы OpenCart,
внедряются через DI.
- **Фронтенд (SPA):**
- `frontend/spa/src/stores/CartStore.js` — Pinia-стор корзины.
- `frontend/spa/src/ShoppingCart.js` — клиентская логика корзины.
- `frontend/spa/src/views/Cart.vue` — экран корзины.
- `frontend/spa/src/components/CartButton.vue` — кнопка/индикатор.
## Поток данных
**Просмотр:** SPA → `action=getCart``CartHandler::index``CartService::getCart()`
читает корзину из OpenCart-реестра → ответ оборачивается `Utf8Cleaner::clean()`
`JsonResponse { data }`.
**Передача в оформление:** SPA отправляет массив позиций → `action=checkout`
`CartHandler::checkout` маппит `options` (берёт `product_option_value_id`) и для каждой
позиции вызывает `$cart->add(productId, quantity, options)` → возвращает очищенные позиции.
## Связанные фичи
- Оформление заказа (`OrderHandler::store` / `OrderCreateService`).
- Настройки (`SettingsService`) влияют на отображение цен/стока в `getCart`.
## Нюансы и подводные камни
- **PHP 7.4.** Без синтаксиса 8.0+ (см. корневой `CLAUDE.md`).
- **Весь вывод чистится `App\Support\Utf8Cleaner::clean()`** перед отдачей — невалидный
UTF-8 из товаров OpenCart ломает `json_encode`.
- **`getCart` зависит от состояния OpenCart-сессии и реестра** (сток, валюта, логин
покупателя, язык `checkout/cart`) — это не чистая функция, результат меняется от конфига
магазина (`config_stock_checkout`, `config_customer_price`, `config_cart_weight` и т.д.).
- **`checkout` мутирует корзину OpenCart** (`Cart\Cart::add`), а не локальное состояние —
он переносит выбор из SPA в нативную корзину OpenCart.

View File

@@ -1,168 +0,0 @@
# Дизайн: честный и отключаемый heartbeat
Дата: 2026-06-30
Статус: согласовано (ожидает финального ревью)
## Проблема
Модуль скрытно отправляет на сервер разработчика (`pulse.telecart.pro`) сведения об
окружении, где он запущен (heartbeat). Покупатель об этом не уведомляется и не может
это отключить. Это нечестно по отношению к покупателю и создаёт риски (GDPR / 152-ФЗ,
претензии маркетплейсов).
## Цель
Сделать heartbeat **честным**: прозрачным и отключаемым, сохранив поведение по умолчанию.
- **Прозрачность:** в админ-панели покупатель видит, что именно собирается, куда
отправляется и зачем.
- **Контроль:** отдельный переключатель, которым покупатель может выключить heartbeat.
- **По умолчанию включено** (текущее поведение сохраняется, но становится видимым и
управляемым).
## Границы (scope)
- **Только heartbeat.** Остальной TeleCart Pulse (Telegram-рассылки, события кампаний)
**не трогаем** — он гейтится наличием `pulse.api_key` и, по позиции владельца, прав не
нарушает.
- Концептуально heartbeat считаем частью фичи **TeleCart Pulse** (одна папка доков), но
у него **отдельный собственный выключатель**, не завязанный на `pulse.api_key` и не
завязанный на секрет подписи. Pulse и heartbeat включаются/выключаются независимо.
## Терминология
- **heartbeat** — периодическая (раз в час по кэшу) отправка информации об окружении:
`domain`, `bot_name`, `php_version`, `module_version`, `opencart_version`,
`opencart_version_core`. Триггерится из SPA при открытии WebApp.
- **TeleCart Pulse** — фича рассылок и аналитики кампаний; heartbeat живёт внутри её
namespace в коде (`framework/TeleCartPulse/`).
## Текущее устройство (как есть)
- `TeleCartPulseService::handleHeartbeat()`
(`module/.../framework/TeleCartPulse/TeleCartPulseService.php:133`) — ставит кэш-флаг
`telecart_pulse_heartbeat` (ttl 3600), дёргает `getMe()`, формирует payload и шлёт POST
на `PULSE_API_HOST/heartbeat`.
- Отправка фактически происходит только если есть `heartbeatPayloadSigner` (строки 168170),
который создаётся в провайдере при наличии секрета (`pulse.heartbeat_secret` /
`PULSE_HEARTBEAT_SECRET`).
- `TeleCartPulseServiceProvider`
(`module/.../framework/TeleCartPulse/TeleCartPulseServiceProvider.php:22-35`) читает
значения из конфига и инжектит их в `TeleCartPulseService` через конструктор.
- **Пользовательской (per-install) настройки для heartbeat сейчас нет.**
## Решение
### Принцип
Использовать существующий паттерн: провайдер читает значение из конфига и **инжектит**
его в сервис через конструктор (как уже делается для `pulse.api_key` и signer). Не вводить
новых механизмов гейтинга.
### 1. Backend
**`module/.../configs/app.php`** — секция `pulse` (строки 8690): добавить флаг с дефолтом
`true`.
```php
'pulse' => [
'api_key' => '',
'heartbeat_enabled' => true, // новый флаг
'batch_size' => 50,
'max_attempts' => 3,
],
```
**`TeleCartPulseServiceProvider`** — прочитать `pulse.heartbeat_enabled` и передать булевый
флаг в конструктор `TeleCartPulseService` (рядом с `apiKey` и `heartbeatSigner`). Привести
к `bool` явно.
**`TeleCartPulseService`** — принять флаг в конструкторе (новое поле `private bool
$heartbeatEnabled`). В `handleHeartbeat()`**ранний `return` в самом начале метода**, до
проверки/установки кэша и до `getMe()`, если флаг выключен:
```php
public function handleHeartbeat(): void
{
if (! $this->heartbeatEnabled) {
return;
}
// ... существующая логика
}
```
Совместимость с PHP 7.4: без named arguments / промоушена свойств / union-типов. Параметр
конструктора — позиционный, со значением по умолчанию `true` для обратной совместимости
вызовов в тестах.
### 2. Frontend (админка) + прозрачность
**`frontend/admin/src/stores/settings.js`** — секция `pulse` (строки 8992): добавить
`heartbeat_enabled` в дефолтное состояние state.
**`frontend/admin/src/views/TeleCartPulseView.vue`** — добавить `ItemBool` с переключателем,
привязанным к `settings.items.pulse.heartbeat_enabled`. В слот-описание — **честное, но
нейтральное по тону раскрытие**:
- перечислить, что отправляется: домен, имя бота, версия PHP, версия модуля, версии
OpenCart;
- указать адрес назначения: `pulse.telecart.pro`;
- описать цель мягко: для улучшения продукта и техподдержки (без акцента на проверке
лицензии).
Точную формулировку владелец отредактирует; черновик в нейтральном тоне готовит реализация.
### 3. Поведение для существующих установок
Дефолт `true` в конфиге мержится с сохранёнными настройками
(`ApplicationFactory`: `Arr::mergeArraysRecursively`). У установок без сохранённого значения
heartbeat остаётся включённым — поведение не меняется, но теперь переключатель виден.
Когда покупатель выключает тумблер и сохраняет, значение пишется в `oc_setting`
(`module_telecart_settings`) и читается на backend.
### 4. Гейтинг — итоговая логика
heartbeat реально отправляется только если **одновременно**: `heartbeat_enabled = true`
**и** есть signer (секрет настроен). Переключатель — пользовательский контроль поверх
существующего серверного гейта; они независимы.
## Тестирование
Unit-тест на `TeleCartPulseService::handleHeartbeat()`:
- `heartbeat_enabled = false` → метод выходит сразу: HTTP-запрос не выполняется, кэш-флаг
`telecart_pulse_heartbeat` **не** ставится, `getMe()` не вызывается.
- `heartbeat_enabled = true` + signer присутствует → payload формируется и уходит (как
сейчас).
Запуск: `make test-unit` (или `./vendor/bin/phpunit --filter ...` через `make ssh`).
## Документация (в этом порядке)
1. **`docs/features/settings/CLAUDE.md`** — каркас системы настроек: путь поля «форма в
админке → `saveSettingsForm` → таблица `oc_setting` (`module_telecart_settings`) →
`Config\Settings` / DTO → backend», и **чек-лист «как добавить новый toggle»** по слоям
(config-дефолт → провайдер/DTO → store → view-компонент `ItemBool`). По шаблону
`docs/features/_TEMPLATE.md`. Добавить строку в реестр в `docs/features/CLAUDE.md`.
2. **`docs/features/telecart-pulse/CLAUDE.md`** — фича Pulse целиком: рассылки, события
кампаний (гейт `api_key`) и heartbeat с новым выключателем. Ссылка на `[[settings]]`.
Добавить строку в реестр.
## Затрагиваемые файлы (сводка)
- `module/.../configs/app.php` — флаг `pulse.heartbeat_enabled`.
- `module/.../framework/TeleCartPulse/TeleCartPulseServiceProvider.php` — чтение и инжект.
- `module/.../framework/TeleCartPulse/TeleCartPulseService.php` — поле + ранний return.
- `frontend/admin/src/stores/settings.js` — дефолт в state.
- `frontend/admin/src/views/TeleCartPulseView.vue``ItemBool` + текст раскрытия.
- `module/.../tests/Unit/...` — тест на гейтинг heartbeat.
- `docs/features/settings/CLAUDE.md`, `docs/features/telecart-pulse/CLAUDE.md`,
`docs/features/CLAUDE.md` (реестр).
## Явно вне scope (YAGNI)
- Не делаем opt-in (по умолчанию остаётся включено).
- Не делаем отдельный consent-баннер на первой установке (достаточно видимого
переключателя с раскрытием).
- Не трогаем события кампаний и рассылки Pulse.
- Не выносим heartbeat в отдельный namespace/фичу из `TeleCartPulse`.

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,7 @@
"@primeuix/themes": "^1.2.5", "@primeuix/themes": "^1.2.5",
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"@vueuse/core": "^14.0.0", "@vueuse/core": "^14.0.0",
"axios": "^1.15.2", "axios": "^1.13.5",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"daisyui": "^5.4.2", "daisyui": "^5.4.2",
"js-md5": "^0.8.3", "js-md5": "^0.8.3",
@@ -51,7 +51,7 @@
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"oxlint": "~1.23.0", "oxlint": "~1.23.0",
"prettier": "3.6.2", "prettier": "3.6.2",
"vite": "^7.3.3", "vite": "^7.1.11",
"vite-plugin-vue-devtools": "^8.0.3" "vite-plugin-vue-devtools": "^8.0.3"
} }
} }

View File

@@ -0,0 +1,48 @@
<template>
<Dropdown
:model-value="modelValue"
:options="options"
option-label="label"
option-value="value"
placeholder="Интервал"
class="tw:w-[14rem] tw:shrink-0"
@update:model-value="$emit('update:modelValue', $event ?? '')"
/>
</template>
<script setup>
import { computed } from 'vue';
import Dropdown from 'primevue/dropdown';
const props = defineProps({
modelValue: {
type: String,
default: '',
},
});
defineEmits(['update:modelValue']);
/** Пресеты интервалов (label — для отображения, value — cron expression) */
const PRESETS = [
{ label: 'Раз в минуту', value: '* * * * *' },
{ label: 'Раз в 5 минут', value: '*/5 * * * *' },
{ label: 'Раз в 10 минут', value: '*/10 * * * *' },
{ label: 'Раз в час', value: '0 * * * *' },
{ label: 'Раз в 3 часа', value: '0 */3 * * *' },
{ label: 'Раз в 6 часов', value: '0 */6 * * *' },
{ label: 'Раз в сутки', value: '0 0 * * *' },
{ label: 'Раз в неделю', value: '0 0 * * 0' },
];
const presetValues = new Set(PRESETS.map((p) => p.value));
/** Только пресеты; если текущее значение не из списка — показываем его в списке (уже сохранённое в БД), чтобы не терять отображение */
const options = computed(() => {
const current = props.modelValue ?? '';
if (!current || presetValues.has(current)) {
return PRESETS;
}
return [{ label: current, value: current }, ...PRESETS];
});
</script>

View File

@@ -0,0 +1,104 @@
<template>
<SettingsItem label="URL для cron-job.org">
<template #default>
<InputGroup>
<Button
icon="fa fa-refresh"
severity="secondary"
:loading="regeneratingUrl"
v-tooltip.top="'Перегенерировать URL'"
@click="confirmRegenerateUrl"
/>
<Button icon="fa fa-copy" severity="secondary" @click="copyToClipboard"/>
<InputText readonly :model-value="cronJobOrgUrl" class="tw:w-full"/>
</InputGroup>
</template>
<template #help>
Создайте задачу на <a href="https://cron-job.org/" target="_blank" rel="noopener" class="tw:underline">cron-job.org</a>, укажите этот URL и интервал (например, каждые 5 минут). Метод: GET. Учитывайте лимиты по времени запроса на вашем хостинге для тяжёлых задач возможны таймауты. При утечке URL нажмите «Перегенерировать URL» и обновите задачу на cron-job.org.
</template>
</SettingsItem>
</template>
<script setup>
import { computed, ref } from 'vue';
import { useSettingsStore } from '@/stores/settings.js';
import SettingsItem from '@/components/SettingsItem.vue';
import InputText from 'primevue/inputtext';
import Button from 'primevue/button';
import InputGroup from 'primevue/inputgroup';
import { useConfirm } from 'primevue/useconfirm';
import { toastBus } from '@/utils/toastHelper.js';
import { apiPost } from '@/utils/http.js';
const settings = useSettingsStore();
const confirm = useConfirm();
const regeneratingUrl = ref(false);
const cronJobOrgUrl = computed(() => settings.items.cron?.schedule_url ?? '');
function confirmRegenerateUrl(event) {
confirm.require({
group: 'popup',
target: event.currentTarget,
message: 'После смены URL его нужно будет обновить в задаче на cron-job.org. Продолжить?',
icon: 'pi pi-exclamation-triangle',
rejectProps: { label: 'Отмена', severity: 'secondary', outlined: true },
acceptProps: { label: 'Перегенерировать', severity: 'secondary' },
accept: () => regenerateUrl(),
});
}
async function regenerateUrl() {
regeneratingUrl.value = true;
try {
const res = await apiPost('regenerateCronScheduleUrl', {});
if (res?.success && res.data?.api_key) {
settings.items.cron.api_key = res.data.api_key;
if (res.data.schedule_url !== undefined) {
settings.items.cron.schedule_url = res.data.schedule_url;
}
toastBus.emit('show', {
severity: 'success',
summary: 'URL обновлён',
detail: 'Обновите URL в задаче на cron-job.org',
life: 4000,
});
} else {
toastBus.emit('show', {
severity: 'error',
summary: 'Ошибка',
detail: res?.data?.error ?? 'Не удалось перегенерировать URL',
life: 4000,
});
}
} catch (err) {
toastBus.emit('show', {
severity: 'error',
summary: 'Ошибка',
detail: err?.response?.data?.error ?? 'Не удалось перегенерировать URL',
life: 4000,
});
} finally {
regeneratingUrl.value = false;
}
}
async function copyToClipboard() {
try {
await navigator.clipboard.writeText(cronJobOrgUrl.value);
toastBus.emit('show', {
severity: 'success',
summary: 'Скопировано',
detail: 'URL скопирован в буфер обмена',
life: 2000,
});
} catch (err) {
toastBus.emit('show', {
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось скопировать текст',
life: 2000,
});
}
}
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div class="tw:border tw:border-gray-300 tw:rounded-md tw:bg-white tw:overflow-hidden">
<div class="tw:flex tw:flex-col">
<div
v-for="job in scheduledJobs"
:key="job.id"
class="tw:flex tw:items-center tw:gap-4 tw:py-3 tw:px-3 tw:border-b tw:border-gray-200 tw:last:border-b-0"
>
<ToggleSwitch
:model-value="Boolean(job.is_enabled)"
@update:model-value="onJobToggle(job, $event)"
/>
<div class="tw:min-w-0 tw:flex-1 tw:flex tw:flex-col tw:gap-0.5">
<span class="tw:text-[inherit] tw:font-bold">{{ hasJobMeta(job.name) ? jobMeta(job.name).friendlyName : job.name }}</span>
<span v-if="hasJobMeta(job.name) && jobMeta(job.name).description" class="tw:text-sm tw:text-gray-500">
{{ jobMeta(job.name).description }}
</span>
</div>
<span
v-if="job.failed_reason"
class="tw:inline-flex tw:items-center tw:gap-1 tw:shrink-0 tw:px-2 tw:py-1 tw:rounded tw:text-sm tw:bg-red-100 tw:text-red-700 tw:cursor-default"
role="img"
:aria-label="'Ошибка: ' + job.failed_reason"
v-tooltip.top="errorTooltip(job)"
>
<i class="fa fa-exclamation-circle" aria-hidden="true"/>
Ошибка
</span>
<div
v-else
class="tw:flex tw:flex-col tw:gap-0.5 tw:shrink-0 tw:items-end"
>
<span
class="tw:inline-flex tw:items-center tw:shrink-0 tw:px-2 tw:py-1 tw:rounded tw:text-sm tw:bg-green-100 tw:text-green-800 tw:cursor-default"
v-tooltip.top="'Дата последнего успешного запуска'"
>
{{ formatLastRun(job.last_success_at) }}
</span>
<span
v-if="formatDuration(job.last_duration_seconds)"
class="tw:text-xs tw:text-gray-500"
>
Время выполнения: {{ formatDuration(job.last_duration_seconds) }}
</span>
</div>
<div class="tw:shrink-0 tw:w-[14rem]">
<CronExpressionSelect
:model-value="job.cron_expression"
@update:model-value="job.cron_expression = $event"
/>
</div>
</div>
<div v-if="!scheduledJobs.length" class="tw:text-gray-500 tw:py-4 tw:px-3">
Нет запланированных задач
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useSettingsStore } from '@/stores/settings.js';
import ToggleSwitch from 'primevue/toggleswitch';
import CronExpressionSelect from '@/components/CronExpressionSelect.vue';
const settings = useSettingsStore();
/** Человекочитаемое имя и описание задач (ключ — job.name с бэкенда) */
const JOB_META = {
telecart_pulse_send_events: {
friendlyName: 'Отправка данных в TeleCart Pulse',
description: 'Отправка данных телеметрии о действиях в TeleCart. Требуется для сбора метрик по рассылкам и кампаниям, сделанных через сервис TeleCart Pulse',
},
};
function hasJobMeta(jobName) {
return jobName in JOB_META;
}
function jobMeta(jobName) {
return JOB_META[jobName];
}
const scheduledJobs = computed(() => settings.items.scheduled_jobs ?? []);
function formatLastRun(value) {
if (!value) return '—';
const d = typeof value === 'string' ? new Date(value.replace(' ', 'T')) : new Date(value);
return Number.isNaN(d.getTime()) ? value : d.toLocaleString('ru-RU');
}
function formatDuration(seconds) {
if (seconds == null || seconds === '' || Number(seconds) <= 0) return '';
const n = Number(seconds);
if (Number.isNaN(n)) return '';
if (n < 1) return 'менее 1с';
return `${n % 1 ? n.toFixed(1) : Math.round(n)}с`;
}
function errorTooltip(job) {
const dateStr = formatLastRun(job.failed_at);
return `${dateStr}\n${job.failed_reason ?? ''}`.trim();
}
function onJobToggle(job, isEnabled) {
job.is_enabled = isEnabled ? 1 : 0;
}
</script>

View File

@@ -1,24 +1,65 @@
<template> <template>
<div class="form-group"> <div class="form-group">
<label class="col-sm-2 control-label" for="module_tgshop_status"> <div class="col-sm-2 tw:flex tw:flex-col tw:gap-1">
<label class="control-label" for="module_tgshop_status">
{{ label }} {{ label }}
</label> </label>
<a
v-if="docHref"
:href="docHref"
target="_blank"
rel="noopener noreferrer"
class="tw:inline-flex tw:items-center tw:gap-1 tw:text-sm tw:text-gray-500 hover:tw:text-gray-700 tw:underline tw:decoration-dotted tw:decoration-1 tw:underline-offset-2 tw:w-fit tw:self-end"
>
<i class="fa fa-external-link tw:text-xs" aria-hidden="true"></i>
<span>Документация</span>
</a>
</div>
<div class="col-sm-10"> <div class="col-sm-10">
<slot name="default"></slot> <slot name="default"></slot>
<div class="help-block"> <div class="help-block">
<slot name="help"></slot> <slot name="help"></slot>
</div> </div>
<div v-if="hasExpandable">
<Button
:label="expandableLabel"
severity="info"
link
size="small"
@click="expanded = !expanded"
/>
<div v-show="expanded" class="tw:mt-2 tw:space-y-2">
<slot name="expandable"></slot>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, useSlots, computed } from 'vue';
import Button from 'primevue/button';
const props = defineProps({ const props = defineProps({
label: { label: {
type: String, type: String,
default: '', default: '',
}, },
/** Ссылка на документацию: отображается под label, открывается в новой вкладке */
docHref: {
type: String,
default: '',
},
/** Подпись кнопки раскрытия блока #expandable (по умолчанию «Подробнее») */
expandableLabel: {
type: String,
default: 'Подробнее',
},
}); });
const slots = useSlots();
const hasExpandable = computed(() => !!slots.expandable);
const expanded = ref(false);
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -93,7 +93,11 @@ export const useSettingsStore = defineStore('settings', {
cron: { cron: {
mode: 'disabled', mode: 'disabled',
api_key: '',
schedule_url: '',
}, },
scheduled_jobs: [],
}, },
}), }),
@@ -121,6 +125,14 @@ export const useSettingsStore = defineStore('settings', {
...this.items, ...this.items,
...response.data, ...response.data,
}; };
// Нормализуем типы у запланированных задач (is_enabled — число 0/1), чтобы переключение туда-обратно не меняло хеш
const jobs = this.items.scheduled_jobs;
if (Array.isArray(jobs)) {
this.items.scheduled_jobs = jobs.map((job) => ({
...job,
is_enabled: job.is_enabled === true || job.is_enabled === 1 || job.is_enabled === '1' ? 1 : 0,
}));
}
// Сохраняем хеш исходного состояния после загрузки // Сохраняем хеш исходного состояния после загрузки
this.originalItemsHash = md5(JSON.stringify(this.items)); this.originalItemsHash = md5(JSON.stringify(this.items));
} else { } else {

View File

@@ -1,5 +1,5 @@
<template> <template>
<SettingsItem label="Режим работы планировщика"> <SettingsItem label="Режим работы планировщика" doc-href="https://docs.telecart.pro/features/cron/">
<template #default> <template #default>
<SelectButton <SelectButton
v-model="settings.items.cron.mode" v-model="settings.items.cron.mode"
@@ -13,22 +13,35 @@
<div v-if="settings.items.cron.mode === 'disabled'" class="tw:text-red-600 tw:font-bold"> <div v-if="settings.items.cron.mode === 'disabled'" class="tw:text-red-600 tw:font-bold">
Все фоновые задачи отключены. Все фоновые задачи отключены.
</div> </div>
<div v-else-if="settings.items.cron.mode === 'cron_job_org'">
Задачи запускаются по вызову URL с сервиса <a href="https://cron-job.org/" target="_blank" rel="noopener" class="tw:underline">cron-job.org</a>. Подходит для лёгких задач; при большом количестве товаров или тяжёлых операциях возможны таймауты.
</div>
<div v-else> <div v-else>
Рекомендуемый режим. Использует системный планировщик задач Linux. Рекомендуемый режим. Использует системный планировщик задач Linux.
</div> </div>
</template>
<div class="tw:mt-2"> <template #expandable>
<p> <p>
<strong>Системный CRON (рекомендуется):</strong> Стабильное выполнение задач по расписанию, независимо от <strong>Системный CRON (рекомендуется):</strong> Задачи выполняются через команду PHP в оболочке сервера (CLI), без HTTP и без ограничений по времени запроса. Не зависит от посещаемости сайта и подходит для любых объёмов данных, в том числе для тяжёлых задач и больших каталогов. Требует доступа к серверу (SSH или панель с CRON). Добавьте команду ниже в планировщик (обычно <code class="tw:px-1 tw:py-0.5 tw:bg-gray-100 tw:dark:bg-gray-800 tw:rounded">crontab -e</code>) для запуска каждые 5 минут.
посещаемости сайта. Добавьте команду в CRON для автоматического выполнения каждые 5 минут. </p>
<p>
<strong>cron-job.org:</strong> Внешний сервис по расписанию вызывает URL вашего сайта по HTTP. Не требует доступа к серверу удобно для shared-хостинга без CRON. Ограничения: выполнение идёт через веб-запрос, поэтому есть лимиты по времени (timeout у хостинга и у cron-job.org). <strong>Не подходит для тяжёлых сайтов</strong> (много товаров, большие каталоги, тяжёлые задачи): запрос может обрываться по таймауту, задачи не успеют завершиться. Выбирайте этот способ только если нет доступа к системному CRON и нагрузка на планировщик небольшая.
</p> </p>
<p> <p>
<strong>Выключено:</strong> Все фоновые задачи отключены. Планировщик не будет выполнять никаких задач. <strong>Выключено:</strong> Все фоновые задачи отключены. Планировщик не будет выполнять никаких задач.
</p> </p>
</div>
</template> </template>
</SettingsItem> </SettingsItem>
<div class="tw:relative tw:mt-4">
<div
:class="[
'tw:transition-all tw:duration-200',
settings.items.cron.mode === 'disabled'
? 'tw:blur-[2px] tw:pointer-events-none tw:select-none'
: '',
]"
>
<SettingsItem label="Последний запуск CRON"> <SettingsItem label="Последний запуск CRON">
<template #default> <template #default>
<div v-if="lastRunDate" class="tw:text-green-600 tw:font-bold tw:py-2"> <div v-if="lastRunDate" class="tw:text-green-600 tw:font-bold tw:py-2">
@@ -58,22 +71,48 @@
5 минут. 5 минут.
</template> </template>
</SettingsItem> </SettingsItem>
<CronJobOrgUrlField v-if="settings.items.cron.mode === 'cron_job_org'"/>
<SettingsItem label="Задачи планировщика">
<template #default>
<ScheduledJobsList />
</template>
<template #help>
Включение и отключение задач планировщика. Дата последнего успешного запуска; при ошибке отображается иконка с подсказкой.
</template>
</SettingsItem>
</div>
<div
v-if="settings.items.cron.mode === 'disabled'"
class="tw:absolute tw:inset-0 tw:flex tw:items-center tw:justify-center tw:rounded-lg tw:bg-white/80 tw:dark:bg-gray-900/80 tw:z-10"
aria-hidden="true"
>
<span class="tw:text-lg tw:font-semibold tw:text-gray-600 tw:dark:text-gray-400">
Планировщик выключен
</span>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import {useSettingsStore} from "@/stores/settings.js"; import { useSettingsStore } from '@/stores/settings.js';
import SettingsItem from "@/components/SettingsItem.vue"; import SettingsItem from '@/components/SettingsItem.vue';
import SelectButton from "primevue/selectbutton"; import ScheduledJobsList from '@/components/ScheduledJobsList.vue';
import InputText from "primevue/inputtext"; import CronJobOrgUrlField from '@/components/CronJobOrgUrlField.vue';
import Button from "primevue/button"; import SelectButton from 'primevue/selectbutton';
import InputText from 'primevue/inputtext';
import Button from 'primevue/button';
import InputGroup from 'primevue/inputgroup'; import InputGroup from 'primevue/inputgroup';
import {toastBus} from "@/utils/toastHelper.js"; import { toastBus } from '@/utils/toastHelper.js';
const settings = useSettingsStore(); const settings = useSettingsStore();
const cronModes = [ const cronModes = [
{value: 'system', label: 'Системный CRON (Linux)'}, {value: 'system', label: 'Системный CRON (Linux)'},
{value: 'cron_job_org', label: 'cron-job.org'},
{value: 'disabled', label: 'Выключено'}, {value: 'disabled', label: 'Выключено'},
]; ];

View File

@@ -9,7 +9,7 @@
<body> <body>
<div id="app"></div> <div id="app"></div>
<div id="app-error"></div> <div id="app-error"></div>
<script src="https://telegram.org/js/telegram-web-app.js?61"></script> <script src="https://telegram.org/js/telegram-web-app.js?59"></script>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@@ -28,19 +28,18 @@
"vue-router": "^4.6.3" "vue-router": "^4.6.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/vue": "^8.1.0", "@testing-library/vue": "^8.1.0",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"@vitest/ui": "^4.1.9", "@vitest/ui": "^4.0.8",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"daisyui": "^5.3.10", "daisyui": "^5.3.10",
"jsdom": "^27.1.0", "jsdom": "^27.1.0",
"postcss": "^8.5.15", "postcss": "^8.5.6",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.1.16",
"terser": "^5.44.0", "terser": "^5.44.0",
"vite": "^7.3.3", "vite": "^7.1.12",
"vitest": "^4.1.9" "vitest": "^4.0.8"
} }
} }

View File

@@ -2,16 +2,16 @@
<a <a
href="#" href="#"
:key="category.id" :key="category.id"
class="flex items-center gap-3" class="flex items-center"
@click.prevent="$emit('onSelect', category)" @click.prevent="$emit('onSelect', category)"
> >
<div class="avatar shrink-0"> <div class="avatar">
<div class="w-10 h-10 rounded"> <div class="w-8 h-8 rounded">
<img :src="category.image" :alt="category.name" loading="lazy" width="40" height="40" class="bg-base-400"/> <img :src="category.image" :alt="category.name" loading="lazy" width="30" height="30" class="bg-base-400"/>
</div> </div>
</div> </div>
<h3 class="text-base font-medium leading-snug line-clamp-2">{{ category.name }}</h3> <h3 class="ml-4 text-lg line-clamp-2">{{ category.name }}</h3>
</a> </a>
</template> </template>

View File

@@ -1,13 +1,13 @@
<template> <template>
<section class="px-4"> <section class="px-4">
<header class="flex justify-between items-end mb-4 gap-2"> <header class="flex justify-between items-end mb-4">
<div class="min-w-0"> <div>
<div v-if="title" class="font-bold uppercase">{{ title }}</div> <div v-if="title" class="font-bold uppercase">{{ title }}</div>
<div v-if="description" class="text-sm text-base-content/50">{{ description }}</div> <div v-if="description" class="text-sm text-base-content/50">{{ description }}</div>
</div> </div>
<div v-if="moreLink" class="shrink-0"> <div v-if="moreLink">
<RouterLink :to="moreLink" class="btn btn-soft btn-xs whitespace-nowrap" @click="haptic.selectionChanged"> <RouterLink :to="moreLink" class="btn btn-soft btn-xs" @click="haptic.selectionChanged">
{{ moreText || 'Смотреть всё' }} {{ moreText || 'Смотреть всё' }}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />

View File

@@ -2,23 +2,10 @@
<BaseBlock <BaseBlock
:title="block.title" :title="block.title"
:description="block.description" :description="block.description"
:moreLink="hasCategory ? {name: 'product.categories.show', params: { category_id: block.data.category_id }} : null" :moreLink="{name: 'product.categories.show', params: { category_id: block.data.category_id }}"
:moreText="block.data.all_text" :moreText="block.data.all_text"
> >
<div v-if="!hasCategory" role="alert" class="alert alert-warning alert-soft">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Для блока не выбрана категория.</span>
</div>
<div v-else-if="!hasProducts" role="alert" class="alert alert-soft">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>В этой категории пока нет товаров.</span>
</div>
<Swiper <Swiper
v-else
class="select-none block-products-carousel" class="select-none block-products-carousel"
:slides-per-view="block.data?.carousel?.slides_per_view || 2.5" :slides-per-view="block.data?.carousel?.slides_per_view || 2.5"
:space-between="block.data?.carousel?.space_between || 20" :space-between="block.data?.carousel?.space_between || 20"
@@ -49,7 +36,6 @@
</template> </template>
<script setup> <script setup>
import {computed} from "vue";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js"; import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {Swiper, SwiperSlide} from "swiper/vue"; import {Swiper, SwiperSlide} from "swiper/vue";
import {useHapticScroll} from "@/composables/useHapticScroll.js"; import {useHapticScroll} from "@/composables/useHapticScroll.js";
@@ -66,9 +52,6 @@ const props = defineProps({
} }
}); });
const hasCategory = computed(() => Boolean(props.block.data?.category_id));
const hasProducts = computed(() => (props.block.data?.products?.data?.length ?? 0) > 0);
const freeModeSettings = { const freeModeSettings = {
enabled: props.block.data?.carousel?.freemode?.enabled || false, enabled: props.block.data?.carousel?.freemode?.enabled || false,
}; };

View File

@@ -2,19 +2,10 @@
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
@plugin "@tailwindcss/typography";
@plugin "daisyui" { @plugin "daisyui" {
themes: all; themes: all;
} }
.prose :where(table):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
display: block;
width: 100%;
max-width: 100%;
overflow-x: auto;
}
/** /**
--color-base-100 - DaisyUI background --color-base-100 - DaisyUI background
*/ */

View File

@@ -9,22 +9,31 @@
</div> </div>
<template v-else> <template v-else>
<button v-if="parentId && parentCategory" class="py-1 px-4 flex items-center mb-3 cursor-pointer" @click="goBack">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 min-w-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
<span class="ml-2 line-clamp-2">Назад к "{{ parentCategory.name }}"</span>
</button>
<button <button
v-if="parentCategory && parentCategory.children?.length && settings.show_category_products_button" v-if="parentCategory && parentCategory.children?.length && settings.show_category_products_button"
class="btn btn-soft btn-block btn-sm mb-3 justify-start" class="py-2 px-4 flex items-center mb-3 cursor-pointer border-b w-full pb-2 border-base-200"
@click.prevent="showProductsInParentCategory" @click.prevent="showProductsInParentCategory"
> >
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5 shrink-0"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
</svg> </svg>
<span>Показать все товары категории</span>
<span class="ml-2">Показать товары из "{{ parentCategory.name }}"</span>
</button> </button>
<TransitionGroup <TransitionGroup
name="stagger" name="stagger"
tag="ul" tag="ul"
appear appear
class="space-y-3" class="space-y-4"
> >
<li <li
v-for="(category, i) in categories" v-for="(category, i) in categories"
@@ -90,6 +99,10 @@ function onSelect(category) {
} }
} }
function goBack() {
router.back();
}
function showProductsInParentCategory() { function showProductsInParentCategory() {
if (parentId.value != null) { if (parentId.value != null) {
router.push({name: "product.categories.show", params: {category_id: parentId.value}}); router.push({name: "product.categories.show", params: {category_id: parentId.value}});

View File

@@ -87,11 +87,7 @@
</svg> </svg>
Описание Описание
</h3> </h3>
<div <div class="prose prose-sm max-w-none text-base-content/80" v-html="product.description"></div>
class="prose prose-sm max-w-none text-base-content/80"
v-html="product.description"
@click="handleDescriptionClick"
></div>
</div> </div>
<!-- Attributes --> <!-- Attributes -->
@@ -310,29 +306,6 @@ async function onCartBtnClick() {
} }
} }
function handleDescriptionClick(event) {
const link = event.target.closest('a');
if (!link) return;
const href = link.getAttribute('href');
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
event.preventDefault();
const telegramWebApp = window.Telegram?.WebApp;
if (!telegramWebApp) {
window.open(href, '_blank', 'noopener,noreferrer');
return;
}
if (/^https?:\/\/(t|telegram)\.me\//i.test(href) || href.startsWith('tg://')) {
telegramWebApp.openTelegramLink(href);
return;
}
telegramWebApp.openLink(href, {try_instant_view: false});
}
function openProductInMarketplace() { function openProductInMarketplace() {
if (!product.value.share) { if (!product.value.share) {
return; return;

View File

@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<modification>
<name>TeleCart</name>
<code>telecart</code>
<version>1.0</version>
<author>TeleCart</author>
<link>https://docs.telecart.pro/</link>
<file path="admin/controller/common/column_left.php">
<operation>
<search><![CDATA[// System]]></search>
<add position="before"><![CDATA[ // BEGIN TeleCart modification
$telecart = array();
$telecart[] = array(
'name' => 'Настройки',
'href' => $this->url->link('extension/module/tgshop', 'user_token=' . $this->session->data['user_token'], true),
'children' => array()
);
$data['menus'][] = array(
'id' => 'menu-telecart',
'icon' => 'fa-telegram',
'name' => 'TeleCart',
'href' => '',
'children' => $telecart
);
// END TeleCart modification
]]></add>
</operation>
</file>
</modification>

View File

@@ -216,6 +216,7 @@ class ControllerExtensionModuleTgshop extends Controller
'app' => [ 'app' => [
'shop_base_url' => HTTPS_CATALOG, // for catalog: HTTPS_SERVER, for admin: HTTPS_CATALOG 'shop_base_url' => HTTPS_CATALOG, // for catalog: HTTPS_SERVER, for admin: HTTPS_CATALOG
'language_id' => (int) $this->config->get('config_language_id'), 'language_id' => (int) $this->config->get('config_language_id'),
'oc_timezone' => $this->config->get('config_timezone'),
], ],
'paths' => [ 'paths' => [
'images' => DIR_IMAGE, 'images' => DIR_IMAGE,

View File

@@ -5,7 +5,6 @@ use Console\ApplicationFactory;
use Console\Commands\CacheClearCommand; use Console\Commands\CacheClearCommand;
use Console\Commands\CustomerCountsCommand; use Console\Commands\CustomerCountsCommand;
use Console\Commands\PulseSendEventsCommand; use Console\Commands\PulseSendEventsCommand;
use Console\Commands\ScheduleListCommand;
use Console\Commands\ScheduleRunCommand; use Console\Commands\ScheduleRunCommand;
use Console\Commands\VersionCommand; use Console\Commands\VersionCommand;
use Console\Commands\ImagesWarmupCacheCommand; use Console\Commands\ImagesWarmupCacheCommand;
@@ -21,7 +20,7 @@ if (PHP_SAPI !== 'cli') {
} }
$baseDir = __DIR__; $baseDir = __DIR__;
$debug = true; $debug = false;
if (is_readable($baseDir . '/oc_telegram_shop.phar')) { if (is_readable($baseDir . '/oc_telegram_shop.phar')) {
require_once "phar://{$baseDir}/oc_telegram_shop.phar/vendor/autoload.php"; require_once "phar://{$baseDir}/oc_telegram_shop.phar/vendor/autoload.php";
@@ -33,8 +32,6 @@ if (is_readable($baseDir . '/oc_telegram_shop.phar')) {
throw new RuntimeException('Unable to locate application directory.'); throw new RuntimeException('Unable to locate application directory.');
} }
date_default_timezone_set('UTC');
// Get Settings from Database // Get Settings from Database
$host = DB_HOSTNAME; $host = DB_HOSTNAME;
$username = DB_USERNAME; $username = DB_USERNAME;
@@ -46,11 +43,14 @@ $dsn = "mysql:host=$host;port=$port;dbname=$dbName";
$pdo = new PDO($dsn, $username, $password); $pdo = new PDO($dsn, $username, $password);
$connection = new MySqlConnection($pdo); $connection = new MySqlConnection($pdo);
$raw = $connection->select("SELECT value FROM `{$prefix}setting` WHERE `key` = 'module_telecart_settings'"); $raw = $connection->select("SELECT value FROM `{$prefix}setting` WHERE `key` = 'module_telecart_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); $json = json_decode($raw[0]['value'], true, 512, JSON_THROW_ON_ERROR);
$items = Arr::mergeArraysRecursively($json, [ $items = Arr::mergeArraysRecursively($json, [
'app' => [ 'app' => [
'shop_base_url' => HTTPS_CATALOG, // for catalog: HTTPS_SERVER, for admin: HTTPS_CATALOG 'shop_base_url' => HTTPS_CATALOG, // for catalog: HTTPS_SERVER, for admin: HTTPS_CATALOG
'language_id' => 1, 'language_id' => 1,
'oc_timezone' => $timezone,
], ],
'paths' => [ 'paths' => [
'images' => DIR_IMAGE, 'images' => DIR_IMAGE,
@@ -96,7 +96,6 @@ $app->boot();
$console = new Application('TeleCart', module_version()); $console = new Application('TeleCart', module_version());
$console->add($app->get(VersionCommand::class)); $console->add($app->get(VersionCommand::class));
$console->add($app->get(ScheduleRunCommand::class)); $console->add($app->get(ScheduleRunCommand::class));
$console->add($app->get(ScheduleListCommand::class));
$console->add($app->get(PulseSendEventsCommand::class)); $console->add($app->get(PulseSendEventsCommand::class));
$console->add($app->get(ImagesWarmupCacheCommand::class)); $console->add($app->get(ImagesWarmupCacheCommand::class));
$console->add($app->get(ImagesCacheClearCommand::class)); $console->add($app->get(ImagesCacheClearCommand::class));

View File

@@ -6,3 +6,7 @@ TELECART_CACHE_DRIVER=redis
#TELECART_REDIS_HOST=redis #TELECART_REDIS_HOST=redis
#TELECART_REDIS_PORT=6379 #TELECART_REDIS_PORT=6379
#TELECART_REDIS_DATABASE=0 #TELECART_REDIS_DATABASE=0
SENTRY_ENABLED=false
SENTRY_DSN=
SENTRY_ENABLE_LOGS=false

View File

@@ -5,3 +5,7 @@ TELECART_CACHE_DRIVER=mysql
TELECART_REDIS_HOST=redis TELECART_REDIS_HOST=redis
TELECART_REDIS_PORT=6379 TELECART_REDIS_PORT=6379
TELECART_REDIS_DATABASE=0 TELECART_REDIS_DATABASE=0
SENTRY_ENABLED=false
SENTRY_DSN=
SENTRY_ENABLE_LOGS=false

View File

@@ -4,7 +4,9 @@ namespace Bastion\Handlers;
use Bastion\Exceptions\BotTokenConfiguratorException; use Bastion\Exceptions\BotTokenConfiguratorException;
use Bastion\Services\BotTokenConfigurator; use Bastion\Services\BotTokenConfigurator;
use Bastion\Services\CronApiKeyRegenerator;
use Bastion\Services\SettingsService; use Bastion\Services\SettingsService;
use Carbon\Carbon;
use Exception; use Exception;
use Openguru\OpenCartFramework\Cache\CacheInterface; use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Config\Settings; use Openguru\OpenCartFramework\Config\Settings;
@@ -13,35 +15,56 @@ use Openguru\OpenCartFramework\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Openguru\OpenCartFramework\QueryBuilder\Builder; use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface; use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\Scheduler\Models\ScheduledJob;
use Openguru\OpenCartFramework\Support\Arr; use Openguru\OpenCartFramework\Support\Arr;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
class SettingsHandler class SettingsHandler
{ {
private BotTokenConfigurator $botTokenConfigurator; private BotTokenConfigurator $botTokenConfigurator;
private CronApiKeyRegenerator $cronApiKeyRegenerator;
private Settings $settings; private Settings $settings;
private SettingsService $settingsUpdateService; private SettingsService $settingsUpdateService;
private CacheInterface $cache; private CacheInterface $cache;
private LoggerInterface $logger; private LoggerInterface $logger;
private Builder $builder; private Builder $builder;
private ConnectionInterface $connection; private ConnectionInterface $connection;
private ScheduledJob $scheduledJob;
public function __construct( public function __construct(
BotTokenConfigurator $botTokenConfigurator, BotTokenConfigurator $botTokenConfigurator,
CronApiKeyRegenerator $cronApiKeyRegenerator,
Settings $settings, Settings $settings,
SettingsService $settingsUpdateService, SettingsService $settingsUpdateService,
CacheInterface $cache, CacheInterface $cache,
LoggerInterface $logger, LoggerInterface $logger,
Builder $builder, Builder $builder,
ConnectionInterface $connection ConnectionInterface $connection,
ScheduledJob $scheduledJob
) { ) {
$this->botTokenConfigurator = $botTokenConfigurator; $this->botTokenConfigurator = $botTokenConfigurator;
$this->cronApiKeyRegenerator = $cronApiKeyRegenerator;
$this->settings = $settings; $this->settings = $settings;
$this->settingsUpdateService = $settingsUpdateService; $this->settingsUpdateService = $settingsUpdateService;
$this->cache = $cache; $this->cache = $cache;
$this->logger = $logger; $this->logger = $logger;
$this->builder = $builder; $this->builder = $builder;
$this->connection = $connection; $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 public function configureBotToken(Request $request): JsonResponse
@@ -81,6 +104,12 @@ class SettingsHandler
// Add CRON system details (read-only) // Add CRON system details (read-only)
$data['cron']['cli_path'] = BP_REAL_BASE_PATH . '/cli.php'; $data['cron']['cli_path'] = BP_REAL_BASE_PATH . '/cli.php';
$data['cron']['last_run'] = $this->getLastCronRunDate(); $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() $forms = $this->builder->newQuery()
->from('telecart_forms') ->from('telecart_forms')
@@ -106,6 +135,21 @@ class SettingsHandler
return new JsonResponse(compact('data')); 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 public function saveSettingsForm(Request $request): JsonResponse
{ {
$input = $request->json(); $input = $request->json();
@@ -116,6 +160,7 @@ class SettingsHandler
if (isset($input['cron'])) { if (isset($input['cron'])) {
unset($input['cron']['cli_path']); unset($input['cron']['cli_path']);
unset($input['cron']['last_run']); unset($input['cron']['last_run']);
unset($input['cron']['schedule_url']);
} }
$this->settingsUpdateService->update( $this->settingsUpdateService->update(
@@ -146,6 +191,25 @@ class SettingsHandler
]); ]);
} }
// 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); return new JsonResponse([], Response::HTTP_ACCEPTED);
} }
@@ -174,7 +238,7 @@ class SettingsHandler
$lastRunTimestamp = $this->cache->get("scheduler.global_last_run"); $lastRunTimestamp = $this->cache->get("scheduler.global_last_run");
if ($lastRunTimestamp) { if ($lastRunTimestamp) {
return date('d.m.Y H:i:s', (int)$lastRunTimestamp); return Carbon::createFromTimestamp($lastRunTimestamp)->toDateTimeString();
} }
return null; return null;

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

@@ -27,6 +27,7 @@ return [
'getSettingsForm' => [SettingsHandler::class, 'getSettingsForm'], 'getSettingsForm' => [SettingsHandler::class, 'getSettingsForm'],
'getTelegramCustomers' => [TelegramCustomersHandler::class, 'getCustomers'], 'getTelegramCustomers' => [TelegramCustomersHandler::class, 'getCustomers'],
'resetCache' => [SettingsHandler::class, 'resetCache'], 'resetCache' => [SettingsHandler::class, 'resetCache'],
'regenerateCronScheduleUrl' => [SettingsHandler::class, 'regenerateCronScheduleUrl'],
'saveSettingsForm' => [SettingsHandler::class, 'saveSettingsForm'], 'saveSettingsForm' => [SettingsHandler::class, 'saveSettingsForm'],
'getSystemInfo' => [SettingsHandler::class, 'getSystemInfo'], 'getSystemInfo' => [SettingsHandler::class, 'getSystemInfo'],
'sendMessageToCustomer' => [SendMessageHandler::class, 'sendMessage'], 'sendMessageToCustomer' => [SendMessageHandler::class, 'sendMessage'],

View File

@@ -36,7 +36,8 @@
"ramsey/uuid": "^4.2", "ramsey/uuid": "^4.2",
"symfony/http-foundation": "^5.4", "symfony/http-foundation": "^5.4",
"symfony/console": "^5.4", "symfony/console": "^5.4",
"dragonmantank/cron-expression": "^3.5" "dragonmantank/cron-expression": "^3.5",
"sentry/sentry": "^4.19"
}, },
"require-dev": { "require-dev": {
"doctrine/sql-formatter": "^1.3", "doctrine/sql-formatter": "^1.3",

View File

@@ -137,16 +137,16 @@
}, },
{ {
"name": "doctrine/dbal", "name": "doctrine/dbal",
"version": "3.10.5", "version": "3.10.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/doctrine/dbal.git", "url": "https://github.com/doctrine/dbal.git",
"reference": "95d84866bf3c04b2ddca1df7c049714660959aef" "reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/95d84866bf3c04b2ddca1df7c049714660959aef", "url": "https://api.github.com/repos/doctrine/dbal/zipball/63a46cb5aa6f60991186cc98c1d1b50c09311868",
"reference": "95d84866bf3c04b2ddca1df7c049714660959aef", "reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -167,9 +167,9 @@
"jetbrains/phpstorm-stubs": "2023.1", "jetbrains/phpstorm-stubs": "2023.1",
"phpstan/phpstan": "2.1.30", "phpstan/phpstan": "2.1.30",
"phpstan/phpstan-strict-rules": "^2", "phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "9.6.34", "phpunit/phpunit": "9.6.29",
"slevomat/coding-standard": "8.27.1", "slevomat/coding-standard": "8.24.0",
"squizlabs/php_codesniffer": "4.0.1", "squizlabs/php_codesniffer": "4.0.0",
"symfony/cache": "^5.4|^6.0|^7.0|^8.0", "symfony/cache": "^5.4|^6.0|^7.0|^8.0",
"symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0" "symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0"
}, },
@@ -231,7 +231,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/doctrine/dbal/issues", "issues": "https://github.com/doctrine/dbal/issues",
"source": "https://github.com/doctrine/dbal/tree/3.10.5" "source": "https://github.com/doctrine/dbal/tree/3.10.4"
}, },
"funding": [ "funding": [
{ {
@@ -247,7 +247,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-02-24T08:03:57+00:00" "time": "2025-11-29T10:46:08+00:00"
}, },
{ {
"name": "doctrine/deprecations", "name": "doctrine/deprecations",
@@ -517,26 +517,25 @@
}, },
{ {
"name": "guzzlehttp/guzzle", "name": "guzzlehttp/guzzle",
"version": "7.13.1", "version": "7.10.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/guzzle/guzzle.git", "url": "https://github.com/guzzle/guzzle.git",
"reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d" "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/55901a76dfd2006a0cc012b9e3c5b487f796478d", "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
"reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d", "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-json": "*", "ext-json": "*",
"guzzlehttp/promises": "^2.5", "guzzlehttp/promises": "^2.3",
"guzzlehttp/psr7": "^2.12.3", "guzzlehttp/psr7": "^2.8",
"php": "^7.2.5 || ^8.0", "php": "^7.2.5 || ^8.0",
"psr/http-client": "^1.0", "psr/http-client": "^1.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0", "symfony/deprecation-contracts": "^2.2 || ^3.0"
"symfony/polyfill-php80": "^1.25"
}, },
"provide": { "provide": {
"psr/http-client-implementation": "1.0" "psr/http-client-implementation": "1.0"
@@ -545,9 +544,8 @@
"bamarni/composer-bin-plugin": "^1.8.2", "bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*", "ext-curl": "*",
"guzzle/client-integration-tests": "3.0.2", "guzzle/client-integration-tests": "3.0.2",
"guzzlehttp/test-server": "^0.6",
"php-http/message-factory": "^1.1", "php-http/message-factory": "^1.1",
"phpunit/phpunit": "^8.5.52 || ^9.6.34", "phpunit/phpunit": "^8.5.39 || ^9.6.20",
"psr/log": "^1.1 || ^2.0 || ^3.0" "psr/log": "^1.1 || ^2.0 || ^3.0"
}, },
"suggest": { "suggest": {
@@ -625,7 +623,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/guzzle/issues", "issues": "https://github.com/guzzle/guzzle/issues",
"source": "https://github.com/guzzle/guzzle/tree/7.13.1" "source": "https://github.com/guzzle/guzzle/tree/7.10.0"
}, },
"funding": [ "funding": [
{ {
@@ -641,29 +639,28 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-29T20:14:18+00:00" "time": "2025-08-23T22:36:01+00:00"
}, },
{ {
"name": "guzzlehttp/promises", "name": "guzzlehttp/promises",
"version": "2.5.0", "version": "2.3.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/guzzle/promises.git", "url": "https://github.com/guzzle/promises.git",
"reference": "4360e982f87f5f258bf872d094647791db2f4c8e" "reference": "481557b130ef3790cf82b713667b43030dc9c957"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957",
"reference": "4360e982f87f5f258bf872d094647791db2f4c8e", "reference": "481557b130ef3790cf82b713667b43030dc9c957",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.2.5 || ^8.0", "php": "^7.2.5 || ^8.0"
"symfony/deprecation-contracts": "^2.5 || ^3.0"
}, },
"require-dev": { "require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2", "bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.52 || ^9.6.34" "phpunit/phpunit": "^8.5.44 || ^9.6.25"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@@ -709,7 +706,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/promises/issues", "issues": "https://github.com/guzzle/promises/issues",
"source": "https://github.com/guzzle/promises/tree/2.5.0" "source": "https://github.com/guzzle/promises/tree/2.3.0"
}, },
"funding": [ "funding": [
{ {
@@ -725,29 +722,27 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-02T12:23:43+00:00" "time": "2025-08-22T14:34:08+00:00"
}, },
{ {
"name": "guzzlehttp/psr7", "name": "guzzlehttp/psr7",
"version": "2.12.3", "version": "2.8.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/guzzle/psr7.git", "url": "https://github.com/guzzle/psr7.git",
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d" "reference": "21dc724a0583619cd1652f673303492272778051"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d", "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051",
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d", "reference": "21dc724a0583619cd1652f673303492272778051",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.2.5 || ^8.0", "php": "^7.2.5 || ^8.0",
"psr/http-factory": "^1.0", "psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0", "psr/http-message": "^1.1 || ^2.0",
"ralouphie/getallheaders": "^3.0", "ralouphie/getallheaders": "^3.0"
"symfony/deprecation-contracts": "^2.5 || ^3.0",
"symfony/polyfill-php80": "^1.25"
}, },
"provide": { "provide": {
"psr/http-factory-implementation": "1.0", "psr/http-factory-implementation": "1.0",
@@ -755,9 +750,8 @@
}, },
"require-dev": { "require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2", "bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "1.1.0", "http-interop/http-factory-tests": "0.9.0",
"jshttp/mime-db": "1.54.0.1", "phpunit/phpunit": "^8.5.44 || ^9.6.25"
"phpunit/phpunit": "^8.5.52 || ^9.6.34"
}, },
"suggest": { "suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
@@ -828,7 +822,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/psr7/issues", "issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.12.3" "source": "https://github.com/guzzle/psr7/tree/2.8.0"
}, },
"funding": [ "funding": [
{ {
@@ -844,7 +838,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-23T15:21:08+00:00" "time": "2025-08-23T21:21:41+00:00"
}, },
{ {
"name": "intervention/image", "name": "intervention/image",
@@ -1870,16 +1864,16 @@
}, },
{ {
"name": "symfony/cache", "name": "symfony/cache",
"version": "v5.4.53", "version": "v5.4.46",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/cache.git", "url": "https://github.com/symfony/cache.git",
"reference": "bf581474737420d5c932ae80b868e253f465ee5b" "reference": "0fe08ee32cec2748fbfea10c52d3ee02049e0f6b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/cache/zipball/bf581474737420d5c932ae80b868e253f465ee5b", "url": "https://api.github.com/repos/symfony/cache/zipball/0fe08ee32cec2748fbfea10c52d3ee02049e0f6b",
"reference": "bf581474737420d5c932ae80b868e253f465ee5b", "reference": "0fe08ee32cec2748fbfea10c52d3ee02049e0f6b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1947,7 +1941,7 @@
"psr6" "psr6"
], ],
"support": { "support": {
"source": "https://github.com/symfony/cache/tree/v5.4.53" "source": "https://github.com/symfony/cache/tree/v5.4.46"
}, },
"funding": [ "funding": [
{ {
@@ -1958,16 +1952,12 @@
"url": "https://github.com/fabpot", "url": "https://github.com/fabpot",
"type": "github" "type": "github"
}, },
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{ {
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-24T08:41:21+00:00" "time": "2024-11-04T11:43:55+00:00"
}, },
{ {
"name": "symfony/cache-contracts", "name": "symfony/cache-contracts",
@@ -2631,7 +2621,7 @@
}, },
{ {
"name": "symfony/polyfill-php73", "name": "symfony/polyfill-php73",
"version": "v1.37.0", "version": "v1.33.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-php73.git", "url": "https://github.com/symfony/polyfill-php73.git",
@@ -2687,7 +2677,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-php73/tree/v1.37.0" "source": "https://github.com/symfony/polyfill-php73/tree/v1.33.0"
}, },
"funding": [ "funding": [
{ {
@@ -2711,16 +2701,16 @@
}, },
{ {
"name": "symfony/polyfill-php80", "name": "symfony/polyfill-php80",
"version": "v1.37.0", "version": "v1.33.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-php80.git", "url": "https://github.com/symfony/polyfill-php80.git",
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2771,7 +2761,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0"
}, },
"funding": [ "funding": [
{ {
@@ -2791,7 +2781,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-04-10T16:19:22+00:00" "time": "2025-01-02T08:10:11+00:00"
}, },
{ {
"name": "symfony/polyfill-php81", "name": "symfony/polyfill-php81",
@@ -3929,11 +3919,11 @@
}, },
{ {
"name": "phpstan/phpstan", "name": "phpstan/phpstan",
"version": "2.1.40", "version": "2.1.39",
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224",
"reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", "reference": "c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3978,7 +3968,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-02-23T15:04:35+00:00" "time": "2026-02-11T14:48:56+00:00"
}, },
{ {
"name": "phpunit/php-code-coverage", "name": "phpunit/php-code-coverage",
@@ -4416,18 +4406,18 @@
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git", "url": "https://github.com/Roave/SecurityAdvisories.git",
"reference": "0d2dce39eb2bce812e8c71a73be98b0f5d78f489" "reference": "92c5ec5685cfbcd7ef721a502e6622516728011c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/0d2dce39eb2bce812e8c71a73be98b0f5d78f489", "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/92c5ec5685cfbcd7ef721a502e6622516728011c",
"reference": "0d2dce39eb2bce812e8c71a73be98b0f5d78f489", "reference": "92c5ec5685cfbcd7ef721a502e6622516728011c",
"shasum": "" "shasum": ""
}, },
"conflict": { "conflict": {
"3f/pygmentize": "<1.2", "3f/pygmentize": "<1.2",
"adaptcms/adaptcms": "<=1.3", "adaptcms/adaptcms": "<=1.3",
"admidio/admidio": "<5.0.6", "admidio/admidio": "<=4.3.16",
"adodb/adodb-php": "<=5.22.9", "adodb/adodb-php": "<=5.22.9",
"aheinze/cockpit": "<2.2", "aheinze/cockpit": "<2.2",
"aimeos/ai-admin-graphql": ">=2022.04.1,<2022.10.10|>=2023.04.1,<2023.10.6|>=2024.04.1,<2024.07.2", "aimeos/ai-admin-graphql": ">=2022.04.1,<2022.10.10|>=2023.04.1,<2023.10.6|>=2024.04.1,<2024.07.2",
@@ -4478,7 +4468,7 @@
"automattic/jetpack": "<9.8", "automattic/jetpack": "<9.8",
"awesome-support/awesome-support": "<=6.0.7", "awesome-support/awesome-support": "<=6.0.7",
"aws/aws-sdk-php": "<3.368", "aws/aws-sdk-php": "<3.368",
"azuracast/azuracast": "<=0.23.3", "azuracast/azuracast": "<=0.23.1",
"b13/seo_basics": "<0.8.2", "b13/seo_basics": "<0.8.2",
"backdrop/backdrop": "<=1.32", "backdrop/backdrop": "<=1.32",
"backpack/crud": "<3.4.9", "backpack/crud": "<3.4.9",
@@ -4550,7 +4540,7 @@
"commerceteam/commerce": ">=0.9.6,<0.9.9", "commerceteam/commerce": ">=0.9.6,<0.9.9",
"components/jquery": ">=1.0.3,<3.5", "components/jquery": ">=1.0.3,<3.5",
"composer/composer": "<1.10.27|>=2,<2.2.26|>=2.3,<2.9.3", "composer/composer": "<1.10.27|>=2,<2.2.26|>=2.3,<2.9.3",
"concrete5/concrete5": "<9.4.8", "concrete5/concrete5": "<9.4.3",
"concrete5/core": "<8.5.8|>=9,<9.1", "concrete5/core": "<8.5.8|>=9,<9.1",
"contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao-components/mediaelement": ">=2.14.2,<2.21.1",
"contao/comments-bundle": ">=2,<4.13.40|>=5.0.0.0-RC1-dev,<5.3.4", "contao/comments-bundle": ">=2,<4.13.40|>=5.0.0.0-RC1-dev,<5.3.4",
@@ -4564,8 +4554,8 @@
"cosenary/instagram": "<=2.3", "cosenary/instagram": "<=2.3",
"couleurcitron/tarteaucitron-wp": "<0.3", "couleurcitron/tarteaucitron-wp": "<0.3",
"cpsit/typo3-mailqueue": "<0.4.3|>=0.5,<0.5.1", "cpsit/typo3-mailqueue": "<0.4.3|>=0.5,<0.5.1",
"craftcms/cms": "<=4.17.3|>=5,<=5.9.8", "craftcms/cms": "<4.17.0.0-beta1|>=5,<5.9.0.0-beta1",
"craftcms/commerce": ">=4,<4.11|>=5,<5.6", "craftcms/commerce": ">=4.0.0.0-RC1-dev,<=4.10|>=5,<=5.5.1",
"craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1", "craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1",
"craftcms/craft": ">=3.5,<=4.16.17|>=5.0.0.0-RC1-dev,<=5.8.21", "craftcms/craft": ">=3.5,<=4.16.17|>=5.0.0.0-RC1-dev,<=5.8.21",
"croogo/croogo": "<=4.0.7", "croogo/croogo": "<=4.0.7",
@@ -4643,7 +4633,7 @@
"drupal/umami_analytics": "<1.0.1", "drupal/umami_analytics": "<1.0.1",
"duncanmcclean/guest-entries": "<3.1.2", "duncanmcclean/guest-entries": "<3.1.2",
"dweeves/magmi": "<=0.7.24", "dweeves/magmi": "<=0.7.24",
"ec-cube/ec-cube": "<2.4.4|>=2.11,<=2.17.1|>=3,<=3.0.18.0-patch4|>=4,<=4.3.1", "ec-cube/ec-cube": "<2.4.4|>=2.11,<=2.17.1|>=3,<=3.0.18.0-patch4|>=4,<=4.1.2",
"ecodev/newsletter": "<=4", "ecodev/newsletter": "<=4",
"ectouch/ectouch": "<=2.7.2", "ectouch/ectouch": "<=2.7.2",
"egroupware/egroupware": "<23.1.20260113|>=26.0.20251208,<26.0.20260113", "egroupware/egroupware": "<23.1.20260113|>=26.0.20251208,<26.0.20260113",
@@ -4700,7 +4690,6 @@
"flarum/flarum": "<0.1.0.0-beta8", "flarum/flarum": "<0.1.0.0-beta8",
"flarum/framework": "<1.8.10", "flarum/framework": "<1.8.10",
"flarum/mentions": "<1.6.3", "flarum/mentions": "<1.6.3",
"flarum/nicknames": "<1.8.3",
"flarum/sticky": ">=0.1.0.0-beta14,<=0.1.0.0-beta15", "flarum/sticky": ">=0.1.0.0-beta14,<=0.1.0.0-beta15",
"flarum/tags": "<=0.1.0.0-beta13", "flarum/tags": "<=0.1.0.0-beta13",
"floriangaerber/magnesium": "<0.3.1", "floriangaerber/magnesium": "<0.3.1",
@@ -4723,10 +4712,10 @@
"friendsoftypo3/openid": ">=4.5,<4.5.31|>=4.7,<4.7.16|>=6,<6.0.11|>=6.1,<6.1.6", "friendsoftypo3/openid": ">=4.5,<4.5.31|>=4.7,<4.7.16|>=6,<6.0.11|>=6.1,<6.1.6",
"froala/wysiwyg-editor": "<=4.3", "froala/wysiwyg-editor": "<=4.3",
"frosh/adminer-platform": "<2.2.1", "frosh/adminer-platform": "<2.2.1",
"froxlor/froxlor": "<=2.3.3", "froxlor/froxlor": "<=2.2.5",
"frozennode/administrator": "<=5.0.12", "frozennode/administrator": "<=5.0.12",
"fuel/core": "<1.8.1", "fuel/core": "<1.8.1",
"funadmin/funadmin": "<=7.1.0.0-RC4", "funadmin/funadmin": "<=5.0.2",
"gaoming13/wechat-php-sdk": "<=1.10.2", "gaoming13/wechat-php-sdk": "<=1.10.2",
"genix/cms": "<=1.1.11", "genix/cms": "<=1.1.11",
"georgringer/news": "<1.3.3", "georgringer/news": "<1.3.3",
@@ -4747,7 +4736,7 @@
"gp247/core": "<1.1.24", "gp247/core": "<1.1.24",
"gree/jose": "<2.2.1", "gree/jose": "<2.2.1",
"gregwar/rst": "<1.0.3", "gregwar/rst": "<1.0.3",
"grumpydictator/firefly-iii": "<6.1.17|>=6.4.23,<=6.5", "grumpydictator/firefly-iii": "<6.1.17",
"gugoan/economizzer": "<=0.9.0.0-beta1", "gugoan/economizzer": "<=0.9.0.0-beta1",
"guzzlehttp/guzzle": "<6.5.8|>=7,<7.4.5", "guzzlehttp/guzzle": "<6.5.8|>=7,<7.4.5",
"guzzlehttp/oauth-subscriber": "<0.8.1", "guzzlehttp/oauth-subscriber": "<0.8.1",
@@ -4773,7 +4762,7 @@
"ibexa/solr": ">=4.5,<4.5.4", "ibexa/solr": ">=4.5,<4.5.4",
"ibexa/user": ">=4,<4.4.3|>=5,<5.0.4", "ibexa/user": ">=4,<4.4.3|>=5,<5.0.4",
"icecoder/icecoder": "<=8.1", "icecoder/icecoder": "<=8.1",
"idno/known": "<1.6.4", "idno/known": "<=1.6.2",
"ilicmiljan/secure-props": ">=1.2,<1.2.2", "ilicmiljan/secure-props": ">=1.2,<1.2.2",
"illuminate/auth": "<5.5.10", "illuminate/auth": "<5.5.10",
"illuminate/cookie": ">=4,<=4.0.11|>=4.1,<6.18.31|>=7,<7.22.4", "illuminate/cookie": ">=4,<=4.0.11|>=4.1,<6.18.31|>=7,<7.22.4",
@@ -4823,7 +4812,7 @@
"kelvinmo/simplexrd": "<3.1.1", "kelvinmo/simplexrd": "<3.1.1",
"kevinpapst/kimai2": "<1.16.7", "kevinpapst/kimai2": "<1.16.7",
"khodakhah/nodcms": "<=3", "khodakhah/nodcms": "<=3",
"kimai/kimai": "<=2.50", "kimai/kimai": "<2.46",
"kitodo/presentation": "<3.2.3|>=3.3,<3.3.4", "kitodo/presentation": "<3.2.3|>=3.3,<3.3.4",
"klaviyo/magento2-extension": ">=1,<3", "klaviyo/magento2-extension": ">=1,<3",
"knplabs/knp-snappy": "<=1.4.2", "knplabs/knp-snappy": "<=1.4.2",
@@ -4848,7 +4837,7 @@
"lavalite/cms": "<=10.1", "lavalite/cms": "<=10.1",
"lavitto/typo3-form-to-database": "<2.2.5|>=3,<3.2.2|>=4,<4.2.3|>=5,<5.0.2", "lavitto/typo3-form-to-database": "<2.2.5|>=3,<3.2.2|>=4,<4.2.3|>=5,<5.0.2",
"lcobucci/jwt": ">=3.4,<3.4.6|>=4,<4.0.4|>=4.1,<4.1.5", "lcobucci/jwt": ">=3.4,<3.4.6|>=4,<4.0.4|>=4.1,<4.1.5",
"league/commonmark": "<=2.8", "league/commonmark": "<2.7",
"league/flysystem": "<1.1.4|>=2,<2.1.1", "league/flysystem": "<1.1.4|>=2,<2.1.1",
"league/oauth2-server": ">=8.3.2,<8.4.2|>=8.5,<8.5.3", "league/oauth2-server": ">=8.3.2,<8.4.2|>=8.5,<8.5.3",
"leantime/leantime": "<3.3", "leantime/leantime": "<3.3",
@@ -4857,7 +4846,7 @@
"librenms/librenms": "<26.2", "librenms/librenms": "<26.2",
"liftkit/database": "<2.13.2", "liftkit/database": "<2.13.2",
"lightsaml/lightsaml": "<1.3.5", "lightsaml/lightsaml": "<1.3.5",
"limesurvey/limesurvey": "<6.15.4", "limesurvey/limesurvey": "<6.5.12",
"livehelperchat/livehelperchat": "<=3.91", "livehelperchat/livehelperchat": "<=3.91",
"livewire-filemanager/filemanager": "<=1.0.4", "livewire-filemanager/filemanager": "<=1.0.4",
"livewire/livewire": "<2.12.7|>=3.0.0.0-beta1,<3.6.4", "livewire/livewire": "<2.12.7|>=3.0.0.0-beta1,<3.6.4",
@@ -4885,7 +4874,7 @@
"marshmallow/nova-tiptap": "<5.7", "marshmallow/nova-tiptap": "<5.7",
"matomo/matomo": "<1.11", "matomo/matomo": "<1.11",
"matyhtf/framework": "<3.0.6", "matyhtf/framework": "<3.0.6",
"mautic/core": "<5.2.10|>=6,<6.0.8|>=7.0.0.0-alpha,<7.0.1", "mautic/core": "<5.2.9|>=6,<6.0.7",
"mautic/core-lib": ">=1.0.0.0-beta,<4.4.13|>=5.0.0.0-alpha,<5.1.1", "mautic/core-lib": ">=1.0.0.0-beta,<4.4.13|>=5.0.0.0-alpha,<5.1.1",
"mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7", "mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7",
"maximebf/debugbar": "<1.19", "maximebf/debugbar": "<1.19",
@@ -4918,7 +4907,7 @@
"mongodb/mongodb": ">=1,<1.9.2", "mongodb/mongodb": ">=1,<1.9.2",
"mongodb/mongodb-extension": "<1.21.2", "mongodb/mongodb-extension": "<1.21.2",
"monolog/monolog": ">=1.8,<1.12", "monolog/monolog": ">=1.8,<1.12",
"moodle/moodle": "<4.5.9|>=5.0.0.0-beta,<5.0.5|>=5.1.0.0-beta,<5.1.2", "moodle/moodle": "<4.4.12|>=4.5.0.0-beta,<4.5.8|>=5.0.0.0-beta,<5.0.4|>=5.1.0.0-beta,<5.1.1",
"moonshine/moonshine": "<=3.12.5", "moonshine/moonshine": "<=3.12.5",
"mos/cimage": "<0.7.19", "mos/cimage": "<0.7.19",
"movim/moxl": ">=0.8,<=0.10", "movim/moxl": ">=0.8,<=0.10",
@@ -5036,7 +5025,7 @@
"pimcore/demo": "<10.3", "pimcore/demo": "<10.3",
"pimcore/ecommerce-framework-bundle": "<1.0.10", "pimcore/ecommerce-framework-bundle": "<1.0.10",
"pimcore/perspective-editor": "<1.5.1", "pimcore/perspective-editor": "<1.5.1",
"pimcore/pimcore": "<=11.5.14.1|>=12,<12.3.3", "pimcore/pimcore": "<=11.5.13|>=12.0.0.0-RC1-dev,<12.3.1",
"pimcore/web2print-tools-bundle": "<=5.2.1|>=6.0.0.0-RC1-dev,<=6.1", "pimcore/web2print-tools-bundle": "<=5.2.1|>=6.0.0.0-RC1-dev,<=6.1",
"piwik/piwik": "<1.11", "piwik/piwik": "<1.11",
"pixelfed/pixelfed": "<0.12.5", "pixelfed/pixelfed": "<0.12.5",
@@ -5086,7 +5075,7 @@
"reportico-web/reportico": "<=8.1", "reportico-web/reportico": "<=8.1",
"rhukster/dom-sanitizer": "<1.0.7", "rhukster/dom-sanitizer": "<1.0.7",
"rmccue/requests": ">=1.6,<1.8", "rmccue/requests": ">=1.6,<1.8",
"robrichards/xmlseclibs": "<3.1.5", "robrichards/xmlseclibs": "<=3.1.3",
"roots/soil": "<4.1", "roots/soil": "<4.1",
"roundcube/roundcubemail": "<1.5.10|>=1.6,<1.6.11", "roundcube/roundcubemail": "<1.5.10|>=1.6,<1.6.11",
"rudloff/alltube": "<3.0.3", "rudloff/alltube": "<3.0.3",
@@ -5102,8 +5091,8 @@
"setasign/fpdi": "<2.6.4", "setasign/fpdi": "<2.6.4",
"sfroemken/url_redirect": "<=1.2.1", "sfroemken/url_redirect": "<=1.2.1",
"sheng/yiicms": "<1.2.1", "sheng/yiicms": "<1.2.1",
"shopware/core": "<6.6.10.15-dev|>=6.7,<6.7.8.1-dev", "shopware/core": "<6.6.10.9-dev|>=6.7,<6.7.6.1-dev",
"shopware/platform": "<6.6.10.15-dev|>=6.7,<6.7.8.1-dev", "shopware/platform": "<6.6.10.7-dev|>=6.7,<6.7.3.1-dev",
"shopware/production": "<=6.3.5.2", "shopware/production": "<=6.3.5.2",
"shopware/shopware": "<=5.7.17|>=6.4.6,<6.6.10.10-dev|>=6.7,<6.7.6.1-dev", "shopware/shopware": "<=5.7.17|>=6.4.6,<6.6.10.10-dev|>=6.7,<6.7.6.1-dev",
"shopware/storefront": "<6.6.10.10-dev|>=6.7,<6.7.5.1-dev", "shopware/storefront": "<6.6.10.10-dev|>=6.7,<6.7.5.1-dev",
@@ -5137,7 +5126,7 @@
"simplesamlphp/simplesamlphp-module-openid": "<1", "simplesamlphp/simplesamlphp-module-openid": "<1",
"simplesamlphp/simplesamlphp-module-openidprovider": "<0.9", "simplesamlphp/simplesamlphp-module-openidprovider": "<0.9",
"simplesamlphp/xml-common": "<1.20", "simplesamlphp/xml-common": "<1.20",
"simplesamlphp/xml-security": "<2.3.1", "simplesamlphp/xml-security": "==1.6.11",
"simplito/elliptic-php": "<1.0.6", "simplito/elliptic-php": "<1.0.6",
"sitegeist/fluid-components": "<3.5", "sitegeist/fluid-components": "<3.5",
"sjbr/sr-feuser-register": "<2.6.2|>=5.1,<12.5", "sjbr/sr-feuser-register": "<2.6.2|>=5.1,<12.5",
@@ -5147,7 +5136,7 @@
"slim/slim": "<2.6", "slim/slim": "<2.6",
"slub/slub-events": "<3.0.3", "slub/slub-events": "<3.0.3",
"smarty/smarty": "<4.5.3|>=5,<5.1.1", "smarty/smarty": "<4.5.3|>=5,<5.1.1",
"snipe/snipe-it": "<8.3.7", "snipe/snipe-it": "<=8.3.4",
"socalnick/scn-social-auth": "<1.15.2", "socalnick/scn-social-auth": "<1.15.2",
"socialiteproviders/steam": "<1.1", "socialiteproviders/steam": "<1.1",
"solspace/craft-freeform": "<4.1.29|>=5,<=5.14.6", "solspace/craft-freeform": "<4.1.29|>=5,<=5.14.6",
@@ -5165,7 +5154,7 @@
"starcitizentools/short-description": ">=4,<4.0.1", "starcitizentools/short-description": ">=4,<4.0.1",
"starcitizentools/tabber-neue": ">=1.9.1,<2.7.2|>=3,<3.1.1", "starcitizentools/tabber-neue": ">=1.9.1,<2.7.2|>=3,<3.1.1",
"starcitizenwiki/embedvideo": "<=4", "starcitizenwiki/embedvideo": "<=4",
"statamic/cms": "<5.73.11|>=6,<6.6.2", "statamic/cms": "<5.73.9|>=6,<6.3.2",
"stormpath/sdk": "<9.9.99", "stormpath/sdk": "<9.9.99",
"studio-42/elfinder": "<=2.1.64", "studio-42/elfinder": "<=2.1.64",
"studiomitte/friendlycaptcha": "<0.1.4", "studiomitte/friendlycaptcha": "<0.1.4",
@@ -5184,7 +5173,7 @@
"sylius/grid-bundle": "<1.10.1", "sylius/grid-bundle": "<1.10.1",
"sylius/paypal-plugin": "<1.6.2|>=1.7,<1.7.2|>=2,<2.0.2", "sylius/paypal-plugin": "<1.6.2|>=1.7,<1.7.2|>=2,<2.0.2",
"sylius/resource-bundle": ">=1,<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4", "sylius/resource-bundle": ">=1,<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4",
"sylius/sylius": "<1.9.12|>=1.10,<1.10.16|>=1.11,<1.11.17|>=1.12,<=1.12.22|>=1.13,<=1.13.14|>=1.14,<=1.14.17|>=2,<=2.0.15|>=2.1,<=2.1.11|>=2.2,<=2.2.2", "sylius/sylius": "<1.12.19|>=1.13.0.0-alpha1,<1.13.4",
"symbiote/silverstripe-multivaluefield": ">=3,<3.1", "symbiote/silverstripe-multivaluefield": ">=3,<3.1",
"symbiote/silverstripe-queuedjobs": ">=3,<3.0.2|>=3.1,<3.1.4|>=4,<4.0.7|>=4.1,<4.1.2|>=4.2,<4.2.4|>=4.3,<4.3.3|>=4.4,<4.4.3|>=4.5,<4.5.1|>=4.6,<4.6.4", "symbiote/silverstripe-queuedjobs": ">=3,<3.0.2|>=3.1,<3.1.4|>=4,<4.0.7|>=4.1,<4.1.2|>=4.2,<4.2.4|>=4.3,<4.3.3|>=4.4,<4.4.3|>=4.5,<4.5.1|>=4.6,<4.6.4",
"symbiote/silverstripe-seed": "<6.0.3", "symbiote/silverstripe-seed": "<6.0.3",
@@ -5239,7 +5228,7 @@
"thelia/thelia": ">=2.1,<2.1.3", "thelia/thelia": ">=2.1,<2.1.3",
"theonedemon/phpwhois": "<=4.2.5", "theonedemon/phpwhois": "<=4.2.5",
"thinkcmf/thinkcmf": "<6.0.8", "thinkcmf/thinkcmf": "<6.0.8",
"thorsten/phpmyfaq": "<4.0.18|>=4.1.0.0-alpha,<=4.1.0.0-beta2", "thorsten/phpmyfaq": "<=4.0.16|>=4.1.0.0-alpha,<=4.1.0.0-beta2",
"tikiwiki/tiki-manager": "<=17.1", "tikiwiki/tiki-manager": "<=17.1",
"timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1", "timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1",
"tinymce/tinymce": "<7.2", "tinymce/tinymce": "<7.2",
@@ -5257,7 +5246,6 @@
"ttskch/pagination-service-provider": "<1", "ttskch/pagination-service-provider": "<1",
"twbs/bootstrap": "<3.4.1|>=4,<4.3.1", "twbs/bootstrap": "<3.4.1|>=4,<4.3.1",
"twig/twig": "<3.11.2|>=3.12,<3.14.1|>=3.16,<3.19", "twig/twig": "<3.11.2|>=3.12,<3.14.1|>=3.16,<3.19",
"typicms/core": "<16.1.7",
"typo3/cms": "<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2", "typo3/cms": "<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2",
"typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1", "typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1",
"typo3/cms-belog": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", "typo3/cms-belog": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2",
@@ -5312,9 +5300,8 @@
"wallabag/wallabag": "<2.6.11", "wallabag/wallabag": "<2.6.11",
"wanglelecc/laracms": "<=1.0.3", "wanglelecc/laracms": "<=1.0.3",
"wapplersystems/a21glossary": "<=0.4.10", "wapplersystems/a21glossary": "<=0.4.10",
"web-auth/webauthn-framework": ">=3.3,<3.3.4|>=4.5,<4.9|>=5.2,<5.2.4", "web-auth/webauthn-framework": ">=3.3,<3.3.4|>=4.5,<4.9",
"web-auth/webauthn-lib": ">=4.5,<4.9|>=5.2,<5.2.4", "web-auth/webauthn-lib": ">=4.5,<4.9",
"web-auth/webauthn-symfony-bundle": ">=5.2,<5.2.4",
"web-feet/coastercms": "==5.5", "web-feet/coastercms": "==5.5",
"web-tp3/wec_map": "<3.0.3", "web-tp3/wec_map": "<3.0.3",
"webbuilders-group/silverstripe-kapost-bridge": "<0.4", "webbuilders-group/silverstripe-kapost-bridge": "<0.4",
@@ -5326,7 +5313,7 @@
"wikibase/wikibase": "<=1.39.3", "wikibase/wikibase": "<=1.39.3",
"wikimedia/parsoid": "<0.12.2", "wikimedia/parsoid": "<0.12.2",
"willdurand/js-translation-bundle": "<2.1.1", "willdurand/js-translation-bundle": "<2.1.1",
"winter/wn-backend-module": "<1.2.12", "winter/wn-backend-module": "<1.2.4",
"winter/wn-cms-module": "<=1.2.9", "winter/wn-cms-module": "<=1.2.9",
"winter/wn-dusk-plugin": "<2.1", "winter/wn-dusk-plugin": "<2.1",
"winter/wn-system-module": "<1.2.4", "winter/wn-system-module": "<1.2.4",
@@ -5339,8 +5326,7 @@
"wpanel/wpanel4-cms": "<=4.3.1", "wpanel/wpanel4-cms": "<=4.3.1",
"wpcloud/wp-stateless": "<3.2", "wpcloud/wp-stateless": "<3.2",
"wpglobus/wpglobus": "<=1.9.6", "wpglobus/wpglobus": "<=1.9.6",
"wpmetabox/meta-box": "<5.11.2", "wwbn/avideo": "<21",
"wwbn/avideo": "<25",
"xataface/xataface": "<3", "xataface/xataface": "<3",
"xpressengine/xpressengine": "<3.0.15", "xpressengine/xpressengine": "<3.0.15",
"yab/quarx": "<2.4.5", "yab/quarx": "<2.4.5",
@@ -5438,7 +5424,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-03-13T21:11:59+00:00" "time": "2026-02-20T22:06:39+00:00"
}, },
{ {
"name": "sebastian/cli-parser", "name": "sebastian/cli-parser",

View File

@@ -117,5 +117,6 @@ HTML,
'cron' => [ 'cron' => [
'mode' => 'disabled', 'mode' => 'disabled',
'api_key' => '',
], ],
]; ];

View File

@@ -1,21 +0,0 @@
<?php
/** @var \Openguru\OpenCartFramework\Scheduler\ScheduleJobRegistry $scheduler */
// Define your scheduled tasks here.
// The $scheduler variable is available in this scope.
// Example: Running a task class every 5 minutes. Class should have execute method.
// $scheduler->add(\My\Task\Class::class)->everyFiveMinutes();
// Example: Running a closure every hour
// $scheduler->add(function () {
// // Do something
// }, 'my_closure_task')->everyHour();
// Example: Custom cron expression
// $scheduler->add(\My\Task\Class::class)->at('0 12 * * *');
use Bastion\ScheduledTasks\TeleCartPulseSendEventsTask;
$scheduler->add(TeleCartPulseSendEventsTask::class, 'telecart_pulse_send_events')->everyTenMinutes();

View File

@@ -1,72 +0,0 @@
<?php
namespace Console\Commands;
use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Scheduler\ScheduleJobRegistry;
use Openguru\OpenCartFramework\Scheduler\SchedulerService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ScheduleListCommand extends TeleCartCommand
{
private SchedulerService $schedulerService;
private Container $container;
protected static $defaultName = 'schedule:list';
protected static $defaultDescription = 'List all scheduled tasks and their status';
public function __construct(SchedulerService $schedulerService, Container $container)
{
parent::__construct();
$this->schedulerService = $schedulerService;
$this->container = $container;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$registry = new ScheduleJobRegistry($this->container);
// Load schedule config
$configFile = BP_PHAR_BASE_PATH . '/configs/schedule.php';
if (file_exists($configFile)) {
$scheduler = $registry; // Variable name used in config file
require $configFile;
}
$jobs = $registry->getJobs();
$table = new Table($output);
$table->setHeaders(['Name / Class', 'Cron Expression', 'Last Run', 'Last Failure']);
foreach ($jobs as $job) {
$id = $job->getId();
$lastRun = $this->schedulerService->getLastRun($id);
$lastFailure = $this->schedulerService->getLastFailure($id);
$lastRunText = $lastRun ? date('Y-m-d H:i:s', $lastRun) : 'Never';
$lastFailureText = 'None';
if ($lastFailure) {
$lastFailureText = date('Y-m-d H:i:s', $lastFailure['time']) . "\n" . substr($lastFailure['message'], 0, 50);
}
$table->addRow([
$job->getName(),
// We don't have getExpression public yet on Job, assuming we might need to add it or it's not critical.
// Wait, Job class stores expression but doesn't expose it via public getter.
// I should add getExpression() to Job.php if I want to show it.
// For now, let's assume we add it.
method_exists($job, 'getExpression') ? $job->getExpression() : '???',
$lastRunText,
$lastFailureText
]);
}
$table->render();
return Command::SUCCESS;
}
}

View File

@@ -7,6 +7,7 @@ use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Scheduler\SchedulerService; use Openguru\OpenCartFramework\Scheduler\SchedulerService;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
class ScheduleRunCommand extends TeleCartCommand class ScheduleRunCommand extends TeleCartCommand
@@ -24,21 +25,34 @@ class ScheduleRunCommand extends TeleCartCommand
$this->settings = $settings; $this->settings = $settings;
} }
protected function configure(): void
{
$this->addOption(
'ignore-global-lock',
null,
InputOption::VALUE_NONE,
'Ignore global scheduler lock (e.g. when running multiple cron instances)'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$mode = $this->settings->get('cron.mode', 'disabled'); $mode = $this->settings->get('cron.mode', 'disabled');
if ($mode !== 'system') { if ($mode !== 'system') {
$output->writeln('<comment>Scheduler is disabled. Skipping CLI execution.</comment>'); $output->writeln('<comment>Scheduler not in CRON mode. Skipping CLI execution.</comment>');
return Command::SUCCESS; return Command::SUCCESS;
} }
$output->writeln(sprintf( $output->writeln(
sprintf(
'[%s] <info>TeleCart Scheduler Running...</info>', '[%s] <info>TeleCart Scheduler Running...</info>',
Carbon::now()->toJSON(), Carbon::now()->toJSON(),
)); )
);
$result = $this->scheduler->run(); $ignoreGlobalLock = (bool) $input->getOption('ignore-global-lock');
$result = $this->scheduler->run($ignoreGlobalLock);
// Print Executed // Print Executed
if (empty($result->executed)) { if (empty($result->executed)) {

View File

@@ -0,0 +1,28 @@
<?php
use Openguru\OpenCartFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS `telecart_scheduled_jobs` (
`id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`task` varchar(1024) COLLATE utf8mb4_unicode_ci NOT NULL,
`is_enabled` tinyint(1) NOT NULL DEFAULT 1,
`cron_expression` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`last_success_at` DATETIME DEFAULT NULL,
`last_duration_seconds` FLOAT UNSIGNED NOT NULL DEFAULT 0.0,
`failed_at` DATETIME DEFAULT NULL,
`failed_reason` varchar(1024) COLLATE utf8mb4_unicode_ci DEFAULT NULL
)
ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
SQL;
$this->database->statement($sql);
}
};

View File

@@ -0,0 +1,21 @@
<?php
use Bastion\ScheduledTasks\TeleCartPulseSendEventsTask;
use Openguru\OpenCartFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$this->database->insert('telecart_scheduled_jobs', [
'name' => 'telecart_pulse_send_events',
'task' => TeleCartPulseSendEventsTask::class,
'is_enabled' => 0,
'cron_expression' => '*/10 * * * *',
'last_success_at' => null,
'last_duration_seconds' => 0,
'failed_at' => null,
'failed_reason' => null,
]);
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Bastion\Services\CronApiKeyRegenerator;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$settings = $this->app->get(Settings::class);
$currentKey = $settings->get('cron.api_key', '');
if ($currentKey !== '') {
$this->logger->info('cron.api_key already set, migration skipped');
return;
}
$regenerator = $this->app->get(CronApiKeyRegenerator::class);
$regenerator->regenerate();
$this->logger->info('cron.api_key initialized');
}
};

View File

@@ -12,7 +12,7 @@ use Openguru\OpenCartFramework\MaintenanceTasks\MaintenanceTasksService;
use Openguru\OpenCartFramework\MaintenanceTasks\MaintenanceTasksServiceProvider; use Openguru\OpenCartFramework\MaintenanceTasks\MaintenanceTasksServiceProvider;
use Openguru\OpenCartFramework\Migrations\MigrationsServiceProvider; use Openguru\OpenCartFramework\Migrations\MigrationsServiceProvider;
use Openguru\OpenCartFramework\Router\Router; use Openguru\OpenCartFramework\Router\Router;
use Openguru\OpenCartFramework\Support\ExecutionTimeProfiler; use Openguru\OpenCartFramework\Sentry\SentryService;
use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -27,11 +27,6 @@ class Application extends Container implements LoggerAwareInterface
private LoggerInterface $logger; private LoggerInterface $logger;
/**
* @var ExecutionTimeProfiler
*/
private ExecutionTimeProfiler $profiler;
public static function getInstance(): Application public static function getInstance(): Application
{ {
return static::$instance; return static::$instance;
@@ -39,13 +34,6 @@ class Application extends Container implements LoggerAwareInterface
private function bootKernelServices(): void private function bootKernelServices(): void
{ {
$this->singleton(ExecutionTimeProfiler::class, function () {
return new ExecutionTimeProfiler();
});
$this->profiler = $this->get(ExecutionTimeProfiler::class);
$this->profiler->start();
$this->singleton(Container::class, function (Container $container) { $this->singleton(Container::class, function (Container $container) {
return $container; return $container;
}); });
@@ -56,8 +44,6 @@ class Application extends Container implements LoggerAwareInterface
return new Settings($container->getConfigValue()); return new Settings($container->getConfigValue());
}); });
$this->loadEnvironmentVariables();
$errorHandler = new ErrorHandler( $errorHandler = new ErrorHandler(
$this->get(LoggerInterface::class), $this->get(LoggerInterface::class),
$this, $this,
@@ -68,16 +54,26 @@ class Application extends Container implements LoggerAwareInterface
public function boot(): Application public function boot(): Application
{ {
$timezone = $this->getConfigValue('app.oc_timezone', 'UTC');
date_default_timezone_set($timezone);
$this->loadEnvironmentVariables();
$action = $_GET['api_action'] ?? null;
SentryService::init($action);
SentryService::measure('boot_kernel_services', 'app.boot', function () {
$this->bootKernelServices(); $this->bootKernelServices();
});
SentryService::measure('dependency_registration', 'app.boot', function () {
$this->initializeEventDispatcher(static::$events); $this->initializeEventDispatcher(static::$events);
DependencyRegistration::register($this, $this->serviceProviders); DependencyRegistration::register($this, $this->serviceProviders);
});
static::$instance = $this; static::$instance = $this;
$this->profiler->addCheckpoint('Bootstrap Application');
return $this; return $this;
} }
@@ -97,18 +93,25 @@ class Application extends Container implements LoggerAwareInterface
throw new InvalidArgumentException('Invalid action: ' . $controller . '->' . $method); throw new InvalidArgumentException('Invalid action: ' . $controller . '->' . $method);
} }
$this->profiler->addCheckpoint('Handle Middlewares.'); $span = SentryService::startSpan('handle_middlewares', 'http.handle');
try {
$next = fn($req) => $this->call($controller, $method); $next = fn($req) => $this->call($controller, $method);
foreach (array_reverse($this->middlewareStack) as $class) { foreach (array_reverse($this->middlewareStack) as $class) {
$instance = $this->get($class); $instance = $this->get($class);
$next = static fn($req) => $instance->handle($req, $next); $next = static fn($req) => $instance->handle($req, $next);
} }
} finally {
SentryService::endSpan($span);
}
$span = SentryService::startSpan('handle_controller', 'http.handle');
try {
$response = $next($request); $response = $next($request);
} finally {
$this->profiler->addCheckpoint('Handle HTTP request.'); SentryService::endSpan($span);
}
return $response; return $response;
} }
@@ -117,9 +120,11 @@ class Application extends Container implements LoggerAwareInterface
{ {
$this->boot(); $this->boot();
SentryService::measure('handle_request', 'http.handle', function () {
$request = Request::createFromGlobals(); $request = Request::createFromGlobals();
$response = $this->handleRequest($request); $response = $this->handleRequest($request);
$response->send(); $response->send();
});
} }
public function withServiceProviders(array $serviceProviders): Application public function withServiceProviders(array $serviceProviders): Application
@@ -182,6 +187,7 @@ class Application extends Container implements LoggerAwareInterface
if (! defined('BP_PHAR_BASE_PATH') || ! defined('BP_REAL_BASE_PATH')) { if (! defined('BP_PHAR_BASE_PATH') || ! defined('BP_REAL_BASE_PATH')) {
$dotenv = Dotenv::createMutable(__DIR__ . '/../'); $dotenv = Dotenv::createMutable(__DIR__ . '/../');
$dotenv->load(); $dotenv->load();
return; return;
} }

View File

@@ -8,6 +8,7 @@ use Openguru\OpenCartFramework\Exceptions\ActionNotFoundException;
use Openguru\OpenCartFramework\Exceptions\HttpNotFoundException; use Openguru\OpenCartFramework\Exceptions\HttpNotFoundException;
use Openguru\OpenCartFramework\Exceptions\InvalidApiTokenException; use Openguru\OpenCartFramework\Exceptions\InvalidApiTokenException;
use Openguru\OpenCartFramework\Exceptions\NonLoggableExceptionInterface; use Openguru\OpenCartFramework\Exceptions\NonLoggableExceptionInterface;
use Openguru\OpenCartFramework\Sentry\SentryService;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -110,6 +111,8 @@ class ErrorHandler
public function handleShutdown(): void public function handleShutdown(): void
{ {
SentryService::finishTransaction();
$error = error_get_last(); $error = error_get_last();
if ($error !== null && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR], true)) { if ($error !== null && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR], true)) {

View File

@@ -2,9 +2,11 @@
namespace Openguru\OpenCartFramework\QueryBuilder\Connections; namespace Openguru\OpenCartFramework\QueryBuilder\Connections;
use Openguru\OpenCartFramework\Sentry\SentryService;
use Openguru\OpenCartFramework\Support\Utils; use Openguru\OpenCartFramework\Support\Utils;
use PDO; use PDO;
use RuntimeException; use RuntimeException;
use Sentry\Tracing\SpanContext;
class MySqlConnection implements ConnectionInterface class MySqlConnection implements ConnectionInterface
{ {
@@ -25,10 +27,23 @@ class MySqlConnection implements ConnectionInterface
public function select(string $sql, array $bindings = []): array public function select(string $sql, array $bindings = []): array
{ {
$span = SentryService::startSpan($sql, 'db.mysql.query', function (SpanContext $context) use ($sql) {
$context->setData([
'db.system' => 'mysql',
'db.statement' => $sql,
]);
});
try {
$statement = $this->pdo->prepare($sql); $statement = $this->pdo->prepare($sql);
$statement->execute($bindings); $statement->execute($bindings);
return $statement->fetchAll(PDO::FETCH_ASSOC); $results = $statement->fetchAll(PDO::FETCH_ASSOC);
} finally {
SentryService::endSpan($span);
}
return $results;
} }
public function escape($value): string public function escape($value): string
@@ -88,10 +103,19 @@ class MySqlConnection implements ConnectionInterface
public function statement(string $sql, array $bindings = []): bool public function statement(string $sql, array $bindings = []): bool
{ {
$span = SentryService::startSpan($sql, 'db.mysql.query', function (SpanContext $context) use ($sql) {
$context->setData([
'db.system' => 'mysql',
'db.statement' => $sql,
]);
});
try {
$statement = $this->pdo->prepare($sql); $statement = $this->pdo->prepare($sql);
if (! $statement) { if (! $statement) {
$this->lastError = $this->pdo->errorInfo(); $this->lastError = $this->pdo->errorInfo();
return false; return false;
} }
@@ -99,8 +123,12 @@ class MySqlConnection implements ConnectionInterface
if (! $success) { if (! $success) {
$this->lastError = $statement->errorInfo(); $this->lastError = $statement->errorInfo();
return false; return false;
} }
} finally {
SentryService::endSpan($span);
}
return $success; return $success;
} }

View File

@@ -3,6 +3,7 @@
namespace Openguru\OpenCartFramework\Scheduler; namespace Openguru\OpenCartFramework\Scheduler;
use Cron\CronExpression; use Cron\CronExpression;
use InvalidArgumentException;
use Openguru\OpenCartFramework\Container\Container; use Openguru\OpenCartFramework\Container\Container;
class Job class Job
@@ -11,14 +12,15 @@ class Job
/** @var string|callable|TaskInterface */ /** @var string|callable|TaskInterface */
private $action; private $action;
private int $id;
private ?string $name;
private string $expression = '* * * * *'; private string $expression = '* * * * *';
private ?string $name; public function __construct(Container $container, int $id, $action, ?string $name = null)
public function __construct(Container $container, $action, ?string $name = null)
{ {
$this->container = $container; $this->container = $container;
$this->id = $id;
$this->action = $action; $this->action = $action;
$this->name = $name; $this->name = $name;
} }
@@ -74,6 +76,16 @@ class Job
call_user_func($this->action); call_user_func($this->action);
} elseif ($this->action instanceof TaskInterface) { } elseif ($this->action instanceof TaskInterface) {
$this->action->execute(); $this->action->execute();
} else {
$actionType = is_object($this->action) ? get_class($this->action) : gettype($this->action);
throw new InvalidArgumentException(
sprintf(
'Job "%s" (id: %d): action is not valid (expected class name, callable or TaskInterface, got %s).',
$this->getName(),
$this->id,
$actionType
)
);
} }
} }
@@ -90,9 +102,9 @@ class Job
return 'Closure'; return 'Closure';
} }
public function getId(): string public function getId(): int
{ {
return md5($this->getName()); return $this->id;
} }
public function getExpression(): string public function getExpression(): string

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Openguru\OpenCartFramework\Scheduler\Models;
use Carbon\Carbon;
use Carbon\CarbonTimeZone;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
class ScheduledJob
{
private const TABLE_NAME = 'telecart_scheduled_jobs';
private Builder $builder;
public function __construct(Builder $builder)
{
$this->builder = $builder;
}
public function all(): array
{
return $this->builder->newQuery()
->from(self::TABLE_NAME)
->get();
}
public function getEnabledScheduledJobs(): array
{
return $this->builder->newQuery()
->from(self::TABLE_NAME)
->where('is_enabled', '=', 1)
->get();
}
public function enable(int $id): bool
{
return $this->builder->newQuery()
->where('id', '=', $id)
->update(self::TABLE_NAME, [
'is_enabled' => 1,
]);
}
public function disable(int $id): bool
{
return $this->builder->newQuery()
->where('id', '=', $id)
->update(self::TABLE_NAME, [
'is_enabled' => 0,
]);
}
public function updateCronExpression(int $id, string $cronExpression): bool
{
return $this->builder->newQuery()
->where('id', '=', $id)
->update(self::TABLE_NAME, [
'cron_expression' => mb_substr($cronExpression, 0, 64),
]);
}
public function updateLastSuccessAt(int $id, float $durationSeconds): void
{
$this->builder->newQuery()
->where('id', '=', $id)
->update(self::TABLE_NAME, [
'last_success_at' => Carbon::now(),
'last_duration_seconds' => $durationSeconds,
]);
}
public function updateFailedAt(int $id, string $message): void
{
$this->builder->newQuery()
->where('id', '=', $id)
->update(self::TABLE_NAME, [
'failed_at' => Carbon::now(),
'failed_reason' => mb_substr($message, 0, 1000),
]);
}
public function clearFailedInfo(int $id): void
{
$this->builder->newQuery()
->where('id', '=', $id)
->update(self::TABLE_NAME, [
'failed_at' => null,
'failed_reason' => null,
]);
}
}

View File

@@ -3,6 +3,7 @@
namespace Openguru\OpenCartFramework\Scheduler; namespace Openguru\OpenCartFramework\Scheduler;
use Openguru\OpenCartFramework\Container\Container; use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Scheduler\Models\ScheduledJob;
class ScheduleJobRegistry class ScheduleJobRegistry
{ {
@@ -10,10 +11,12 @@ class ScheduleJobRegistry
/** @var Job[] */ /** @var Job[] */
private array $jobs = []; private array $jobs = [];
private ScheduledJob $scheduledJob;
public function __construct(Container $container) public function __construct(Container $container, ScheduledJob $scheduledJob)
{ {
$this->container = $container; $this->container = $container;
$this->scheduledJob = $scheduledJob;
} }
/** /**
@@ -21,9 +24,9 @@ class ScheduleJobRegistry
* @param string|null $name * @param string|null $name
* @return Job * @return Job
*/ */
public function add($job, ?string $name = null): Job public function add(int $id, $job, ?string $name = null): Job
{ {
$newJob = new Job($this->container, $job, $name); $newJob = new Job($this->container, $id, $job, $name);
$this->jobs[] = $newJob; $this->jobs[] = $newJob;
return $newJob; return $newJob;
@@ -36,4 +39,14 @@ class ScheduleJobRegistry
{ {
return $this->jobs; return $this->jobs;
} }
public function loadJobsFromDatabase(): void
{
$jobs = $this->scheduledJob->getEnabledScheduledJobs();
foreach ($jobs as $job) {
$this->add($job['id'], $job['task'], $job['name'])
->at($job['cron_expression']);
}
}
} }

View File

@@ -1,11 +1,13 @@
<?php <?php
declare(strict_types=1);
namespace Openguru\OpenCartFramework\Scheduler; namespace Openguru\OpenCartFramework\Scheduler;
use DateTime; use DateTime;
use Openguru\OpenCartFramework\Cache\CacheInterface; use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Config\Settings; use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Container\Container; use Openguru\OpenCartFramework\Scheduler\Models\ScheduledJob;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Throwable; use Throwable;
@@ -13,23 +15,25 @@ class SchedulerService
{ {
private LoggerInterface $logger; private LoggerInterface $logger;
private CacheInterface $cache; private CacheInterface $cache;
private Container $container;
private Settings $settings; private Settings $settings;
private ?ScheduleJobRegistry $registry = null; private ScheduleJobRegistry $registry;
private const GLOBAL_LOCK_KEY = 'scheduler.global_lock'; private const GLOBAL_LOCK_KEY = 'scheduler.global_lock';
private const GLOBAL_LOCK_TTL = 300; // 5 minutes private const GLOBAL_LOCK_TTL = 300; // 5 minutes
private ScheduledJob $scheduledJob;
public function __construct( public function __construct(
LoggerInterface $logger, LoggerInterface $logger,
CacheInterface $cache, CacheInterface $cache,
Container $container, Settings $settings,
Settings $settings ScheduleJobRegistry $registry,
ScheduledJob $scheduledJob
) { ) {
$this->logger = $logger; $this->logger = $logger;
$this->cache = $cache; $this->cache = $cache;
$this->container = $container;
$this->settings = $settings; $this->settings = $settings;
$this->registry = $registry;
$this->scheduledJob = $scheduledJob;
} }
// For testing purposes // For testing purposes
@@ -38,7 +42,7 @@ class SchedulerService
$this->registry = $registry; $this->registry = $registry;
} }
public function run(): SchedulerResult public function run(bool $ignoreGlobalLock = false): SchedulerResult
{ {
$result = new SchedulerResult(); $result = new SchedulerResult();
@@ -49,41 +53,31 @@ class SchedulerService
return $result; return $result;
} }
if ($this->isGlobalLocked()) { if (! $ignoreGlobalLock && $this->isGlobalLocked()) {
$result->addSkipped('Global', 'Global scheduler lock active'); $result->addSkipped('Global', 'Global scheduler lock active');
return $result; return $result;
} }
if (! $ignoreGlobalLock) {
$this->acquireGlobalLock(); $this->acquireGlobalLock();
// Since we want to run every 5 minutes, running it more frequently won't trigger jobs earlier than due, }
// but locking might prevent overlap if previous run takes > 5 mins.
// However, updating global last run on every attempt might be useful for diagnostics,
// but strictly speaking, we only care if tasks were processed.
$this->updateGlobalLastRun(); $this->updateGlobalLastRun();
try { try {
$scheduler = $this->registry ?: new ScheduleJobRegistry($this->container); $this->registry->loadJobsFromDatabase();
// Only load config file if registry was not injected (for production use) foreach ($this->registry->getJobs() as $job) {
if (! $this->registry) {
$configFile = BP_PHAR_BASE_PATH . '/configs/schedule.php';
if (file_exists($configFile)) {
require $configFile;
} else {
$this->logger->warning('Scheduler config file not found: ' . $configFile);
}
}
foreach ($scheduler->getJobs() as $job) {
$this->processJob($job, $result); $this->processJob($job, $result);
} }
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error('Scheduler run failed: ' . $e->getMessage(), ['exception' => $e]); $this->logger->error('Scheduler run failed: ' . $e->getMessage(), ['exception' => $e]);
$result->addFailed('Scheduler', $e->getMessage()); $result->addFailed('Scheduler', $e->getMessage());
} finally { } finally {
if (! $ignoreGlobalLock) {
$this->releaseGlobalLock(); $this->releaseGlobalLock();
} }
}
return $result; return $result;
} }
@@ -93,7 +87,6 @@ class SchedulerService
$name = $job->getName(); $name = $job->getName();
$id = $job->getId(); $id = $job->getId();
try {
// 1. Check if due by Cron expression // 1. Check if due by Cron expression
if (! $job->isDue()) { if (! $job->isDue()) {
$result->addSkipped($name, 'Not due'); $result->addSkipped($name, 'Not due');
@@ -115,29 +108,24 @@ class SchedulerService
return; return;
} }
// Lock and Run
$this->lockJob($id); $this->lockJob($id);
$startTime = microtime(true);
try { try {
$this->scheduledJob->clearFailedInfo($id);
$startTime = microtime(true);
$job->run(); $job->run();
$duration = microtime(true) - $startTime; $duration = microtime(true) - $startTime;
$this->updateLastRun($id); $this->scheduledJob->updateLastSuccessAt($id, $duration);
$this->logger->debug("Job executed: {$name}", ['duration' => $duration]); $this->logger->debug("Job executed: {$name}", ['duration' => $duration]);
$result->addExecuted($name, $duration); $result->addExecuted($name, $duration);
} catch (Throwable $e) { } catch (Throwable $e) {
$this->updateLastFailure($id, $e->getMessage());
$this->logger->error("Job failed: {$name}", ['exception' => $e]); $this->logger->error("Job failed: {$name}", ['exception' => $e]);
$this->scheduledJob->updateFailedAt($id, $e->getMessage());
$result->addFailed($name, $e->getMessage()); $result->addFailed($name, $e->getMessage());
} finally { } finally {
$this->updateLastRun($id);
$this->unlockJob($id); $this->unlockJob($id);
} }
} catch (Throwable $e) {
$this->logger->error("Error processing job {$name}: " . $e->getMessage());
$result->addFailed($name, 'Processing error: ' . $e->getMessage());
}
} }
private function isGlobalLocked(): bool private function isGlobalLocked(): bool
@@ -155,36 +143,36 @@ class SchedulerService
$this->cache->delete(self::GLOBAL_LOCK_KEY); $this->cache->delete(self::GLOBAL_LOCK_KEY);
} }
private function isJobLocked(string $id): bool private function isJobLocked(int $id): bool
{ {
return (bool) $this->cache->get("scheduler.lock.{$id}"); return (bool) $this->cache->get("scheduler.lock.{$id}");
} }
private function lockJob(string $id): void private function lockJob(int $id): void
{ {
// 30 minutes max execution time for a single job safe-guard // 30 minutes max execution time for a single job safe-guard
$this->cache->set("scheduler.lock.{$id}", 1, 1800); $this->cache->set("scheduler.lock.{$id}", 1, 1800);
} }
private function unlockJob(string $id): void private function unlockJob(int $id): void
{ {
$this->cache->delete("scheduler.lock.{$id}"); $this->cache->delete("scheduler.lock.{$id}");
} }
private function hasRanRecently(string $id): bool private function hasRanRecently(int $id): bool
{ {
$lastRun = $this->getLastRun($id); $lastRun = $this->getLastRun($id);
if (! $lastRun) { if (! $lastRun) {
return false; return false;
} }
$lastRunDate = (new DateTime())->setTimestamp((int) $lastRun); $lastRunDate = (new DateTime())->setTimestamp($lastRun);
$now = new DateTime(); $now = new DateTime();
return $lastRunDate->format('Y-m-d H:i') === $now->format('Y-m-d H:i'); return $lastRunDate->format('Y-m-d H:i') === $now->format('Y-m-d H:i');
} }
private function updateLastRun(string $id): void private function updateLastRun(int $id): void
{ {
$this->cache->set("scheduler.last_run.{$id}", time()); $this->cache->set("scheduler.last_run.{$id}", time());
} }
@@ -194,34 +182,8 @@ class SchedulerService
$this->cache->set("scheduler.global_last_run", time()); $this->cache->set("scheduler.global_last_run", time());
} }
public function getGlobalLastRun(): ?int public function getLastRun(int $id): ?int
{
$time = $this->cache->get("scheduler.global_last_run");
return $time ? (int) $time : null;
}
private function updateLastFailure(string $id, string $message): void
{
$this->cache->set("scheduler.last_failure.{$id}", time());
$this->cache->set("scheduler.last_failure_msg.{$id}", $message);
}
public function getLastRun(string $id): ?int
{ {
return $this->cache->get("scheduler.last_run.{$id}"); return $this->cache->get("scheduler.last_run.{$id}");
} }
public function getLastFailure(string $id): ?array
{
$time = $this->cache->get("scheduler.last_failure.{$id}");
if (! $time) {
return null;
}
return [
'time' => (int) $time,
'message' => $this->cache->get("scheduler.last_failure_msg.{$id}"),
];
}
} }

View File

@@ -2,23 +2,12 @@
namespace Openguru\OpenCartFramework\Scheduler; namespace Openguru\OpenCartFramework\Scheduler;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Container\ServiceProvider; use Openguru\OpenCartFramework\Container\ServiceProvider;
use Psr\Log\LoggerInterface;
class SchedulerServiceProvider extends ServiceProvider class SchedulerServiceProvider extends ServiceProvider
{ {
public function register(): void public function register(): void
{ {
$this->container->singleton(SchedulerService::class, function (Container $container) { //
return new SchedulerService(
$container->get(LoggerInterface::class),
$container->get(CacheInterface::class),
$container,
$container->get(Settings::class)
);
});
} }
} }

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Openguru\OpenCartFramework\Sentry;
use Closure;
use Sentry\SentrySdk;
use Sentry\Tracing\Span;
use Sentry\Tracing\SpanContext;
use Sentry\Tracing\Transaction;
use Sentry\Tracing\TransactionContext;
use SplObjectStorage;
use function Sentry\init;
use function Sentry\startTransaction;
class SentryService
{
private static ?string $currentAction = null;
public static ?Transaction $currentTransaction = null;
/**
* Stores parent span for each created child span to correctly restore nesting.
*
* @var SplObjectStorage<Span, Span|null>|null
*/
private static ?SplObjectStorage $spanParents = null;
public static array $excludeActions = [
'health',
];
private static function resolveOptionsFromEnv(): array
{
return [
'dsn' => env('SENTRY_DSN'),
'send_default_pii' => true,
'enable_logs' => (bool) filter_var(env('SENTRY_ENABLE_LOGS', false), FILTER_VALIDATE_BOOLEAN),
'traces_sample_rate' => (float) env('SENTRY_TRACES_SAMPLE_RATE', 1.0),
];
}
private static function isSentryEnabled(): bool
{
return self::$currentAction !== null
&& ! in_array(self::$currentAction, self::$excludeActions, true)
&& env('SENTRY_ENABLED', false);
}
public static function init(?string $action): void
{
$options = self::resolveOptionsFromEnv();
self::$currentAction = $action;
if (! self::isSentryEnabled()) {
return;
}
init($options);
$transactionContext = TransactionContext::make()
->setName(self::$currentAction)
->setOp('http.server');
self::$currentTransaction = startTransaction($transactionContext);
SentrySdk::getCurrentHub()->setSpan(self::$currentTransaction);
}
public static function startSpan(string $description, string $op, ?Closure $closure = null): ?Span
{
$parent = self::resolveParent();
if (! self::isSentryEnabled() || ! $parent) {
return null;
}
$context = SpanContext::make()
->setOp($op)
->setDescription($description);
if ($closure) {
$closure($context);
}
$span = $parent->startChild($context);
if (self::$spanParents === null) {
self::$spanParents = new SplObjectStorage();
}
self::$spanParents[$span] = $parent;
SentrySdk::getCurrentHub()->setSpan($span);
return $span;
}
public static function endSpan(?Span $span = null): void
{
if (! $span || ! self::isSentryEnabled()) {
return;
}
$parent = null;
if (self::$spanParents !== null && self::$spanParents->contains($span)) {
$parent = self::$spanParents[$span] ?? null;
self::$spanParents->detach($span);
}
$span->finish();
SentrySdk::getCurrentHub()->setSpan($parent ?? self::$currentTransaction);
}
public static function resolveParent(): ?Span
{
return SentrySdk::getCurrentHub()->getSpan() ?? self::$currentTransaction;
}
public static function measure(string $description, string $op, Closure $closure, ?Closure $params = null): void
{
$span = self::startSpan($description, $op, $params);
try {
$closure();
} finally {
self::endSpan($span);
}
}
public static function finishTransaction(): void
{
if (self::$currentTransaction) {
self::$currentTransaction->finish();
self::$currentTransaction = null;
}
}
}

View File

@@ -15,6 +15,7 @@ class TelegramValidateInitDataMiddleware
'health', 'health',
'etlCustomers', 'etlCustomers',
'etlCustomersMeta', 'etlCustomersMeta',
'runSchedule',
]; ];
public function __construct(SignatureValidator $signatureValidator) public function __construct(SignatureValidator $signatureValidator)

View File

@@ -11,6 +11,7 @@ use Openguru\OpenCartFramework\QueryBuilder\QueryBuilderServiceProvider;
use Openguru\OpenCartFramework\Router\RouteServiceProvider; use Openguru\OpenCartFramework\Router\RouteServiceProvider;
use Openguru\OpenCartFramework\Support\Arr; use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\TeleCartPulse\TeleCartPulseServiceProvider; use Openguru\OpenCartFramework\TeleCartPulse\TeleCartPulseServiceProvider;
use Openguru\OpenCartFramework\Scheduler\SchedulerServiceProvider;
use Openguru\OpenCartFramework\Telegram\TelegramServiceProvider; use Openguru\OpenCartFramework\Telegram\TelegramServiceProvider;
use Openguru\OpenCartFramework\Telegram\TelegramValidateInitDataMiddleware; use Openguru\OpenCartFramework\Telegram\TelegramValidateInitDataMiddleware;
use Openguru\OpenCartFramework\Validator\ValidatorServiceProvider; use Openguru\OpenCartFramework\Validator\ValidatorServiceProvider;
@@ -31,6 +32,7 @@ class ApplicationFactory
RouteServiceProvider::class, RouteServiceProvider::class,
AppServiceProvider::class, AppServiceProvider::class,
TelegramServiceProvider::class, TelegramServiceProvider::class,
SchedulerServiceProvider::class,
ValidatorServiceProvider::class, ValidatorServiceProvider::class,
TeleCartPulseServiceProvider::class, TeleCartPulseServiceProvider::class,
ImageToolServiceProvider::class, ImageToolServiceProvider::class,

View File

@@ -3,7 +3,6 @@
namespace App\Handlers; namespace App\Handlers;
use App\Services\CartService; use App\Services\CartService;
use App\Support\Utf8Cleaner;
use Cart\Cart; use Cart\Cart;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\Http\Request; use Openguru\OpenCartFramework\Http\Request;
@@ -24,7 +23,7 @@ class CartHandler
$items = $this->cartService->getCart(); $items = $this->cartService->getCart();
return new JsonResponse([ return new JsonResponse([
'data' => Utf8Cleaner::clean($items), 'data' => $items,
]); ]);
} }
@@ -49,7 +48,7 @@ class CartHandler
} }
return new JsonResponse([ return new JsonResponse([
'data' => Utf8Cleaner::clean($items), 'data' => $items,
]); ]);
} }
} }

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

@@ -4,7 +4,6 @@ namespace App\Handlers;
use App\Exceptions\OrderValidationFailedException; use App\Exceptions\OrderValidationFailedException;
use App\Services\OrderCreateService; use App\Services\OrderCreateService;
use App\Support\Utf8Cleaner;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\Http\Request; use Openguru\OpenCartFramework\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -27,11 +26,11 @@ class OrderHandler
]); ]);
return new JsonResponse([ return new JsonResponse([
'data' => Utf8Cleaner::clean($order), 'data' => $order,
], Response::HTTP_CREATED); ], Response::HTTP_CREATED);
} catch (OrderValidationFailedException $exception) { } catch (OrderValidationFailedException $exception) {
return new JsonResponse([ return new JsonResponse([
'data' => Utf8Cleaner::clean($exception->getErrorBag()->firstOfAll()), 'data' => $exception->getErrorBag()->firstOfAll(),
], Response::HTTP_UNPROCESSABLE_ENTITY); ], Response::HTTP_UNPROCESSABLE_ENTITY);
} }
} }

View File

@@ -16,6 +16,7 @@ use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause; use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Openguru\OpenCartFramework\QueryBuilder\RawExpression; use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
use Openguru\OpenCartFramework\QueryBuilder\Table; use Openguru\OpenCartFramework\QueryBuilder\Table;
use Openguru\OpenCartFramework\Sentry\SentryService;
use Openguru\OpenCartFramework\Support\Arr; use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Support\PaginationHelper; use Openguru\OpenCartFramework\Support\PaginationHelper;
use Openguru\OpenCartFramework\Support\Str; use Openguru\OpenCartFramework\Support\Str;
@@ -157,6 +158,7 @@ class ProductsService
->get(); ->get();
} }
$span = SentryService::startSpan('crop_images', 'image.process');
$productsImagesMap = []; $productsImagesMap = [];
foreach ($productsImages as $item) { foreach ($productsImages as $item) {
$productId = $item['product_id']; $productId = $item['product_id'];
@@ -174,6 +176,8 @@ class ProductsService
} }
} }
SentryService::endSpan($span);
$debug = []; $debug = [];
if (env('APP_DEBUG')) { if (env('APP_DEBUG')) {
$debug = [ $debug = [

View File

@@ -1,49 +0,0 @@
<?php
namespace App\Support;
class Utf8Cleaner
{
/**
* Рекурсивно проходит по массиву/объекту и приводит все строковые значения к валидному UTF-8.
*/
public static function clean($value)
{
if (is_array($value)) {
foreach ($value as $key => $item) {
$value[$key] = self::clean($item);
}
return $value;
}
if (is_object($value)) {
foreach (get_object_vars($value) as $property => $item) {
$value->{$property} = self::clean($item);
}
return $value;
}
if (is_string($value)) {
// Попробовать нормализовать строку в UTF-8, подменяя невалидные последовательности.
$converted = @mb_convert_encoding($value, 'UTF-8', 'UTF-8');
if ($converted === false) {
// Фолбэк: заменить невалидные байты стандартным алгоритмом json_encode.
$encoded = json_encode($value, JSON_INVALID_UTF8_SUBSTITUTE);
if ($encoded === false) {
return '';
}
// Убираем кавычки, которые добавляет json_encode.
return json_decode($encoded, true) ?? '';
}
return $converted;
}
return $value;
}
}

View File

@@ -3,6 +3,7 @@
use App\Handlers\BlocksHandler; use App\Handlers\BlocksHandler;
use App\Handlers\CartHandler; use App\Handlers\CartHandler;
use App\Handlers\CategoriesHandler; use App\Handlers\CategoriesHandler;
use App\Handlers\CronHandler;
use App\Handlers\ETLHandler; use App\Handlers\ETLHandler;
use App\Handlers\FiltersHandler; use App\Handlers\FiltersHandler;
use App\Handlers\FormsHandler; use App\Handlers\FormsHandler;
@@ -24,6 +25,7 @@ return [
'getForm' => [FormsHandler::class, 'getForm'], 'getForm' => [FormsHandler::class, 'getForm'],
'health' => [HealthCheckHandler::class, 'handle'], 'health' => [HealthCheckHandler::class, 'handle'],
'ingest' => [TelemetryHandler::class, 'ingest'], 'ingest' => [TelemetryHandler::class, 'ingest'],
'runSchedule' => [CronHandler::class, 'runSchedule'],
'heartbeat' => [TelemetryHandler::class, 'heartbeat'], 'heartbeat' => [TelemetryHandler::class, 'heartbeat'],
'processBlock' => [BlocksHandler::class, 'processBlock'], 'processBlock' => [BlocksHandler::class, 'processBlock'],
'product_show' => [ProductsHandler::class, 'show'], 'product_show' => [ProductsHandler::class, 'show'],

View File

@@ -14,11 +14,11 @@ class JobTest extends TestCase
$container = $this->app; $container = $this->app;
// Act // Act
$job = new Job($container, function() {}, 'TestJob'); $job = new Job($container, 1, function() {}, 'TestJob');
// Assert // Assert
$this->assertEquals('TestJob', $job->getName()); $this->assertEquals('TestJob', $job->getName());
$this->assertEquals(md5('TestJob'), $job->getId()); $this->assertEquals(1, $job->getId());
} }
public function testJobWithoutNameUsesClassName() public function testJobWithoutNameUsesClassName()
@@ -27,7 +27,7 @@ class JobTest extends TestCase
$container = $this->app; $container = $this->app;
// Act // Act
$job = new Job($container, TestTask::class); $job = new Job($container, 1, TestTask::class);
// Assert // Assert
$this->assertEquals(TestTask::class, $job->getName()); $this->assertEquals(TestTask::class, $job->getName());
@@ -39,7 +39,7 @@ class JobTest extends TestCase
$container = $this->app; $container = $this->app;
// Act // Act
$job = new Job($container, function() {}); $job = new Job($container, 1, function() {});
// Assert // Assert
$this->assertEquals('Closure', $job->getName()); $this->assertEquals('Closure', $job->getName());
@@ -49,7 +49,7 @@ class JobTest extends TestCase
{ {
// Arrange // Arrange
$container = $this->app; $container = $this->app;
$job = new Job($container, function() {}); $job = new Job($container, 1, function() {});
// Act // Act
$job->everyMinute(); $job->everyMinute();
@@ -62,7 +62,7 @@ class JobTest extends TestCase
{ {
// Arrange // Arrange
$container = $this->app; $container = $this->app;
$job = new Job($container, function() {}); $job = new Job($container, 1, function() {});
// Act // Act
$job->everyFiveMinutes(); $job->everyFiveMinutes();
@@ -75,7 +75,7 @@ class JobTest extends TestCase
{ {
// Arrange // Arrange
$container = $this->app; $container = $this->app;
$job = new Job($container, function() {}); $job = new Job($container, 1, function() {});
// Act // Act
$job->everyHour(); $job->everyHour();
@@ -88,7 +88,7 @@ class JobTest extends TestCase
{ {
// Arrange // Arrange
$container = $this->app; $container = $this->app;
$job = new Job($container, function() {}); $job = new Job($container, 1, function() {});
// Act // Act
$job->dailyAt(9, 30); $job->dailyAt(9, 30);
@@ -101,7 +101,7 @@ class JobTest extends TestCase
{ {
// Arrange // Arrange
$container = $this->app; $container = $this->app;
$job = new Job($container, function() {}); $job = new Job($container, 1, function() {});
// Act // Act
$job->at('0 12 * * 1'); $job->at('0 12 * * 1');
@@ -114,7 +114,7 @@ class JobTest extends TestCase
{ {
// Arrange // Arrange
$container = $this->app; $container = $this->app;
$job = new Job($container, function() {}); $job = new Job($container, 1, function() {});
$job->everyMinute(); $job->everyMinute();
// Act // Act
@@ -128,7 +128,7 @@ class JobTest extends TestCase
{ {
// Arrange // Arrange
$container = $this->app; $container = $this->app;
$job = new Job($container, function() {}); $job = new Job($container, 1, function() {});
// Set to run at a specific future time // Set to run at a specific future time
$job->at('0 23 * * *'); // 23:00 every day $job->at('0 23 * * *'); // 23:00 every day
@@ -146,7 +146,7 @@ class JobTest extends TestCase
$container = $this->app; $container = $this->app;
$executed = false; $executed = false;
$job = new Job($container, function() use (&$executed) { $job = new Job($container, 1, function() use (&$executed) {
$executed = true; $executed = true;
}); });
@@ -167,7 +167,7 @@ class JobTest extends TestCase
return new TestTask(); return new TestTask();
}); });
$job = new Job($container, TestTask::class); $job = new Job($container, 1, TestTask::class);
// Act // Act
$job->run(); $job->run();
@@ -182,7 +182,7 @@ class JobTest extends TestCase
// Arrange // Arrange
$container = $this->app; $container = $this->app;
$task = new TestTask(); $task = new TestTask();
$job = new Job($container, $task); $job = new Job($container, 1, $task);
// Act // Act
$job->run(); $job->run();

View File

@@ -2,16 +2,23 @@
namespace Tests\Unit\Framework\Scheduler; namespace Tests\Unit\Framework\Scheduler;
use Mockery;
use Openguru\OpenCartFramework\Scheduler\Job; use Openguru\OpenCartFramework\Scheduler\Job;
use Openguru\OpenCartFramework\Scheduler\Models\ScheduledJob;
use Openguru\OpenCartFramework\Scheduler\ScheduleJobRegistry; use Openguru\OpenCartFramework\Scheduler\ScheduleJobRegistry;
use Tests\TestCase; use Tests\TestCase;
class ScheduleJobRegistryTest extends TestCase class ScheduleJobRegistryTest extends TestCase
{ {
private function createScheduledJobMock(): ScheduledJob
{
return Mockery::mock(ScheduledJob::class);
}
public function testRegistryCreation() public function testRegistryCreation()
{ {
// Arrange & Act // Arrange & Act
$registry = new ScheduleJobRegistry($this->app); $registry = new ScheduleJobRegistry($this->app, $this->createScheduledJobMock());
// Assert // Assert
$this->assertInstanceOf(ScheduleJobRegistry::class, $registry); $this->assertInstanceOf(ScheduleJobRegistry::class, $registry);
@@ -21,10 +28,10 @@ class ScheduleJobRegistryTest extends TestCase
public function testAddJobWithoutName() public function testAddJobWithoutName()
{ {
// Arrange // Arrange
$registry = new ScheduleJobRegistry($this->app); $registry = new ScheduleJobRegistry($this->app, $this->createScheduledJobMock());
// Act // Act
$job = $registry->add(function() {}); $job = $registry->add(1, function() {});
// Assert // Assert
$this->assertInstanceOf(Job::class, $job); $this->assertInstanceOf(Job::class, $job);
@@ -35,10 +42,10 @@ class ScheduleJobRegistryTest extends TestCase
public function testAddJobWithName() public function testAddJobWithName()
{ {
// Arrange // Arrange
$registry = new ScheduleJobRegistry($this->app); $registry = new ScheduleJobRegistry($this->app, $this->createScheduledJobMock());
// Act // Act
$job = $registry->add(function() {}, 'MyCustomJob'); $job = $registry->add(1, function() {}, 'MyCustomJob');
// Assert // Assert
$this->assertInstanceOf(Job::class, $job); $this->assertInstanceOf(Job::class, $job);
@@ -49,12 +56,12 @@ class ScheduleJobRegistryTest extends TestCase
public function testAddMultipleJobs() public function testAddMultipleJobs()
{ {
// Arrange // Arrange
$registry = new ScheduleJobRegistry($this->app); $registry = new ScheduleJobRegistry($this->app, $this->createScheduledJobMock());
// Act // Act
$job1 = $registry->add(function() {}, 'Job1'); $job1 = $registry->add(1, function() {}, 'Job1');
$job2 = $registry->add(function() {}, 'Job2'); $job2 = $registry->add(2, function() {}, 'Job2');
$job3 = $registry->add(TestTask::class, 'Job3'); $job3 = $registry->add(3, TestTask::class, 'Job3');
// Assert // Assert
$jobs = $registry->getJobs(); $jobs = $registry->getJobs();
@@ -67,8 +74,8 @@ class ScheduleJobRegistryTest extends TestCase
public function testGetJobsReturnsArray() public function testGetJobsReturnsArray()
{ {
// Arrange // Arrange
$registry = new ScheduleJobRegistry($this->app); $registry = new ScheduleJobRegistry($this->app, $this->createScheduledJobMock());
$registry->add(function() {}, 'TestJob'); $registry->add(1, function() {}, 'TestJob');
// Act // Act
$jobs = $registry->getJobs(); $jobs = $registry->getJobs();
@@ -82,10 +89,10 @@ class ScheduleJobRegistryTest extends TestCase
public function testJobSchedulingMethods() public function testJobSchedulingMethods()
{ {
// Arrange // Arrange
$registry = new ScheduleJobRegistry($this->app); $registry = new ScheduleJobRegistry($this->app, $this->createScheduledJobMock());
// Act // Act
$job = $registry->add(function() {}, 'TestJob') $job = $registry->add(1, function() {}, 'TestJob')
->everyFiveMinutes(); ->everyFiveMinutes();
// Assert // Assert

View File

@@ -6,6 +6,7 @@ use Mockery;
use Openguru\OpenCartFramework\Cache\CacheInterface; use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Config\Settings; use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Scheduler\Job; use Openguru\OpenCartFramework\Scheduler\Job;
use Openguru\OpenCartFramework\Scheduler\Models\ScheduledJob;
use Openguru\OpenCartFramework\Scheduler\SchedulerService; use Openguru\OpenCartFramework\Scheduler\SchedulerService;
use Openguru\OpenCartFramework\Scheduler\ScheduleJobRegistry; use Openguru\OpenCartFramework\Scheduler\ScheduleJobRegistry;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -26,11 +27,21 @@ class SchedulerServiceTest extends TestCase
$this->settingsMock = Mockery::mock(Settings::class); $this->settingsMock = Mockery::mock(Settings::class);
$this->loggerMock = Mockery::mock(LoggerInterface::class); $this->loggerMock = Mockery::mock(LoggerInterface::class);
$registryMock = Mockery::mock(ScheduleJobRegistry::class);
$registryMock->shouldReceive('loadJobsFromDatabase')->andReturnNull();
$registryMock->shouldReceive('getJobs')->andReturn([]);
$scheduledJobMock = Mockery::mock(ScheduledJob::class);
$scheduledJobMock->shouldReceive('clearFailedInfo')->zeroOrMoreTimes()->andReturnNull();
$scheduledJobMock->shouldReceive('updateLastSuccessAt')->zeroOrMoreTimes()->andReturnNull();
$scheduledJobMock->shouldReceive('updateFailedAt')->zeroOrMoreTimes()->andReturnNull();
$this->scheduler = new SchedulerService( $this->scheduler = new SchedulerService(
$this->loggerMock, $this->loggerMock,
$this->cacheMock, $this->cacheMock,
$this->app, $this->settingsMock,
$this->settingsMock $registryMock,
$scheduledJobMock
); );
} }
@@ -91,32 +102,33 @@ class SchedulerServiceTest extends TestCase
->with('scheduler.global_lock', 1, 300) ->with('scheduler.global_lock', 1, 300)
->once(); ->once();
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once();
$this->cacheMock->shouldReceive('set') $this->cacheMock->shouldReceive('set')
->with('scheduler.global_last_run', Mockery::type('int')) ->with('scheduler.global_last_run', Mockery::type('int'))
->once(); ->once();
// Mock registry and job // Mock registry and job
$registryMock = Mockery::mock(ScheduleJobRegistry::class); $registryMock = Mockery::mock(ScheduleJobRegistry::class);
$registryMock->shouldReceive('loadJobsFromDatabase')->andReturnNull();
$jobMock = Mockery::mock(Job::class); $jobMock = Mockery::mock(Job::class);
$jobMock->shouldReceive('getName')->andReturn('TestJob'); $jobMock->shouldReceive('getName')->andReturn('TestJob');
$jobMock->shouldReceive('getId')->andReturn('test_job_id'); $jobMock->shouldReceive('getId')->andReturn(42);
$jobMock->shouldReceive('isDue')->andReturn(true); $jobMock->shouldReceive('isDue')->andReturn(true);
// Job has not run recently (getLastRun returns null) // Job has not run recently (getLastRun returns null)
$this->cacheMock->shouldReceive('get') $this->cacheMock->shouldReceive('get')
->with('scheduler.last_run.test_job_id') ->with('scheduler.last_run.42')
->andReturn(null); ->andReturn(null);
// Job is locked // Job is locked
$this->cacheMock->shouldReceive('get') $this->cacheMock->shouldReceive('get')
->with('scheduler.lock.test_job_id') ->with('scheduler.lock.42')
->andReturn('1'); ->andReturn('1');
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once();
$registryMock->shouldReceive('getJobs')->andReturn([$jobMock]); $registryMock->shouldReceive('getJobs')->andReturn([$jobMock]);
// Logger should not be called for this test // Logger should not be called for this test
@@ -147,32 +159,33 @@ class SchedulerServiceTest extends TestCase
->with('scheduler.global_lock', 1, 300) ->with('scheduler.global_lock', 1, 300)
->once(); ->once();
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once();
$this->cacheMock->shouldReceive('set') $this->cacheMock->shouldReceive('set')
->with('scheduler.global_last_run', Mockery::type('int')) ->with('scheduler.global_last_run', Mockery::type('int'))
->once(); ->once();
// Mock registry and job // Mock registry and job
$registryMock = Mockery::mock(ScheduleJobRegistry::class); $registryMock = Mockery::mock(ScheduleJobRegistry::class);
$registryMock->shouldReceive('loadJobsFromDatabase')->andReturnNull();
$jobMock = Mockery::mock(Job::class); $jobMock = Mockery::mock(Job::class);
$jobMock->shouldReceive('getName')->andReturn('TestJob'); $jobMock->shouldReceive('getName')->andReturn('TestJob');
$jobMock->shouldReceive('getId')->andReturn('test_job_id'); $jobMock->shouldReceive('getId')->andReturn(42);
$jobMock->shouldReceive('isDue')->andReturn(true); $jobMock->shouldReceive('isDue')->andReturn(true);
$this->cacheMock->shouldReceive('get') $this->cacheMock->shouldReceive('get')
->with('scheduler.lock.test_job_id') ->with('scheduler.lock.42')
->andReturn(null); // Not locked ->andReturn(null); // Not locked
// Job was recently executed (same minute) // Job was recently executed (same minute)
$recentTime = time(); $recentTime = time();
$this->cacheMock->shouldReceive('get') $this->cacheMock->shouldReceive('get')
->with('scheduler.last_run.test_job_id') ->with('scheduler.last_run.42')
->andReturn($recentTime); ->andReturn($recentTime);
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once();
$registryMock->shouldReceive('getJobs')->andReturn([$jobMock]); $registryMock->shouldReceive('getJobs')->andReturn([$jobMock]);
// Inject registry for testing // Inject registry for testing
@@ -200,22 +213,23 @@ class SchedulerServiceTest extends TestCase
->with('scheduler.global_lock', 1, 300) ->with('scheduler.global_lock', 1, 300)
->once(); ->once();
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once();
$this->cacheMock->shouldReceive('set') $this->cacheMock->shouldReceive('set')
->with('scheduler.global_last_run', Mockery::type('int')) ->with('scheduler.global_last_run', Mockery::type('int'))
->once(); ->once();
// Mock registry and job // Mock registry and job
$registryMock = Mockery::mock(ScheduleJobRegistry::class); $registryMock = Mockery::mock(ScheduleJobRegistry::class);
$registryMock->shouldReceive('loadJobsFromDatabase')->andReturnNull();
$jobMock = Mockery::mock(Job::class); $jobMock = Mockery::mock(Job::class);
$jobMock->shouldReceive('getName')->andReturn('TestJob'); $jobMock->shouldReceive('getName')->andReturn('TestJob');
$jobMock->shouldReceive('getId')->andReturn('test_job_id'); $jobMock->shouldReceive('getId')->andReturn(42);
$jobMock->shouldReceive('isDue')->andReturn(false); // Not due $jobMock->shouldReceive('isDue')->andReturn(false); // Not due
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once();
$registryMock->shouldReceive('getJobs')->andReturn([$jobMock]); $registryMock->shouldReceive('getJobs')->andReturn([$jobMock]);
// Inject registry for testing // Inject registry for testing
@@ -243,42 +257,43 @@ class SchedulerServiceTest extends TestCase
->with('scheduler.global_lock', 1, 300) ->with('scheduler.global_lock', 1, 300)
->once(); ->once();
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once();
$this->cacheMock->shouldReceive('set') $this->cacheMock->shouldReceive('set')
->with('scheduler.global_last_run', Mockery::type('int')) ->with('scheduler.global_last_run', Mockery::type('int'))
->once(); ->once();
// Mock registry and job // Mock registry and job
$registryMock = Mockery::mock(ScheduleJobRegistry::class); $registryMock = Mockery::mock(ScheduleJobRegistry::class);
$registryMock->shouldReceive('loadJobsFromDatabase')->andReturnNull();
$jobMock = Mockery::mock(Job::class); $jobMock = Mockery::mock(Job::class);
$jobMock->shouldReceive('getName')->andReturn('TestJob'); $jobMock->shouldReceive('getName')->andReturn('TestJob');
$jobMock->shouldReceive('getId')->andReturn('test_job_id'); $jobMock->shouldReceive('getId')->andReturn(42);
$jobMock->shouldReceive('isDue')->andReturn(true); $jobMock->shouldReceive('isDue')->andReturn(true);
$jobMock->shouldReceive('run')->once(); $jobMock->shouldReceive('run')->once();
$this->cacheMock->shouldReceive('get') $this->cacheMock->shouldReceive('get')
->with('scheduler.lock.test_job_id') ->with('scheduler.lock.42')
->andReturn(null); ->andReturn(null);
$this->cacheMock->shouldReceive('get') $this->cacheMock->shouldReceive('get')
->with('scheduler.last_run.test_job_id') ->with('scheduler.last_run.42')
->andReturn(null); // Never ran before ->andReturn(null); // Never ran before
// Lock and unlock operations // Lock and unlock operations
$this->cacheMock->shouldReceive('set') $this->cacheMock->shouldReceive('set')
->with('scheduler.lock.test_job_id', 1, 1800) ->with('scheduler.lock.42', 1, 1800)
->once(); ->once();
$this->cacheMock->shouldReceive('delete') $this->cacheMock->shouldReceive('delete')
->with('scheduler.lock.test_job_id') ->with('scheduler.lock.42')
->once(); ->once();
$this->cacheMock->shouldReceive('set') $this->cacheMock->shouldReceive('set')
->with('scheduler.last_run.test_job_id', Mockery::type('int')) ->with('scheduler.last_run.42', Mockery::type('int'))
->once();
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once(); ->once();
$registryMock->shouldReceive('getJobs')->andReturn([$jobMock]); $registryMock->shouldReceive('getJobs')->andReturn([$jobMock]);
@@ -320,46 +335,43 @@ class SchedulerServiceTest extends TestCase
->with('scheduler.global_lock', 1, 300) ->with('scheduler.global_lock', 1, 300)
->once(); ->once();
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once();
$this->cacheMock->shouldReceive('set') $this->cacheMock->shouldReceive('set')
->with('scheduler.global_last_run', Mockery::type('int')) ->with('scheduler.global_last_run', Mockery::type('int'))
->once(); ->once();
// Mock registry and job // Mock registry and job
$registryMock = Mockery::mock(ScheduleJobRegistry::class); $registryMock = Mockery::mock(ScheduleJobRegistry::class);
$registryMock->shouldReceive('loadJobsFromDatabase')->andReturnNull();
$jobMock = Mockery::mock(Job::class); $jobMock = Mockery::mock(Job::class);
$jobMock->shouldReceive('getName')->andReturn('TestJob'); $jobMock->shouldReceive('getName')->andReturn('TestJob');
$jobMock->shouldReceive('getId')->andReturn('test_job_id'); $jobMock->shouldReceive('getId')->andReturn(42);
$jobMock->shouldReceive('isDue')->andReturn(true); $jobMock->shouldReceive('isDue')->andReturn(true);
$jobMock->shouldReceive('run')->andThrow(new \Exception('Job failed')); $jobMock->shouldReceive('run')->andThrow(new \Exception('Job failed'));
$this->cacheMock->shouldReceive('get') $this->cacheMock->shouldReceive('get')
->with('scheduler.lock.test_job_id') ->with('scheduler.lock.42')
->andReturn(null); ->andReturn(null);
$this->cacheMock->shouldReceive('get') $this->cacheMock->shouldReceive('get')
->with('scheduler.last_run.test_job_id') ->with('scheduler.last_run.42')
->andReturn(null); ->andReturn(null);
// Lock and unlock operations // Lock and unlock operations
$this->cacheMock->shouldReceive('set') $this->cacheMock->shouldReceive('set')
->with('scheduler.lock.test_job_id', 1, 1800) ->with('scheduler.lock.42', 1, 1800)
->once(); ->once();
$this->cacheMock->shouldReceive('delete') $this->cacheMock->shouldReceive('delete')
->with('scheduler.lock.test_job_id') ->with('scheduler.lock.42')
->once(); ->once();
$this->cacheMock->shouldReceive('set') $this->cacheMock->shouldReceive('set')
->with('scheduler.last_failure.test_job_id', Mockery::type('int')) ->with('scheduler.last_run.42', Mockery::type('int'))
->once(); ->once();
$this->cacheMock->shouldReceive('set') $this->cacheMock->shouldReceive('delete')
->with('scheduler.last_failure_msg.test_job_id', 'Job failed') ->with('scheduler.global_lock')
->once(); ->once();
$registryMock->shouldReceive('getJobs')->andReturn([$jobMock]); $registryMock->shouldReceive('getJobs')->andReturn([$jobMock]);

View File

@@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Support;
use App\Support\Utf8Cleaner;
use PHPUnit\Framework\TestCase;
class Utf8CleanerTest extends TestCase
{
/** @test */
public function returns_non_string_values_as_is(): void
{
$this->assertSame(123, Utf8Cleaner::clean(123));
$this->assertSame(12.3, Utf8Cleaner::clean(12.3));
$this->assertSame(true, Utf8Cleaner::clean(true));
$this->assertSame(false, Utf8Cleaner::clean(false));
$this->assertNull(Utf8Cleaner::clean(null));
}
/** @test */
public function leaves_valid_utf8_strings_unchanged(): void
{
$value = 'Привет, мир! 😀';
$this->assertSame($value, Utf8Cleaner::clean($value));
}
/** @test */
public function cleans_strings_with_invalid_utf8_bytes(): void
{
// Строка с заведомо некорректной UTF-8 последовательностью.
$invalid = "\xF0\x28\x8C\x28";
$cleaned = Utf8Cleaner::clean($invalid);
$this->assertIsString($cleaned);
$this->assertNotSame($invalid, $cleaned);
$json = json_encode($cleaned);
$this->assertNotFalse($json, 'Resulting string must be valid UTF-8 for json_encode');
}
/** @test */
public function cleans_nested_arrays_recursively(): void
{
$invalid = "\xF0\x28\x8C\x28";
$data = [
'foo' => 'bar',
'nested' => [
'value' => $invalid,
],
];
$cleaned = Utf8Cleaner::clean($data);
$this->assertIsArray($cleaned);
$this->assertSame('bar', $cleaned['foo']);
$this->assertIsString($cleaned['nested']['value']);
$this->assertNotSame($invalid, $cleaned['nested']['value']);
$json = json_encode($cleaned);
$this->assertNotFalse($json);
}
/** @test */
public function cleans_objects_recursively(): void
{
$invalid = "\xF0\x28\x8C\x28";
$object = new class {
public string $name = 'test';
public array $meta = [];
};
$object->meta['invalid'] = $invalid;
$cleaned = Utf8Cleaner::clean($object);
$this->assertSame('test', $cleaned->name);
$this->assertIsString($cleaned->meta['invalid']);
$this->assertNotSame($invalid, $cleaned->meta['invalid']);
$json = json_encode($cleaned);
$this->assertNotFalse($json);
}
}

View File

@@ -1,51 +0,0 @@
#!/usr/bin/env bash
# Создаёт симлинк install.xml в system/ OpenCart внутри контейнера.
# OpenCart при «Обновить» (Расширения → Модификации) подхватывает файлы
# DIR_SYSTEM . '*.ocmod.xml' и применяет их своей логикой — так же, как на сервере.
#
# Использование:
# ./scripts/apply_ocmod.sh
#
# Переменные (по умолчанию из docker-compose.yaml):
# CONTAINER — сервис/контейнер (по умолчанию web)
# TARGET_DIR — каталог system в контейнере (по умолчанию /web/upload/system)
# SOURCE — путь к install.xml в контейнере (по умолчанию /module/oc_telegram_shop/ocmod/install.xml)
#
# После запуска: открой админку → Расширения → Модификации → нажми «Обновить».
set -euo pipefail
CONTAINER="${CONTAINER:-web}"
TARGET_DIR="${TARGET_DIR:-/web/upload/system}"
SOURCE="${SOURCE:-/module/oc_telegram_shop/install.xml}"
TARGET="$TARGET_DIR/telecart.ocmod.xml"
if ! docker compose exec "$CONTAINER" test -f "$SOURCE" 2>/dev/null; then
echo "Ошибка: в контейнере $CONTAINER не найден $SOURCE" >&2
echo "Проверь, что том ./module примонтирован в контейнер." >&2
exit 1
fi
if ! docker compose exec "$CONTAINER" test -d "$TARGET_DIR" 2>/dev/null; then
echo "Ошибка: в контейнере $CONTAINER не найден каталог $TARGET_DIR" >&2
exit 1
fi
docker compose exec "$CONTAINER" bash -c "
if [ -e '$TARGET' ]; then
if [ -L '$TARGET' ]; then
current=\$(readlink '$TARGET')
if [ \"\$current\" = '$SOURCE' ]; then
echo 'Симлинк уже указывает на модуль: $TARGET'
exit 0
fi
fi
rm -f '$TARGET'
fi
ln -sf '$SOURCE' '$TARGET'
echo 'Симлинк создан в контейнере: $TARGET$SOURCE'
"
echo ""
echo "Дальше: открой админку OpenCart → Расширения → Модификации → нажми «Обновить»."
echo "Модификация применится той же логикой, что и при установке на сервере."