diff --git a/.cursor/agents.md b/.cursor/agents.md new file mode 100644 index 0000000..23e58de --- /dev/null +++ b/.cursor/agents.md @@ -0,0 +1,39 @@ +# 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** + +### Запрещено + +- Хардкод значений (используй конфиги/настройки) +- Игнорирование обработки ошибок +- Создание циклических зависимостей + +Для разработки 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` diff --git a/.cursor/config.json b/.cursor/config.json new file mode 100644 index 0000000..c7970c6 --- /dev/null +++ b/.cursor/config.json @@ -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 + } +} + diff --git a/.cursor/prompts/api-generation.md b/.cursor/prompts/api-generation.md new file mode 100644 index 0000000..68b9fab --- /dev/null +++ b/.cursor/prompts/api-generation.md @@ -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 (используй только индексы) + +Следуй структуре существующих миграций. +``` + diff --git a/.cursor/prompts/documentation.md b/.cursor/prompts/documentation.md new file mode 100644 index 0000000..33a3c8d --- /dev/null +++ b/.cursor/prompts/documentation.md @@ -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 +``` + diff --git a/.cursor/prompts/refactoring.md b/.cursor/prompts/refactoring.md new file mode 100644 index 0000000..37f316a --- /dev/null +++ b/.cursor/prompts/refactoring.md @@ -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 где необходимо +``` + diff --git a/.cursor/prompts/testing.md b/.cursor/prompts/testing.md new file mode 100644 index 0000000..87cb188 --- /dev/null +++ b/.cursor/prompts/testing.md @@ -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. Добавь тесты для обработки ошибок +``` + diff --git a/.cursor/rules/architecture.md b/.cursor/rules/architecture.md new file mode 100644 index 0000000..9464a3e --- /dev/null +++ b/.cursor/rules/architecture.md @@ -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(); +}); +``` + diff --git a/.cursor/rules/javascript.md b/.cursor/rules/javascript.md new file mode 100644 index 0000000..6672b3e --- /dev/null +++ b/.cursor/rules/javascript.md @@ -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 + +``` + +### 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 + +``` + +### Emits + +```vue + +``` + +## 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 + + + +``` + +### 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 { + return apiGet(`customers/${id}`); +} +``` + diff --git a/.cursor/rules/php.md b/.cursor/rules/php.md new file mode 100644 index 0000000..7d8b3e1 --- /dev/null +++ b/.cursor/rules/php.md @@ -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 + '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 {} +``` + diff --git a/.cursor/rules/vue.md b/.cursor/rules/vue.md new file mode 100644 index 0000000..e501897 --- /dev/null +++ b/.cursor/rules/vue.md @@ -0,0 +1,370 @@ +# Vue.js 3 Rules + +## Component Structure + +### Template + +```vue + + + + + {{ title }} + + + + + + + + + +``` + +### Script Setup + +```vue + +``` + +### Styles + +```vue + +``` + +## Component Naming + +```vue + + + + + + + +``` + +## Props + +```vue + +``` + +## Emits + +```vue + +``` + +## Reactive State + +```vue + +``` + +## Computed Properties + +```vue + +``` + +## Event Handlers + +```vue + + + + + + + + +``` + +## Conditional Rendering + +```vue + + + + + + + + + + + + + + + + +``` + +## Lists + +```vue + + + + {{ item.name }} + + + + + {{ item.name }} + + +``` + +## Form Handling + +```vue + + + + + + + + + + + + +``` + +## PrimeVue Components + +```vue + + + + + + +``` + +## Styling + +```vue + +``` + +## Composition Functions + +```vue + +``` + +## Error Handling + +```vue + +``` + diff --git a/.cursorignore b/.cursorignore index 0c55918..5513664 100644 --- a/.cursorignore +++ b/.cursorignore @@ -22,8 +22,77 @@ dist-ssr *.sln *.sw? -src/* +# Cursor ignore patterns + frontend/spa/node_modules +frontend/admin/node_modules module/oc_telegram_shop/upload/oc_telegram_shop/vendor module/oc_telegram_shop/upload/image module/oc_telegram_shop/upload/oc_telegram_shop/.phpunit.cache + +# Dependencies +node_modules/ +vendor/ +composer.lock +package-lock.json +yarn.lock + +# Build outputs +dist/ +build/ +*.phar + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Logs +*.log +logs/ +storage/logs/ + +# Cache +storage/cache/ +.cache/ + +# Environment +.env +.env.local +.env.*.local + +# Database +*.sql +*.sqlite +*.db + +# Temporary files +tmp/ +temp/ +*.tmp + +# OS +.DS_Store +Thumbs.db + +# OpenCart specific +src/upload/system/ +src/upload/image/cache/ +src/storage/ + +# Test fixtures (large files) +tests/fixtures/*.sql +tests/fixtures/*.json + +# Documentation builds +docs/_build/ + +# Coverage reports +coverage/ +.nyc_output/ + +# Docker +docker-compose.override.yml + diff --git a/Makefile b/Makefile index 2de186c..2180165 100644 --- a/Makefile +++ b/Makefile @@ -49,6 +49,9 @@ lint: phpcs: docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpcs --standard=PSR12 bastion framework src" +phpcbf: + docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpcbf --standard=PSR12 bastion framework src" + test: docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpunit --testdox tests/" diff --git a/frontend/admin/src/App.vue b/frontend/admin/src/App.vue index 5cc84d1..7c72a05 100644 --- a/frontend/admin/src/App.vue +++ b/frontend/admin/src/App.vue @@ -34,6 +34,10 @@ Форма заказа + + Telegram Покупатели + + Журнал событий diff --git a/frontend/admin/src/assets/main.css b/frontend/admin/src/assets/main.css index c475b30..bfc17d8 100644 --- a/frontend/admin/src/assets/main.css +++ b/frontend/admin/src/assets/main.css @@ -66,3 +66,11 @@ ul.formkit-options label { ul.formkit-options input[type="radio"] { position: absolute; } + +input.p-checkbox-input { + position: absolute; +} + +label { + margin-bottom: 0; +} diff --git a/frontend/admin/src/main.js b/frontend/admin/src/main.js index 7ceced4..1d9cb4c 100644 --- a/frontend/admin/src/main.js +++ b/frontend/admin/src/main.js @@ -36,6 +36,49 @@ onReady(async () => { options: { cssLayer: false, // если используешь Tailwind, отключает layering }, + }, + locale: { + startsWith: 'Начинается с', + contains: 'Содержит', + notContains: 'Не содержит', + endsWith: 'Заканчивается на', + equals: 'Равно', + notEquals: 'Не равно', + noFilter: 'Без фильтра', + lt: 'Меньше чем', + lte: 'Меньше или равно', + gt: 'Больше чем', + gte: 'Больше или равно', + dateIs: 'Дата равна', + dateIsNot: 'Дата не равна', + dateBefore: 'Дата до', + dateAfter: 'Дата после', + clear: 'Очистить', + apply: 'Применить', + matchAll: 'Совпадает со всеми', + matchAny: 'Совпадает с любым', + addRule: 'Добавить правило', + removeRule: 'Удалить правило', + accept: 'Да', + reject: 'Нет', + choose: 'Выбрать', + upload: 'Загрузить', + cancel: 'Отмена', + dayNames: ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'], + dayNamesShort: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'], + dayNamesMin: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'], + monthNames: ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'], + monthNamesShort: ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек'], + today: 'Сегодня', + weekHeader: 'Неделя', + firstDayOfWeek: 1, + dateFormat: 'dd.mm.yy', + weak: 'Слабый', + medium: 'Средний', + strong: 'Сильный', + passwordPrompt: 'Введите пароль', + emptyMessage: 'Нет доступных записей', + emptyFilterMessage: 'Нет доступных записей' } }); app.use(ToastService); diff --git a/frontend/admin/src/router/index.js b/frontend/admin/src/router/index.js index b5d388f..222019c 100644 --- a/frontend/admin/src/router/index.js +++ b/frontend/admin/src/router/index.js @@ -8,19 +8,21 @@ import StoreView from "@/views/StoreView.vue"; import MainPageView from "@/views/MainPageView.vue"; import LogsView from "@/views/LogsView.vue"; import FormBuilderView from "@/views/FormBuilderView.vue"; +import CustomersView from "@/views/CustomersView.vue"; const router = createRouter({ history: createMemoryHistory(), routes: [ {path: '/', name: 'general', component: GeneralView}, - {path: '/orders', name: 'orders', component: OrdersView}, - {path: '/texts', name: 'texts', component: TextsView}, - {path: '/telegram', name: 'telegram', component: TelegramView}, - {path: '/metrics', name: 'metrics', component: MetricsView}, - {path: '/store', name: 'store', component: StoreView}, - {path: '/mainpage', name: 'mainpage', component: MainPageView}, - {path: '/logs', name: 'logs', component: LogsView}, + {path: '/customers', name: 'customers', component: CustomersView}, {path: '/formbuilder', name: 'formbuilder', component: FormBuilderView}, + {path: '/logs', name: 'logs', component: LogsView}, + {path: '/mainpage', name: 'mainpage', component: MainPageView}, + {path: '/metrics', name: 'metrics', component: MetricsView}, + {path: '/orders', name: 'orders', component: OrdersView}, + {path: '/store', name: 'store', component: StoreView}, + {path: '/telegram', name: 'telegram', component: TelegramView}, + {path: '/texts', name: 'texts', component: TextsView}, ], }); diff --git a/frontend/admin/src/views/CustomersView.vue b/frontend/admin/src/views/CustomersView.vue new file mode 100644 index 0000000..106835e --- /dev/null +++ b/frontend/admin/src/views/CustomersView.vue @@ -0,0 +1,549 @@ + + + + + + + + + + + + + + + toggleColumn(col, val)" + :binary="true" + /> + {{ col.header }} + + + + + + + + + + + + + + + + + + + + + + + {{ data.id }} + {{ data.telegram_user_id }} + + + + + + + @{{ data.username }} + — + + + {{ data.first_name || '—' }} + {{ data.last_name || '—' }} + + + {{ data.language_code.toUpperCase() }} + + — + + + + — + + + {{ data.oc_customer_id }} + — + + + + {{ formatDate(data.last_seen_at) }} + + — + + + {{ formatDate(data.created_at) }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + Нет данных о кастомерах + + Пользователи появятся здесь после первого входа в Telegram Mini App + + + + + + + + Загрузка данных... + + + + + + + + Получатель: + + + + @{{ selectedCustomer.username }} + + + {{ selectedCustomer.first_name }} {{ selectedCustomer.last_name }} + + + ID: {{ selectedCustomer.telegram_user_id }} + + + Пользователь не разрешил писать ему в PM + + + + + + + Сообщение: + + + + + + + + + + + + + + + diff --git a/frontend/spa/src/main.js b/frontend/spa/src/main.js index ab7671c..f7aa5d7 100644 --- a/frontend/spa/src/main.js +++ b/frontend/spa/src/main.js @@ -8,6 +8,7 @@ import {useSettingsStore} from "@/stores/SettingsStore.js"; import ApplicationError from "@/ApplicationError.vue"; import AppMetaInitializer from "@/utils/AppMetaInitializer.ts"; import {injectYaMetrika} from "@/utils/yaMetrika.js"; +import {saveTelegramCustomer} from "@/utils/ftch.js"; import {register} from 'swiper/element/bundle'; import 'swiper/element/bundle'; @@ -44,6 +45,20 @@ settings.load() throw new Error('App disabled (maintenance mode)'); } }) + .then(async () => { + // Сохраняем данные Telegram-пользователя в базу данных + const userData = window.Telegram?.WebApp?.initDataUnsafe?.user; + if (userData) { + try { + console.debug('[Init] Saving Telegram customer data'); + await saveTelegramCustomer(userData); + console.debug('[Init] Telegram customer data saved successfully'); + } catch (error) { + // Не прерываем загрузку приложения, если не удалось сохранить пользователя + console.warn('[Init] Failed to save Telegram customer data:', error); + } + } + }) .then(() => blocks.processBlocks(settings.mainpage_blocks)) .then(async () => { console.debug('Load default filters for the main page'); diff --git a/frontend/spa/src/utils/ftch.js b/frontend/spa/src/utils/ftch.js index b0a8d2c..c036c21 100644 --- a/frontend/spa/src/utils/ftch.js +++ b/frontend/spa/src/utils/ftch.js @@ -96,4 +96,15 @@ export async function processBlock(block) { return await ftch('processBlock', null, block); } +/** + * Сохранить или обновить данные Telegram-пользователя + * @param {Object} userData - Данные пользователя из Telegram.WebApp.initDataUnsafe.user + * @returns {Promise} + */ +export async function saveTelegramCustomer(userData) { + return await ftch('saveTelegramCustomer', null, { + user: userData, + }); +} + export default ftch; diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/bastion/Handlers/SendMessageHandler.php b/module/oc_telegram_shop/upload/oc_telegram_shop/bastion/Handlers/SendMessageHandler.php new file mode 100644 index 0000000..b0e8e59 --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/bastion/Handlers/SendMessageHandler.php @@ -0,0 +1,126 @@ +telegramService = $telegramService; + $this->telegramCustomerModel = $telegramCustomerModel; + $this->logger = $logger; + } + + /** + * Отправить сообщение Telegram-пользователю + * + * @param Request $request HTTP запрос с id (ID записи в таблице) и message + * @return JsonResponse JSON ответ с результатом операции + * @throws TelegramCustomerNotFoundException Если пользователь не найден + * @throws TelegramCustomerWriteNotAllowedException Если пользователь не разрешил писать в PM + * @throws RuntimeException Если данные невалидны + * @throws \Exception + * @throws GuzzleException + */ + public function sendMessage(Request $request): JsonResponse + { + $customerId = $this->extractCustomerId($request); + $message = $this->extractMessage($request); + + // Находим запись по ID + $customer = $this->telegramCustomerModel->findById($customerId); + if (! $customer) { + throw new TelegramCustomerNotFoundException($customerId); + } + + $telegramUserId = (int) $customer['telegram_user_id']; + + // Проверяем, что пользователь разрешил писать ему в PM + if (! $customer['allows_write_to_pm']) { + throw new TelegramCustomerWriteNotAllowedException($telegramUserId); + } + + // Отправляем сообщение (telegram_user_id используется как chat_id) + // Используем пустую строку для parse_mode чтобы отправлять обычный текст + $this->telegramService->sendMessage( + $telegramUserId, + $message, + [], + \Openguru\OpenCartFramework\Telegram\Enums\ChatAction::TYPING, + '' // Обычный текст без форматирования + ); + + $this->logger->info('Message sent to Telegram user', [ + 'oc_customer_id' => $customerId, + 'telegram_user_id' => $telegramUserId, + 'message_length' => strlen($message), + ]); + + return new JsonResponse([ + 'success' => true, + 'message' => 'Message sent successfully', + ]); + } + + /** + * Извлечь ID записи из запроса + * + * @param Request $request HTTP запрос + * @return int ID записи в таблице telecart_customers + * @throws RuntimeException Если ID отсутствует или невалиден + */ + private function extractCustomerId(Request $request): int + { + $jsonData = $request->json(); + $customerId = isset($jsonData['id']) ? (int) $jsonData['id'] : 0; + + if ($customerId <= 0) { + throw new RuntimeException('Customer ID is required and must be positive'); + } + + return $customerId; + } + + /** + * Извлечь сообщение из запроса + * + * @param Request $request HTTP запрос + * @return string Текст сообщения + * @throws RuntimeException Если сообщение отсутствует или пустое + */ + private function extractMessage(Request $request): string + { + $jsonData = $request->json(); + $message = isset($jsonData['message']) ? trim($jsonData['message']) : ''; + + if (empty($message)) { + throw new RuntimeException('Message is required and cannot be empty'); + } + + return $message; + } +} diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/bastion/Handlers/TelegramCustomersHandler.php b/module/oc_telegram_shop/upload/oc_telegram_shop/bastion/Handlers/TelegramCustomersHandler.php new file mode 100644 index 0000000..bca93da --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/bastion/Handlers/TelegramCustomersHandler.php @@ -0,0 +1,330 @@ +builder = $builder; + } + + /** + * Получить список Telegram-кастомеров с пагинацией, фильтрацией и сортировкой + * + * @param Request $request HTTP запрос с параметрами пагинации, сортировки и фильтров + * @return JsonResponse JSON ответ с данными и метаинформацией + */ + public function getCustomers(Request $request): JsonResponse + { + $page = max(1, (int) $request->json('page', self::DEFAULT_PAGE)); + $rows = max(1, (int) $request->json('rows', self::DEFAULT_ROWS)); + $first = ($page - 1) * $rows; + + $sortField = $request->json('sortField', self::DEFAULT_SORT_FIELD) ?? self::DEFAULT_SORT_FIELD; + $sortOrder = $this->normalizeSortOrder((string)$request->json('sortOrder', self::DEFAULT_SORT_ORDER)); + + $filters = $request->json('filters', []); + $globalFilter = Arr::get($filters, 'global.value'); + + // Создаем базовый query с фильтрами + $query = $this->buildBaseQuery(); + $this->applyFilters($query, $filters, $globalFilter); + + // Получаем общее количество записей + $countQuery = $this->buildCountQuery(); + $this->applyFilters($countQuery, $filters, $globalFilter); + $totalRecords = (int) ($countQuery->value('total') ?? 0); + + // Применяем сортировку и пагинацию + $customers = $query + ->orderBy($sortField, $sortOrder) + ->offset($first) + ->limit($rows) + ->get(); + + return new JsonResponse([ + 'data' => [ + 'data' => $this->mapToResponse($customers), + 'totalRecords' => $totalRecords, + ], + ]); + } + + /** + * Создать базовый query для выборки данных + * + * @return Builder + */ + private function buildBaseQuery(): Builder + { + return $this->builder->newQuery() + ->select([ + 'id', + 'telegram_user_id', + 'oc_customer_id', + 'username', + 'first_name', + 'last_name', + 'language_code', + 'is_premium', + 'allows_write_to_pm', + 'photo_url', + 'last_seen_at', + 'referral', + 'created_at', + 'updated_at', + ]) + ->from(self::TABLE_NAME); + } + + /** + * Создать query для подсчета общего количества записей + * + * @return Builder + */ + private function buildCountQuery(): Builder + { + return $this->builder->newQuery() + ->select([new RawExpression('COUNT(*) as total')]) + ->from(self::TABLE_NAME); + } + + /** + * Применить фильтры к query + * + * @param Builder $query Query builder + * @param array $filters Массив фильтров + * @param string|null $globalFilter Глобальный фильтр поиска + * @return void + */ + private function applyFilters(Builder $query, array $filters, ?string $globalFilter): void + { + // Применяем глобальный фильтр + if ($globalFilter) { + $this->applyGlobalFilter($query, $globalFilter); + } + + // Применяем фильтры по колонкам + $this->applyColumnFilters($query, $filters); + } + + /** + * Применить глобальный фильтр поиска + * + * @param Builder $query Query builder + * @param string $searchTerm Поисковый запрос + * @return void + */ + private function applyGlobalFilter(Builder $query, string $searchTerm): void + { + $query->whereNested(function ($q) use ($searchTerm) { + $q->where('telegram_user_id', 'LIKE', "%{$searchTerm}%") + ->orWhere('username', 'LIKE', "%{$searchTerm}%") + ->orWhere('first_name', 'LIKE', "%{$searchTerm}%") + ->orWhere('last_name', 'LIKE', "%{$searchTerm}%") + ->orWhere('language_code', 'LIKE', "%{$searchTerm}%"); + }); + } + + /** + * Применить фильтры по колонкам + * + * @param Builder $query Query builder + * @param array $filters Массив фильтров + * @return void + */ + private function applyColumnFilters(Builder $query, array $filters): void + { + foreach ($filters as $field => $filter) { + if ($field === 'global') { + continue; + } + + // Обработка сложных фильтров (constraints) + if (isset($filter['constraints']) && is_array($filter['constraints'])) { + $this->applyConstraintFilters($query, $field, $filter); + continue; + } + + // Обработка простых фильтров (обратная совместимость) + if (! isset($filter['value']) || $filter['value'] === null || $filter['value'] === '') { + continue; + } + + $value = $filter['value']; + $matchMode = Arr::get($filter, 'matchMode', 'contains'); + + $this->applyColumnFilter($query, $field, $value, $matchMode); + } + } + + /** + * Применить сложные фильтры с условиями (AND/OR) + * + * @param Builder $query Query builder + * @param string $field Имя поля + * @param array $filter Данные фильтра + * @return void + */ + private function applyConstraintFilters(Builder $query, string $field, array $filter): void + { + $operator = strtolower($filter['operator'] ?? 'and'); + $constraints = $filter['constraints']; + + // Фильтруем пустые значения (но учитываем false как валидное значение для boolean полей) + $activeConstraints = array_filter($constraints, function ($constraint) { + if (!isset($constraint['value'])) { + return false; + } + $value = $constraint['value']; + // null означает "любой", пропускаем + if ($value === null) { + return false; + } + // Пустая строка пропускаем + if ($value === '') { + return false; + } + // false - валидное значение для boolean полей + return true; + }); + + if (empty($activeConstraints)) { + return; + } + + $query->whereNested(function ($q) use ($field, $activeConstraints, $operator) { + // Для первого элемента всегда используем where, чтобы начать группу + $first = true; + + foreach ($activeConstraints as $constraint) { + $value = $constraint['value']; + $matchMode = $constraint['matchMode'] ?? 'contains'; + + if ($first) { + $this->applyColumnFilter($q, $field, $value, $matchMode); + $first = false; + continue; + } + + if ($operator === 'or') { + $q->orWhere(function ($subQ) use ($field, $value, $matchMode) { + $this->applyColumnFilter($subQ, $field, $value, $matchMode); + }); + } else { + $this->applyColumnFilter($q, $field, $value, $matchMode); + } + } + }); + } + + /** + * Применить фильтр для одной колонки + * + * @param Builder $query Query builder + * @param string $field Имя поля + * @param mixed $value Значение фильтра + * @param string $matchMode Режим совпадения (contains, startsWith, endsWith, equals, notEquals) + * @return void + */ + private function applyColumnFilter(Builder $query, string $field, $value, string $matchMode): void + { + if (in_array($matchMode, ['contains', 'startsWith', 'endsWith'], true)) { + $likeValue = $this->buildLikeValue($value, $matchMode); + $query->where($field, 'LIKE', $likeValue); + } elseif ($matchMode === 'equals') { + $query->where($field, '=', $value); + } elseif ($matchMode === 'notEquals') { + $query->where($field, '!=', $value); + } elseif ($matchMode === 'dateIs') { + // Для точного совпадения даты используем диапазон от 00:00:00 до 23:59:59 + $date = date('Y-m-d', strtotime($value)); + $query->where($field, '>=', $date . ' 00:00:00') + ->where($field, '<=', $date . ' 23:59:59'); + } elseif ($matchMode === 'dateIsNot') { + // Для отрицания проверяем, что дата меньше начала дня ИЛИ больше конца дня + $date = date('Y-m-d', strtotime($value)); + $query->whereNested(function ($q) use ($field, $date) { + $q->where($field, '<', $date . ' 00:00:00') + ->orWhere($field, '>', $date . ' 23:59:59'); + }); + } elseif ($matchMode === 'dateBefore') { + $query->where($field, '<', date('Y-m-d 00:00:00', strtotime($value))); + } elseif ($matchMode === 'dateAfter') { + // "После" означает после конца указанного дня + $query->where($field, '>', date('Y-m-d 23:59:59', strtotime($value))); + } + } + + /** + * Построить значение для LIKE запроса + * + * @param string $value Значение + * @param string $matchMode Режим совпадения + * @return string + */ + private function buildLikeValue(string $value, string $matchMode): string + { + if ($matchMode === 'startsWith') { + return "{$value}%"; + } + + if ($matchMode === 'endsWith') { + return "%{$value}"; + } + + return "%{$value}%"; + } + + /** + * Нормализовать порядок сортировки + * + * @param string $sortOrder Порядок сортировки + * @return string 'ASC' или 'DESC' + */ + private function normalizeSortOrder(string $sortOrder): string + { + $normalized = strtoupper($sortOrder); + + return in_array($normalized, ['ASC', 'DESC'], true) ? $normalized : self::DEFAULT_SORT_ORDER; + } + + private function mapToResponse(array $customers): array + { + return array_map(static function (array $customer) { + return [ + 'id' => (int) $customer['id'], + 'telegram_user_id' => (int) $customer['telegram_user_id'], + 'oc_customer_id' => (int) $customer['oc_customer_id'], + 'username' => $customer['username'], + 'first_name' => $customer['first_name'], + 'last_name' => $customer['last_name'], + 'language_code' => $customer['language_code'], + 'is_premium' => filter_var($customer['is_premium'], FILTER_VALIDATE_BOOLEAN), + 'allows_write_to_pm' => filter_var($customer['allows_write_to_pm'], FILTER_VALIDATE_BOOLEAN), + 'photo_url' => $customer['photo_url'], + 'last_seen_at' => $customer['last_seen_at'], + 'referral' => $customer['referral'], + 'created_at' => $customer['created_at'], + 'updated_at' => $customer['updated_at'], + ]; + }, $customers); + } +} diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/bastion/routes.php b/module/oc_telegram_shop/upload/oc_telegram_shop/bastion/routes.php index 7eae492..70ec2a1 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/bastion/routes.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/bastion/routes.php @@ -4,27 +4,28 @@ use Bastion\Handlers\AutocompleteHandler; use Bastion\Handlers\DictionariesHandler; use Bastion\Handlers\FormsHandler; use Bastion\Handlers\LogsHandler; +use Bastion\Handlers\SendMessageHandler; use Bastion\Handlers\SettingsHandler; use Bastion\Handlers\StatsHandler; +use Bastion\Handlers\TelegramCustomersHandler; use Bastion\Handlers\TelegramHandler; return [ 'configureBotToken' => [SettingsHandler::class, 'configureBotToken'], - 'getChatId' => [TelegramHandler::class, 'getChatId'], - 'getSettingsForm' => [SettingsHandler::class, 'getSettingsForm'], - 'saveSettingsForm' => [SettingsHandler::class, 'saveSettingsForm'], - 'testTgMessage' => [TelegramHandler::class, 'testTgMessage'], - 'getProductsById' => [AutocompleteHandler::class, 'getProductsById'], - 'getCategoriesById' => [AutocompleteHandler::class, 'getCategoriesById'], - 'getDashboardStats' => [StatsHandler::class, 'getDashboardStats'], - 'tgGetMe' => [TelegramHandler::class, 'tgGetMe'], - - 'getCategories' => [DictionariesHandler::class, 'getCategories'], - 'getAutocompleteCategories' => [AutocompleteHandler::class, 'getCategories'], 'getAutocompleteCategoriesFlat' => [AutocompleteHandler::class, 'getCategoriesFlat'], - 'resetCache' => [SettingsHandler::class, 'resetCache'], - 'getLogs' => [LogsHandler::class, 'getLogs'], - + 'getCategories' => [DictionariesHandler::class, 'getCategories'], + 'getCategoriesById' => [AutocompleteHandler::class, 'getCategoriesById'], + 'getChatId' => [TelegramHandler::class, 'getChatId'], + 'getDashboardStats' => [StatsHandler::class, 'getDashboardStats'], 'getFormByAlias' => [FormsHandler::class, 'getFormByAlias'], + 'getLogs' => [LogsHandler::class, 'getLogs'], + 'getProductsById' => [AutocompleteHandler::class, 'getProductsById'], + 'getSettingsForm' => [SettingsHandler::class, 'getSettingsForm'], + 'getTelegramCustomers' => [TelegramCustomersHandler::class, 'getCustomers'], + 'resetCache' => [SettingsHandler::class, 'resetCache'], + 'saveSettingsForm' => [SettingsHandler::class, 'saveSettingsForm'], + 'sendMessageToCustomer' => [SendMessageHandler::class, 'sendMessage'], + 'testTgMessage' => [TelegramHandler::class, 'testTgMessage'], + 'tgGetMe' => [TelegramHandler::class, 'tgGetMe'], ]; diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/database/migrations/20260101000005_create_telecart_customers_table.php b/module/oc_telegram_shop/upload/oc_telegram_shop/database/migrations/20260101000005_create_telecart_customers_table.php new file mode 100644 index 0000000..e800c1d --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/database/migrations/20260101000005_create_telecart_customers_table.php @@ -0,0 +1,36 @@ +database->statement($sql); + } +}; + diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/Enums/TelegramHeader.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/Enums/TelegramHeader.php new file mode 100644 index 0000000..7108e9c --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/Enums/TelegramHeader.php @@ -0,0 +1,8 @@ +botToken = $botToken; $this->settings = $settings; $this->logger = $logger; + $this->initDataDecoder = $initDataDecoder; } + /** + * @throws TelegramInvalidSignatureException + */ public function validate(Request $request): void { if ($this->settings->config()->getApp()->isAppDebug()) { @@ -32,13 +43,15 @@ class SignatureValidator return; } - $initDataString = base64_decode($request->header('X-Telegram-Initdata')); - - if (! $initDataString) { - throw new TelegramInvalidSignatureException('Invalid Telegram signature!'); + if (! $request->header(TelegramHeader::INIT_DATA)) { + throw new TelegramInvalidSignatureException('Telegram Signature not exists.'); } - $data = $this->parseInitDataStringToArray($initDataString); + try { + $data = $this->initDataDecoder->decode($request->header(TelegramHeader::INIT_DATA)); + } catch (DecodeTelegramInitDataException $e) { + throw new TelegramInvalidSignatureException('Invalid Telegram Signature.', 500, $e); + } if (! isset($data['hash'])) { throw new TelegramInvalidSignatureException('Missing hash in init data'); @@ -55,26 +68,6 @@ class SignatureValidator } } - private function parseInitDataStringToArray(string $initData): array - { - parse_str($initData, $parsed); - - foreach ($parsed as $key => $value) { - if ($this->isValidJson($value)) { - $parsed[$key] = json_decode(urldecode($value), true); - } - } - - return $parsed; - } - - private function isValidJson(string $jsonString): bool - { - json_decode($jsonString); - - return (json_last_error() === JSON_ERROR_NONE); - } - private function getCheckString(array $data): string { unset($data['hash']); diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/TelegramInitDataDecoder.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/TelegramInitDataDecoder.php new file mode 100644 index 0000000..74e2eb9 --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/TelegramInitDataDecoder.php @@ -0,0 +1,60 @@ +parseInitDataStringToArray($initDataString); + } catch (JsonException $e) { + throw new DecodeTelegramInitDataException( + 'Error decoding Telegram init data JSON: ' . $e->getMessage(), + 500, + $e + ); + } + } + + /** + * @throws JsonException + */ + private function parseInitDataStringToArray(string $initData): array + { + parse_str($initData, $parsed); + + foreach ($parsed as $key => $value) { + if ($this->isValidJson($value)) { + $parsed[$key] = json_decode(urldecode($value), true, 512, JSON_THROW_ON_ERROR); + } else { + $parsed[$key] = $value; + } + } + + return $parsed; + } + + private function isValidJson(string $string): bool + { + try { + json_decode($string, true, 512, JSON_THROW_ON_ERROR); + + return true; + } catch (JsonException $e) { + return false; + } + } +} diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/TelegramServiceProvider.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/TelegramServiceProvider.php index 2da537a..c4d32a7 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/TelegramServiceProvider.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/TelegramServiceProvider.php @@ -21,6 +21,7 @@ class TelegramServiceProvider extends ServiceProvider return new SignatureValidator( $app->get(SettingsService::class), $app->get(LoggerInterface::class), + $app->get(TelegramInitDataDecoder::class), $app->getConfigValue('telegram.bot_token'), ); }); diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Exceptions/TelegramCustomerNotFoundException.php b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Exceptions/TelegramCustomerNotFoundException.php new file mode 100644 index 0000000..1fc3f50 --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Exceptions/TelegramCustomerNotFoundException.php @@ -0,0 +1,24 @@ +telegramCustomerService = $telegramCustomerService; + $this->logger = $logger; + $this->initDataDecoder = $initDataDecoder; + } + + /** + * Сохранить или обновить Telegram-пользователя + * + * @param Request $request HTTP запрос с данными пользователя + * @return JsonResponse JSON ответ с результатом операции + */ + public function saveOrUpdate(Request $request): JsonResponse + { + try { + $this->telegramCustomerService->saveOrUpdate( + $this->extractTelegramUserData($request) + ); + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } catch (Throwable $e) { + $this->logger->error('Could not save telegram customer data', ['exception' => $e]); + + return new JsonResponse([], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + /** + * Извлечь данные Telegram пользователя из запроса + * + * @param Request $request HTTP запрос + * @return array Данные пользователя + * @throws RuntimeException|DecodeTelegramInitDataException невозможно извлечь данные пользователя из Request + */ + private function extractTelegramUserData(Request $request): array + { + $telegramUserData = $request->json('user'); + + if (! $telegramUserData) { + $telegramUserData = $this->extractUserDataFromInitData($request); + } + + return $telegramUserData; + } + + /** + * @throws DecodeTelegramInitDataException + */ + private function extractUserDataFromInitData(Request $request): array + { + $raw = $request->header(TelegramHeader::INIT_DATA); + if (! $raw) { + throw new RuntimeException('No init data found in http request header'); + } + + $initData = $this->initDataDecoder->decode($raw); + + return Arr::get($initData, 'user'); + } +} diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Models/TelegramCustomer.php b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Models/TelegramCustomer.php new file mode 100644 index 0000000..c4a0aca --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Models/TelegramCustomer.php @@ -0,0 +1,123 @@ +database = $database; + $this->builder = $builder; + } + + /** + * Найти запись по ID + * + * @param int $id ID записи + * @return array|null Данные пользователя или null если не найдено + */ + public function findById(int $id): ?array + { + return $this->builder + ->newQuery() + ->select(['*']) + ->from(self::TABLE_NAME) + ->where('id', '=', $id) + ->firstOrNull(); + } + + /** + * Найти запись по Telegram user ID + * + * @param int $telegramUserId Telegram user ID + * @return array|null Данные пользователя или null если не найдено + */ + public function findByTelegramUserId(int $telegramUserId): ?array + { + return $this->builder + ->newQuery() + ->select(['*']) + ->from(self::TABLE_NAME) + ->where('telegram_user_id', '=', $telegramUserId) + ->firstOrNull(); + } + + /** + * Найти запись по oc_customer_id + * + * @param int $customerId ID покупателя в OpenCart + * @return array|null Данные пользователя или null если не найдено + */ + public function findByCustomerId(int $customerId): ?array + { + return $this->builder + ->newQuery() + ->select(['*']) + ->from(self::TABLE_NAME) + ->where('oc_customer_id', '=', $customerId) + ->firstOrNull(); + } + + /** + * Создать новую запись + * + * @param array $data Данные для создания записи + * @return int ID созданной записи + * @throws RuntimeException Если не удалось создать запись + */ + public function create(array $data): int + { + $data['created_at'] = date('Y-m-d H:i:s'); + $data['updated_at'] = date('Y-m-d H:i:s'); + + $success = $this->database->insert(self::TABLE_NAME, $data); + + if (! $success) { + $error = $this->database->getLastError(); + $errorMessage = $error ? $error[1] : 'Unknown error'; + throw new RuntimeException("Failed to insert telegram customer. Error: {$errorMessage}"); + } + + return $this->database->lastInsertId(); + } + + /** + * Обновить запись по Telegram user ID + * + * @param int $telegramUserId Telegram user ID + * @param array $data Данные для обновления + * @return bool true если обновление успешно + */ + public function updateByTelegramUserId(int $telegramUserId, array $data): bool + { + $data['updated_at'] = date('Y-m-d H:i:s'); + + return $this->builder->newQuery() + ->where('telegram_user_id', '=', $telegramUserId) + ->update(self::TABLE_NAME, $data); + } + + /** + * Обновить last_seen_at для пользователя + * + * @param int $telegramUserId Telegram user ID + * @return bool true если обновление успешно + */ + public function updateLastSeen(int $telegramUserId): bool + { + return $this->updateByTelegramUserId($telegramUserId, [ + 'last_seen_at' => date('Y-m-d H:i:s'), + ]); + } +} diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Services/TelegramCustomerService.php b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Services/TelegramCustomerService.php new file mode 100644 index 0000000..b766e32 --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Services/TelegramCustomerService.php @@ -0,0 +1,96 @@ +telegramCustomerModel = $telegramCustomerModel; + } + + /** + * Сохранить или обновить Telegram-пользователя + * + * @param array $telegramUserData Данные пользователя из Telegram.WebApp.initDataUnsafe + * @return void + * @throws RuntimeException Если данные невалидны или не удалось сохранить + */ + public function saveOrUpdate(array $telegramUserData): void + { + $telegramUserId = $this->extractTelegramUserId($telegramUserData); + $telegramCustomerData = $this->prepareCustomerData($telegramUserData, $telegramUserId); + + $existingRecord = $this->telegramCustomerModel->findByTelegramUserId($telegramUserId); + + if ($existingRecord) { + $this->telegramCustomerModel->updateByTelegramUserId($telegramUserId, $telegramCustomerData); + } else { + $this->telegramCustomerModel->create($telegramCustomerData); + } + } + + /** + * Извлечь Telegram user ID из данных + * + * @param array $telegramUserData Данные пользователя + * @return int Telegram user ID + * @throws RuntimeException Если ID отсутствует или невалиден + */ + private function extractTelegramUserId(array $telegramUserData): int + { + $telegramUserId = (int) Arr::get($telegramUserData, 'id'); + + if ($telegramUserId <= 0) { + throw new RuntimeException('Telegram user ID is required and must be positive'); + } + + return $telegramUserId; + } + + /** + * Подготовить данные для сохранения в БД + * + * @param array $telegramUserData Исходные данные пользователя + * @param int $telegramUserId Telegram user ID + * @return array Подготовленные данные + */ + private function prepareCustomerData(array $telegramUserData, int $telegramUserId): array + { + return [ + 'telegram_user_id' => $telegramUserId, + 'username' => Arr::get($telegramUserData, 'username'), + 'first_name' => Arr::get($telegramUserData, 'first_name'), + 'last_name' => Arr::get($telegramUserData, 'last_name'), + 'language_code' => Arr::get($telegramUserData, 'language_code'), + 'is_premium' => $this->convertToInt(Arr::get($telegramUserData, 'is_premium', false)), + 'allows_write_to_pm' => $this->convertToInt(Arr::get($telegramUserData, 'allows_write_to_pm', false)), + 'photo_url' => Arr::get($telegramUserData, 'photo_url'), + 'last_seen_at' => date('Y-m-d H:i:s'), + ]; + } + + /** + * Конвертировать булево значение в int для БД + * + * @param mixed $value Значение для конвертации + * @return int 1 или 0 + */ + private function convertToInt($value): int + { + return ($value === true || $value === 1 || $value === '1') ? 1 : 0; + } +} diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/src/routes.php b/module/oc_telegram_shop/upload/oc_telegram_shop/src/routes.php index c8930f6..aa5dfc2 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/src/routes.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/src/routes.php @@ -10,26 +10,22 @@ use App\Handlers\OrderHandler; use App\Handlers\ProductsHandler; use App\Handlers\SettingsHandler; use App\Handlers\TelegramHandler; +use App\Handlers\TelegramCustomerHandler; return [ - 'health' => [HealthCheckHandler::class, 'handle'], - - 'products' => [ProductsHandler::class, 'index'], - 'product_show' => [ProductsHandler::class, 'show'], - 'storeOrder' => [OrderHandler::class, 'store'], - 'filtersForMainPage' => [FiltersHandler::class, 'getFiltersForMainPage'], - 'categoriesList' => [CategoriesHandler::class, 'index'], - 'checkout' => [CartHandler::class, 'checkout'], + 'filtersForMainPage' => [FiltersHandler::class, 'getFiltersForMainPage'], 'getCart' => [CartHandler::class, 'index'], - - 'settings' => [SettingsHandler::class, 'index'], - 'manifest' => [SettingsHandler::class, 'manifest'], - 'testTgMessage' => [SettingsHandler::class, 'testTgMessage'], - - 'webhook' => [TelegramHandler::class, 'webhook'], - 'processBlock' => [BlocksHandler::class, 'processBlock'], - 'getForm' => [FormsHandler::class, 'getForm'], + 'health' => [HealthCheckHandler::class, 'handle'], + 'manifest' => [SettingsHandler::class, 'manifest'], + 'processBlock' => [BlocksHandler::class, 'processBlock'], + 'product_show' => [ProductsHandler::class, 'show'], + 'products' => [ProductsHandler::class, 'index'], + 'saveTelegramCustomer' => [TelegramCustomerHandler::class, 'saveOrUpdate'], + 'settings' => [SettingsHandler::class, 'index'], + 'storeOrder' => [OrderHandler::class, 'store'], + 'testTgMessage' => [SettingsHandler::class, 'testTgMessage'], + 'webhook' => [TelegramHandler::class, 'webhook'], ];